From 817055a9e1df3c6f53fcd423865e641ff4051f4f Mon Sep 17 00:00:00 2001 From: lucaronin Date: Thu, 5 Mar 2026 22:13:03 +0100 Subject: [PATCH] =?UTF-8?q?fix:=20make=20qmd/search=20work=20on=20fresh=20?= =?UTF-8?q?installs=20=E2=80=94=20auto-install,=20fix=20permissions,=20sig?= =?UTF-8?q?n=20binaries?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On fresh MacBook installs, the bundled qmd binary fails to run due to: missing execute permissions, macOS quarantine attributes, and no fallback when qmd is completely absent. This fix addresses all three issues: - Runtime: ensure +x permissions and remove quarantine on bundled qmd - Runtime: auto-install qmd via bun when binary not found anywhere - Build: ad-hoc code-sign qmd and .dylib files in bundle-qmd.sh - Build: create placeholder resource dirs so fresh clones build cleanly Co-Authored-By: Claude Opus 4.6 --- scripts/bundle-qmd.sh | 7 ++ src-tauri/build.rs | 10 ++ src-tauri/src/indexing.rs | 201 +++++++++++++++++++++++++++++++--- src-tauri/src/lib.rs | 39 ++++++- src/hooks/useIndexing.test.ts | 10 +- 5 files changed, 246 insertions(+), 21 deletions(-) diff --git a/scripts/bundle-qmd.sh b/scripts/bundle-qmd.sh index bad7ff75..eed67f92 100755 --- a/scripts/bundle-qmd.sh +++ b/scripts/bundle-qmd.sh @@ -116,6 +116,13 @@ export class LlamaChatSession { export const LlamaLogLevel = { Error: 0, Warn: 1, Info: 2, Debug: 3 }; STUB +# ---------- ad-hoc code signing (macOS) ---------- +if [[ "$(uname)" == "Darwin" ]] && command -v codesign &>/dev/null; then + echo "Ad-hoc signing bundled binaries..." + codesign --force --sign - "$OUT/qmd" 2>/dev/null && echo " ✓ qmd signed" || echo " ⚠ qmd signing failed (non-fatal)" + find "$OUT/node_modules" -name "*.dylib" -exec sh -c 'codesign --force --sign - "$1" 2>/dev/null && echo " ✓ $(basename "$1") signed"' _ {} \; +fi + # ---------- summary ---------- echo "" echo "qmd bundled → $OUT/" diff --git a/src-tauri/build.rs b/src-tauri/build.rs index d860e1e6..dc4d9fe2 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -1,3 +1,13 @@ fn main() { + // Ensure resource directories exist for the Tauri build. + // These are gitignored and populated by scripts (bundle-qmd.sh, bundle-mcp-server.mjs). + // Without a placeholder, `tauri build` / `cargo test` fails if the scripts haven't run. + for dir in ["resources/qmd", "resources/mcp-server"] { + let path = std::path::Path::new(dir); + if !path.exists() { + std::fs::create_dir_all(path).ok(); + std::fs::write(path.join(".placeholder"), "").ok(); + } + } tauri_build::build() } diff --git a/src-tauri/src/indexing.rs b/src-tauri/src/indexing.rs index 2c103d84..a301a8e6 100644 --- a/src-tauri/src/indexing.rs +++ b/src-tauri/src/indexing.rs @@ -89,24 +89,24 @@ fn find_bundled_qmd() -> Option { // macOS app bundle: /Contents/MacOS/laputa → /Contents/Resources/qmd/qmd let bundle_dir = exe_dir.parent()?.join("Resources").join("qmd"); - if bundle_dir.join("qmd").exists() { - return Some(QmdBinary { - path: bundle_dir.join("qmd").to_string_lossy().to_string(), - work_dir: Some(bundle_dir), - }); + if let Some(bin) = prepare_bundled_dir(&bundle_dir) { + return Some(bin); } - // Dev mode: src-tauri/resources/qmd/qmd (binary is in target/debug or target/release) - // Walk up from exe_dir to find the project root + // Dev mode: use compile-time CARGO_MANIFEST_DIR for reliable path resolution + let dev_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("resources") + .join("qmd"); + if let Some(bin) = prepare_bundled_dir(&dev_dir) { + return Some(bin); + } + + // Dev mode fallback: walk up from exe_dir to find the project root let mut dir = exe_dir.to_path_buf(); for _ in 0..6 { - let dev_qmd = dir.join("resources").join("qmd").join("qmd"); - if dev_qmd.exists() { - let qmd_dir = dir.join("resources").join("qmd"); - return Some(QmdBinary { - path: dev_qmd.to_string_lossy().to_string(), - work_dir: Some(qmd_dir), - }); + let qmd_dir = dir.join("resources").join("qmd"); + if let Some(bin) = prepare_bundled_dir(&qmd_dir) { + return Some(bin); } if !dir.pop() { break; @@ -116,7 +116,99 @@ fn find_bundled_qmd() -> Option { None } -/// Clear the cached qmd binary (e.g. after path changes). +/// Validate a bundled qmd directory and prepare the binary for execution. +/// Sets execute permissions and removes macOS quarantine attributes. +fn prepare_bundled_dir(qmd_dir: &Path) -> Option { + let qmd_path = qmd_dir.join("qmd"); + if !qmd_path.exists() { + return None; + } + + ensure_executable(&qmd_path); + + // Remove macOS quarantine attributes that block execution of bundled binaries + #[cfg(target_os = "macos")] + { + let _ = Command::new("xattr") + .args(["-rd", "com.apple.quarantine"]) + .arg(qmd_dir) + .output(); + } + + Some(QmdBinary { + path: qmd_path.to_string_lossy().to_string(), + work_dir: Some(qmd_dir.to_path_buf()), + }) +} + +/// Ensure a file has execute permission. +#[cfg(unix)] +fn ensure_executable(path: &Path) { + use std::os::unix::fs::PermissionsExt; + if let Ok(metadata) = path.metadata() { + let mode = metadata.permissions().mode(); + if mode & 0o111 == 0 { + let mut perms = metadata.permissions(); + perms.set_mode(mode | 0o755); + let _ = std::fs::set_permissions(path, perms); + } + } +} + +#[cfg(not(unix))] +fn ensure_executable(_path: &Path) {} + +/// Try to install qmd globally using bun. Returns Ok if installation succeeded. +pub fn try_auto_install_qmd() -> Result<(), String> { + let bun = find_bun().ok_or("bun not found — cannot auto-install qmd")?; + + log::info!("Auto-installing qmd via bun..."); + let output = Command::new(&bun) + .args(["install", "-g", "qmd"]) + .output() + .map_err(|e| format!("Failed to run bun install: {e}"))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(format!("bun install -g qmd failed: {stderr}")); + } + + // Clear cache so the newly installed binary is discovered + clear_qmd_cache(); + log::info!("qmd auto-install succeeded"); + Ok(()) +} + +/// Locate bun binary for auto-installing qmd. +fn find_bun() -> Option { + let candidates = [ + dirs::home_dir().map(|h| h.join(".bun/bin/bun")), + Some(PathBuf::from("/opt/homebrew/bin/bun")), + Some(PathBuf::from("/usr/local/bin/bun")), + ]; + for candidate in candidates.into_iter().flatten() { + if candidate.exists() { + return Some(candidate); + } + } + + // Fallback: try PATH + Command::new("which") + .arg("bun") + .output() + .ok() + .and_then(|o| { + if o.status.success() { + Some(PathBuf::from( + String::from_utf8_lossy(&o.stdout).trim().to_string(), + )) + } else { + None + } + }) +} + +/// Clear the cached qmd binary (e.g. after path changes or installation). pub fn clear_qmd_cache() { if let Ok(mut guard) = QMD_CACHE.lock() { *guard = None; @@ -527,4 +619,83 @@ Collections assert_eq!(parse_indexed_count("Indexed 342 files in 1.2s"), 342); assert_eq!(parse_indexed_count("No output"), 0); } + + #[test] + fn ensure_executable_sets_permission() { + use std::fs; + let dir = tempfile::tempdir().unwrap(); + let file = dir.path().join("test-bin"); + fs::write(&file, "#!/bin/sh\necho ok").unwrap(); + + // Start with no execute permission + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&file, fs::Permissions::from_mode(0o644)).unwrap(); + assert_eq!(fs::metadata(&file).unwrap().permissions().mode() & 0o111, 0); + + ensure_executable(&file); + assert_ne!(fs::metadata(&file).unwrap().permissions().mode() & 0o111, 0); + } + } + + #[test] + fn ensure_executable_noop_when_already_executable() { + use std::fs; + let dir = tempfile::tempdir().unwrap(); + let file = dir.path().join("test-bin"); + fs::write(&file, "#!/bin/sh\necho ok").unwrap(); + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&file, fs::Permissions::from_mode(0o755)).unwrap(); + ensure_executable(&file); + let mode = fs::metadata(&file).unwrap().permissions().mode(); + assert_ne!(mode & 0o111, 0); + } + } + + #[test] + fn prepare_bundled_dir_returns_none_for_missing_binary() { + let dir = tempfile::tempdir().unwrap(); + assert!(prepare_bundled_dir(dir.path()).is_none()); + } + + #[test] + fn prepare_bundled_dir_finds_and_prepares_binary() { + use std::fs; + let dir = tempfile::tempdir().unwrap(); + let qmd_path = dir.path().join("qmd"); + fs::write(&qmd_path, "#!/bin/sh\necho ok").unwrap(); + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&qmd_path, fs::Permissions::from_mode(0o644)).unwrap(); + } + + let result = prepare_bundled_dir(dir.path()); + assert!(result.is_some()); + let bin = result.unwrap(); + assert!(bin.path.ends_with("qmd")); + assert_eq!(bin.work_dir.unwrap(), dir.path()); + + // Verify execute permission was set + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_ne!( + fs::metadata(&qmd_path).unwrap().permissions().mode() & 0o111, + 0 + ); + } + } + + #[test] + fn find_bun_returns_some_if_available() { + // This test may succeed or fail depending on the system. + // It verifies the function doesn't panic. + let _ = find_bun(); + } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 4971c544..aef15eed 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -403,24 +403,53 @@ fn get_index_status(vault_path: String) -> IndexStatus { indexing::check_index_status(&vault_path) } +fn emit_unavailable(app_handle: &tauri::AppHandle) { + use tauri::Emitter; + let _ = app_handle.emit( + "indexing-progress", + IndexingProgress { + phase: "unavailable".to_string(), + current: 0, + total: 0, + done: true, + error: Some("qmd not available".to_string()), + }, + ); +} + #[tauri::command] async fn start_indexing(app_handle: tauri::AppHandle, vault_path: String) -> Result<(), String> { use tauri::Emitter; let vault_path = expand_tilde(&vault_path).into_owned(); tokio::task::spawn_blocking(move || { if indexing::find_qmd_binary().is_none() { - log::info!("qmd binary not found (bundled or system)"); + log::info!("qmd binary not found — attempting auto-install via bun"); let _ = app_handle.emit( "indexing-progress", IndexingProgress { - phase: "unavailable".to_string(), + phase: "installing".to_string(), current: 0, total: 0, - done: true, - error: Some("qmd not available".to_string()), + done: false, + error: None, }, ); - return Err("qmd not available".to_string()); + + match indexing::try_auto_install_qmd() { + Ok(()) if indexing::find_qmd_binary().is_some() => { + log::info!("qmd auto-installed successfully, proceeding with indexing"); + } + Ok(()) => { + log::warn!("qmd auto-install reported success but binary still not found"); + emit_unavailable(&app_handle); + return Err("qmd not available after install".to_string()); + } + Err(e) => { + log::info!("qmd auto-install failed: {e}"); + emit_unavailable(&app_handle); + return Err(format!("qmd not available: {e}")); + } + } } indexing::run_full_index(&vault_path, |progress| { diff --git a/src/hooks/useIndexing.test.ts b/src/hooks/useIndexing.test.ts index 06ced88f..7136f7df 100644 --- a/src/hooks/useIndexing.test.ts +++ b/src/hooks/useIndexing.test.ts @@ -53,7 +53,7 @@ describe('useIndexing', () => { expect(result.current.progress.phase).toBe('idle') }) - it('sets unavailable phase for missing qmd errors', async () => { + it('sets unavailable phase for "not installed" errors', async () => { const { result } = renderHook(() => useIndexing('/test/vault')) mockInvoke.mockRejectedValueOnce(new Error('bun not installed')) @@ -61,6 +61,14 @@ describe('useIndexing', () => { expect(result.current.progress.phase).toBe('unavailable') }) + it('sets unavailable phase for "not available" errors', async () => { + const { result } = renderHook(() => useIndexing('/test/vault')) + + mockInvoke.mockRejectedValueOnce(new Error('qmd not available: bun not found')) + await act(async () => { await result.current.retryIndexing() }) + expect(result.current.progress.phase).toBe('unavailable') + }) + it('auto-dismisses unavailable phase after 8 seconds', async () => { const { result } = renderHook(() => useIndexing('/test/vault'))