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:
@@ -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/"
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -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'))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user