fix: make qmd/search work on fresh installs — auto-install, fix permissions, sign binaries

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 <noreply@anthropic.com>
This commit is contained in:
lucaronin
2026-03-05 22:13:03 +01:00
parent a2f58e1b2d
commit 817055a9e1
5 changed files with 246 additions and 21 deletions

View File

@@ -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/"

View File

@@ -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()
}

View File

@@ -89,24 +89,24 @@ fn find_bundled_qmd() -> Option<QmdBinary> {
// macOS app bundle: <app>/Contents/MacOS/laputa → <app>/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<QmdBinary> {
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<QmdBinary> {
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<PathBuf> {
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();
}
}

View File

@@ -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| {

View File

@@ -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'))