From 5b93dd67a7b305bcfa9b207e380544c94e0795fd Mon Sep 17 00:00:00 2001 From: karl Date: Fri, 23 Oct 2026 09:43:42 +0200 Subject: [PATCH] Support native macOS speech relocation and safe indexing --- README.md | 140 +++++++++++++++++- .../src-tauri/src/automatic_indexing.rs | 29 ++++ apps/desktop/src-tauri/src/watching.rs | 12 +- docs/BUILDING.md | 4 +- docs/MACOS-RUNTIME.md | 62 +++++++- docs/ROADMAP.md | 17 +++ tools/speech-runtime/CMakeLists.txt | 7 +- tools/speech-runtime/macos_smoke.py | 30 ++++ 8 files changed, 287 insertions(+), 14 deletions(-) create mode 100644 tools/speech-runtime/macos_smoke.py diff --git a/README.md b/README.md index 2c315dc..6964396 100644 --- a/README.md +++ b/README.md @@ -258,9 +258,143 @@ claim an untested platform, hardware backend or UI interaction works. ``` Building needs internet access, disk space for the toolchain and local models, -and permission to install dependencies. macOS native execution remains -unverified in this repository; your agent should verify its build on your Mac -and report any issue. See [platform build instructions](docs/BUILDING.md). +and permission to install dependencies. An Apple Silicon source installation +was tested on 23 October 2026; follow the Mac guide below. Other host/architecture +combinations still need their own checks. See [platform build instructions](docs/BUILDING.md). + +### macOS: build and install from source + +Tested with source commit `9a93ee6375d610d580ef16c5ec52218727fab187` +on an Apple M4 Mac with 16 GiB unified memory and macOS 26.6.2. The installed +0.1.9 app opened outside the checkout, used Metal for genuine Google embeddings, +and passed English/German document retrieval and code-only semantic search. +There is currently no downloadable Mac installer. Intel Macs, Developer ID +signing and notarization remain unverified. + +**Prerequisites:** Xcode command-line tools (or full Xcode), stable Rust/Cargo, +Node **24 LTS**, Python 3.11+ and CMake. Check that `cargo`, `node`, `npm`, +`python3` and `cmake` are on your `PATH`; run `xcode-select -p` to check the +Apple toolchain. If it is missing, run `xcode-select --install` and complete +Apple's installation before continuing. Use the official [Rust installer](https://rustup.rs/) +if Cargo is missing. With Homebrew, `brew install node@24 cmake python` supplies +the other tools; select Node 24 with +`export PATH="$(brew --prefix node@24)/bin:$PATH"` and verify `node --version`. +Node 26 failed a Unicode-highlighting test on the tested machine; Node 24.19.0 +passed all 66 frontend tests. + +Allow space for native build outputs and the model assets. The pinned text +model and media projector download totals **864,676,480 bytes** (about 865 MB). +The steps below also copy those assets into the app, so the download size is +not the total disk-space requirement. Existing valid model files are reused +and verified by size and SHA-256. + +```sh +git clone https://tea.karlcom.de/karl/searchi.git +cd searchi +# Record the current revision, including the Mac speech/scan fixes: +git rev-parse HEAD + +python3 tools/embeddinggemma2/build_runtime.py --backend auto --jobs 2 +python3 tools/embeddinggemma2/download.py +python3 tools/embeddinggemma2/stage_runtime.py --backend auto --bundle-model +python3 tools/stage_extraction_worker.py +``` + +Use the normal `auto` backend: on macOS it builds Metal support with CPU support +in the same worker. The builder embeds Metal kernels and checks the native +architecture and Apple framework dependencies. Do not substitute an explicit +CPU-only worker for this normal installation. See [macOS runtime details](docs/MACOS-RUNTIME.md). + +**Optional media helpers:** the pinned decoder-provisioning manifest currently +covers Windows and Linux. For a local Mac installation with Homebrew FFmpeg, +stage the existing tools before packaging: + +```sh +brew install ffmpeg +mkdir -p apps/desktop/src-tauri/resources/media-runtime/bin +install -m 755 "$(brew --prefix)/bin/ffmpeg" \ + apps/desktop/src-tauri/resources/media-runtime/bin/ffmpeg +install -m 755 "$(brew --prefix)/bin/ffprobe" \ + apps/desktop/src-tauri/resources/media-runtime/bin/ffprobe +``` + +These binaries retain dependencies on Homebrew libraries. Keep that Homebrew +installation available; this does not create a portable Mac media distribution. +Speech recognition uses the separate worker/models staged below. The setup +panel reports readiness and any missing capabilities. + +**Local speech recognition:** build and stage the normal Metal/CPU speech runtime +before packaging the desktop. The app provisions about149MB of verified speech +models separately from the Google assets. + +```sh +git clone https://github.com/ggml-org/whisper.cpp.git .searchi/references/whisper.cpp +git -C .searchi/references/whisper.cpp checkout d1be6fde11ac6e0407606b4e42fe72d34add8037 +python3 tools/speech-runtime/build.py --source .searchi/references/whisper.cpp \ + --output .searchi/speech-runtime/macos-auto --device auto +python3 tools/stage_speech_worker.py --runtime .searchi/speech-runtime/macos-auto/runtime \ + --platform macos --architecture aarch64 +``` + +Use a fresh speech build output directory. The Mac runtime uses loader-relative +library paths, so the installed app does not depend on the build tree. Speech +recognition, video-frame retrieval, timestamped audio/video previews and music +metadata lookup were exercised in the installed Mac app. A known spoken sentence +was found in both audio and video; an artist search found a piano recording. +The optional CLI command `searchi setup --speech --model-dir PATH` can provision +the same verified models explicitly. Normal Mac app profiles use +`~/Library/Application Support/dev.searchi.desktop/SpeechModels`. + +Build the desktop bundle using Node 24: + +```sh +cd apps/desktop +npm ci +npm test +npm run tauri -- build --features custom-protocol --bundles app \ + --config '{"bundle":{"active":true}}' +cd ../.. +``` + +Install the generated bundle in your user Applications folder. If searchi is +already running, choose Quit before replacing it. Retain an existing installed +copy separately if you need to roll back; the app's data is stored separately. + +```sh +mkdir -p "$HOME/Applications" +ditto apps/desktop/src-tauri/target/release/bundle/macos/searchi.app \ + "$HOME/Applications/searchi.app" +codesign --force --deep --sign - "$HOME/Applications/searchi.app" +codesign --verify --deep --strict "$HOME/Applications/searchi.app" +open "$HOME/Applications/searchi.app" +``` + +This applies a local ad-hoc signature, not Developer ID signing or notarization. +The installed bundle contains the embedding worker, extraction worker and model +files; it does not need a frontend development server or the original checkout +to serve search. Any staged Homebrew media helpers still need their libraries. + +**First-run coverage:** settings and the index live in +`~/Library/Application Support/dev.searchi.desktop/`. Review your indexed folders +before starting a large scan. Automatic discovery starts at `/` on macOS but prunes `/System`, `/Volumes`, +`/private` and the user Library app-state directory. You can also use explicit +personal-folder roots to control coverage. A dedicated sample folder makes an initial +semantic check easy. Known credential directories, build caches and searchi's +own data/model directories are excluded; macOS permissions still determine +which files can be read. Adding a folder does not mean its semantic index is +immediately ready—wait for the setup status. + +Try a filename, an exact phrase and a description using different words. Select +the result and inspect its original passage. The tested sample agreement said +“sixty days of written notice”; “ending the supplier partnership” and the German +“Kündigung beim Lieferanten” both found it. Code scope found a retry function +from a description of increasing delays. These were small fixture checks, not +a whole-library accuracy or performance benchmark. + +The Mac launcher shortcut is **Shift + Command + Space**, as configured in the +app; shortcut/tray interaction was not verified during this installation test. +Optional external Ask/summarization remains unconfigured unless you choose an +endpoint explicitly. ### Your first search diff --git a/apps/desktop/src-tauri/src/automatic_indexing.rs b/apps/desktop/src-tauri/src/automatic_indexing.rs index c3df6c6..4b64474 100644 --- a/apps/desktop/src-tauri/src/automatic_indexing.rs +++ b/apps/desktop/src-tauri/src/automatic_indexing.rs @@ -59,6 +59,14 @@ pub fn exclusions() -> Vec { .iter() .map(PathBuf::from) .collect(); + #[cfg(target_os = "macos")] + { + paths.extend(["/System", "/Volumes", "/private"].map(PathBuf::from)); + // App state, VM disks and OS caches are not personal searchable documents. + if let Some(home) = std::env::var_os("HOME") { + paths.push(PathBuf::from(home).join("Library")); + } + } #[cfg(windows)] if let Some(local) = std::env::var_os("LOCALAPPDATA") { paths.push(PathBuf::from(local).join("Programs")); @@ -156,6 +164,27 @@ mod tests { assert!(exclusions().iter().all(|path| path.is_absolute())); } + #[cfg(target_os = "macos")] + #[test] + fn mac_scan_prunes_system_mounts_and_app_state_but_keeps_documents() { + let excluded = exclusions(); + let home = PathBuf::from(std::env::var_os("HOME").expect("Mac user home")); + for path in [ + PathBuf::from("/System"), + PathBuf::from("/Volumes"), + home.join("Library"), + ] { + let canonical = super::super::data_directory::canonical_exclusion(&path).unwrap(); + assert!( + excluded.contains(&canonical), + "unpruned Mac path: {}", + path.display() + ); + } + let documents = home.join("Documents"); + assert!(!excluded.iter().any(|p| documents.starts_with(p))); + } + #[test] fn automatic_scan_keeps_documents_and_prunes_credential_stores() { let dir = tempfile::tempdir().unwrap(); diff --git a/apps/desktop/src-tauri/src/watching.rs b/apps/desktop/src-tauri/src/watching.rs index f7f7b69..bc1aad5 100644 --- a/apps/desktop/src-tauri/src/watching.rs +++ b/apps/desktop/src-tauri/src/watching.rs @@ -522,11 +522,13 @@ mod tests { .expect("watch reconciliation") } #[test] - fn native_windows_or_linux_create_update_rename_delete() { - let root = tempfile::tempdir().unwrap(); + fn native_create_update_rename_delete() { + let directory = tempfile::tempdir().unwrap(); + // Match canonical production roots; macOS FSEvents emits /private/var paths. + let root = std::fs::canonicalize(directory.path()).unwrap(); let (tx, rx) = mpsc::channel(); let service = WatchService::start_timed( - vec![root.path().to_owned()], + vec![root.clone()], vec![], move |r| { tx.send(r).unwrap(); @@ -536,8 +538,8 @@ mod tests { ) .unwrap(); assert!(receive(&rx).paths.is_none()); - let first = root.path().join("first.txt"); - let second = root.path().join("second.txt"); + let first = root.join("first.txt"); + let second = root.join("second.txt"); std::fs::write(&first, "one").unwrap(); assert!(receive(&rx).paths.unwrap().contains(&first)); std::fs::write(&first, "two").unwrap(); diff --git a/docs/BUILDING.md b/docs/BUILDING.md index feaf057..a60f26e 100644 --- a/docs/BUILDING.md +++ b/docs/BUILDING.md @@ -11,7 +11,9 @@ Headless Linux CLI/worker builds do not need WebKitGTK or a display server. macOS runtime preparation embeds Metal kernel sources and inspects the static worker's Apple dependencies before staging. See [macOS runtime](MACOS-RUNTIME.md) -for the prepared workflow and explicitly unexecuted native checks. +for the Apple Silicon installation evidence and remaining native checks. +The [README Mac guide](../README.md#macos-build-and-install-from-source) includes +the tested build, local installation, signing and first-run steps. Headless builds still need a C/C++ compiler, linker, CMake, and Rust: SQLite, USearch, NumKong, and the Google worker contain native code. diff --git a/docs/MACOS-RUNTIME.md b/docs/MACOS-RUNTIME.md index dd873f5..80a5b6e 100644 --- a/docs/MACOS-RUNTIME.md +++ b/docs/MACOS-RUNTIME.md @@ -27,10 +27,10 @@ python3 tools/embeddinggemma2/build_runtime.py --backend auto --jobs 2 python3 tools/embeddinggemma2/stage_runtime.py --backend auto ``` -These commands are prepared, **not executed on macOS**. Native compilation, -actual `lipo`/`otool` output, relocation outside the source tree, genuine Google -CPU/Metal inference, device fallback, app integration and signing/notarization -remain open. This change does not create a macOS release download. +The initial preparation was fixture-tested on 13 October; native Apple Silicon +execution was subsequently verified on 23 October as recorded below. This does +not create a macOS release download. Follow the [README installation guide](../README.md#macos-build-and-install-from-source) +for the tested source-build and local installation steps. ## Evidence, 2026-10-13 @@ -47,3 +47,57 @@ Worker hashes remain identical to their previously tested CPU deployments: Windows `c01cb3ec13cfca722ee768e8350e1399a8b9a4d14a24a35adc3473a5cfc82a73`; Linux `b52d50a1e676bd4bf64124daff1c4ce83d86a8bde01e4eaf09838cbb24a4c28f`. No GPU inference or native macOS execution was performed. + + +## Native Apple Silicon installation, 2026-10-23 + +Source `9a93ee6375d610d580ef16c5ec52218727fab187`, Apple M4/16 GiB, +macOS26.6.2, stable Rust, CMake4.4.3 and Node24.19.0. Normal `auto` worker +build/staging passed: arm64, embedded Metal library, Apple system-framework +linkage and artifact provenance verified. Both bundled Google assets passed +size/SHA-256 checks. The genuine query-role worker reported `device: gpu`, +512-token context and normalized 768-dimensional English/German embeddings. + +The release Tauri app was installed outside the checkout with both helpers and +the model files. Local ad-hoc signing and deep/strict signature verification +passed. Installed UI checks covered model readiness, English/German document +retrieval, code-only retrieval, source previews, explanations and filters. +Seven actual native screenshots were captured. All66frontend tests passed +under Node24; global Node26 failed one Unicode-highlighting test. + +Homebrew FFmpeg supplied local media helpers with external Homebrew library +dependencies. Speech recognition was unavailable during this initial check; the full installation below supersedes that limitation. Intel/universal execution, +GPU failure recovery, shortcut/tray interaction, clean-machine distribution, +Developer ID signing and notarization remain unchecked. These results do not +replace those gates or establish broad media accuracy. + +## Full media and speech installation, 2026-10-23 + +The installed native app now includes the auto Metal/CPU whisper.cpp runtime at +`d1be6fde11ac6e0407606b4e42fe72d34add8037`, with verified multilingual base +and Silero6.2 model assets. All speech shared libraries use loader-relative +paths. `tools/speech-runtime/macos_smoke.py` checked installed linkage and real +recognition of a public-domain recording; the observed backend was `MTL0`. + +An owned13-file corpus produced38indexed content segments, including8recognized +speech segments, with no reported media/speech failures. Native UI checks found +and played a waterfall video at4seconds, a spoken sentence in audio and video +near2seconds, and a tagged piano track by artist. A semantic piano-description +query ranked the track first among related suggestions. These checks do not +establish general lyrics recognition or large-library retrieval quality. + +CLI and stdio MCP use the same normal query worker. A three-file fixture passed +semantic search, code filtering, source excerpt reading and invalid-handle +rejection. The worker reported GPU,768dimensions and512query-context tokens. +Ten distinct queries through an already-running MCP process had median8.28ms +request wall time; ten repeated cached queries median0.29ms. Three fresh CLI +processes including worker startup had median1018ms. These are small-index local +measurements, not desktop UI timings or production-scale latency guarantees. + +Commit validation: all57native desktop tests pass, including real filesystem +create/update/rename/delete events using canonical Mac paths;66frontend tests, +format/build checks,3speech builder tests and installed Metal recognition pass. +The broader workspace run exposes an existing stale permission-denial rollback +expectation in `targeted_reconciliation` (current engine skips unreadable inputs). +Strict current-toolchain Clippy also flags unchanged permission-literal and +ANN-cache boolean expressions. These failures are not qualified as passing gates. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index f3ec5a8..aa440a4 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1,5 +1,22 @@ # Production readiness roadmap +## Native Apple Silicon support — 2026-10-23 + +- [x] Build/install the normal Metal/CPU runtime and Tauri desktop on Apple M4, + verify bundled assets and native GPU query inference. +- [x] Relocate the speech worker and shared libraries using `@loader_path`; + verify installed linkage and actual Whisper recognition on Metal. +- [x] Verify native document/code search, media previews, timestamped audio/video + playback and artist metadata lookup; document the complete source installation. +- [x] Verify native watcher lifecycle using canonical Mac paths (57 desktop tests pass). +- [x] Exclude Mac system trees, mounted volumes and user Library app state from + automatic indexing, preserving personal document roots. + +Evidence and limits: [Mac runtime](MACOS-RUNTIME.md). Homebrew FFmpeg remains a +local dependency. Intel/universal builds, clean-machine redistributable media, +Developer ID/notarization, hotkey/tray checks and broad CPU/GPU quality parity +remain open; these native M4 checks do not complete every platform release gate. + ## Current bounded goal — 2026-10-14 ### Final 0.1.9 correction — delivered diff --git a/tools/speech-runtime/CMakeLists.txt b/tools/speech-runtime/CMakeLists.txt index 3bb64a9..9e42b48 100644 --- a/tools/speech-runtime/CMakeLists.txt +++ b/tools/speech-runtime/CMakeLists.txt @@ -33,6 +33,11 @@ else() set(GGML_METAL OFF CACHE BOOL "" FORCE) endif() endif() +if(APPLE AND NOT SEARCHI_INTERNAL_CPU) + # Every dylib/backend must resolve beside the installed worker, without the build tree. + set(CMAKE_INSTALL_RPATH "@loader_path") + set(CMAKE_BUILD_WITH_INSTALL_RPATH ON) +endif() if(UNIX AND NOT APPLE AND NOT SEARCHI_INTERNAL_CPU) # The staged worker and every dynamic backend live together. RUNPATH is # non-transitive, so each shared target needs its own relocatable lookup. @@ -51,7 +56,7 @@ if(MSVC) target_compile_options(searchi-speech-worker PRIVATE /utf-8) endif() if(APPLE) - set_target_properties(searchi-speech-worker PROPERTIES BUILD_RPATH "$ORIGIN;@loader_path" INSTALL_RPATH "$ORIGIN;@loader_path") + set_target_properties(searchi-speech-worker PROPERTIES BUILD_RPATH "@loader_path" INSTALL_RPATH "@loader_path") elseif(UNIX) set_target_properties(searchi-speech-worker PROPERTIES BUILD_RPATH "$ORIGIN" INSTALL_RPATH "$ORIGIN") endif() diff --git a/tools/speech-runtime/macos_smoke.py b/tools/speech-runtime/macos_smoke.py new file mode 100644 index 0000000..7b0eb49 --- /dev/null +++ b/tools/speech-runtime/macos_smoke.py @@ -0,0 +1,30 @@ +"""Check installed Mac speech linkage and real recognition of an owned PCM fixture.""" +import argparse,json,struct,subprocess +from pathlib import Path + +def check_runtime(runtime): + names={p.name for p in runtime.iterdir() if p.is_file()} + for p in runtime.iterdir(): + if not p.is_file() or not (p.name=='searchi-speech-worker' or p.suffix in {'.dylib','.so'}):continue + loads=subprocess.check_output(['otool','-L',str(p)],text=True).splitlines()[1:] + for line in loads: + dep=line.strip().split(' (',1)[0] + assert dep.startswith(('/System/Library/','/usr/lib/')) or dep.startswith('@rpath/') and Path(dep).name in names,(p.name,dep) + meta=subprocess.check_output(['otool','-l',str(p)],text=True).splitlines() + paths=[meta[i+2].strip().split(' (',1)[0].removeprefix('path ')for i,l in enumerate(meta)if l.strip()=='cmd LC_RPATH'] + assert paths and all(x=='@loader_path' for x in paths),(p.name,paths) + +def frames(raw): + out=[] + while raw: + assert len(raw)>=4 + n=struct.unpack('=4+n + out.append(json.loads(raw[4:4+n]));raw=raw[4+n:] + return out + +if __name__=='__main__': + a=argparse.ArgumentParser();a.add_argument('--runtime',type=Path,required=True);a.add_argument('--models',type=Path,required=True);a.add_argument('--pcm',type=Path,required=True);a.add_argument('--output',type=Path,required=True);args=a.parse_args();check_runtime(args.runtime) + pcm=args.pcm.read_bytes();assert len(pcm)%4==0 and 0