# Setup Signals: Model Discovery, Signing, and First Run

> What actually has to be true before Omni runs: where the model directory is located (OMNI_MODEL_DIR, Application Support, HuggingFace cache via ModelLocator), the one-time model download flow, the Apple Team ID / code-signing requirement wired through project.yml, and the onboarding path the user sees on first launch.

- Repository: hanxiao/omni-macos
- GitHub: https://github.com/hanxiao/omni-macos
- Human wiki: https://grok-wiki.com/public/wiki/hanxiao-omni-macos-7817a5cffe05
- Complete Markdown: https://grok-wiki.com/public/wiki/hanxiao-omni-macos-7817a5cffe05/llms-full.txt

## Source Files

- `project.yml`
- `Sources/OmniKit/OmniEngine.swift`
- `Sources/OmniKit/ModelDownloader.swift`
- `App/OnboardingView.swift`
- `Scripts/build-app.sh`
- `Makefile`

---

<details>
<summary>Relevant source files</summary>
The following files were used as context for generating this wiki page:
- [project.yml](project.yml)
- [Sources/OmniKit/OmniEngine.swift](Sources/OmniKit/OmniEngine.swift)
- [Sources/OmniKit/ModelDownloader.swift](Sources/OmniKit/ModelDownloader.swift)
- [App/OnboardingView.swift](App/OnboardingView.swift)
- [App/AppModel.swift](App/AppModel.swift)
- [App/ContentView.swift](App/ContentView.swift)
- [Scripts/build-app.sh](Scripts/build-app.sh)
- [Makefile](Makefile)
- [README.md](README.md)
- [.github/workflows/release.yml](.github/workflows/release.yml)
</details>

# Setup Signals: Model Discovery, Signing, and First Run

Omni is a native macOS app that does semantic search entirely on-device, so before it can do anything useful two things must be true: a build identity must exist so macOS trusts the app with your files, and a complete model directory must be discoverable on disk. This page traces both. It explains where Omni looks for the model (the `OMNI_MODEL_DIR` env pointer, `~/Library/Application Support/Omni/`, and the HuggingFace cache, all resolved by `ModelLocator`), how the one-time download lands a model in Application Support, how the Apple Team ID is wired through `project.yml` at project-generation time, and what the user actually sees on first launch when no model is found yet.

If you are new to the repo, the two files that own this story are `Sources/OmniKit/OmniEngine.swift` (the `ModelLocator` discovery rules) and `App/AppModel.swift` (the launch/bootstrap state machine). `project.yml` owns signing. Read those three first.

## The four setup signals

| Signal | Owned by | What must be true |
| --- | --- | --- |
| Team ID | `project.yml` via `OMNI_TEAM_ID` env | A 10-char Apple Team ID is exported before `xcodegen generate`, so the generated project signs with a stable identity. |
| Model directory | `ModelLocator` (`OmniEngine.swift`) | A directory containing `model.safetensors` + `config.json` + `tokenizer.json` is reachable via override, App Support, or HF cache. |
| First-run UI | `AppModel.Phase` + `OnboardingView` | If no complete model is found, the app shows onboarding and offers a download or a folder picker. |
| Download | `ModelDownloader` | The runtime-required files are fetched once into `Application Support/Omni/<variant>`. |

Sources: [project.yml:30-41](project.yml), [Sources/OmniKit/OmniEngine.swift:14-91](Sources/OmniKit/OmniEngine.swift), [App/AppModel.swift:96-99](App/AppModel.swift), [Sources/OmniKit/ModelDownloader.swift:14-33](Sources/OmniKit/ModelDownloader.swift)

## Model discovery: how `ModelLocator` resolves a directory

Model location is path-based and layered, not hard-coded to one provider. `AppModel.resolvedModelDir()` is the entry point at launch: it first honors a folder the user explicitly picked (the `omni.modelDir` default, set by "Choose Model Folder"), but only if that folder is *complete*. Otherwise it delegates to `ModelLocator.resolve()`, which walks a fixed precedence chain and returns the first directory that holds a complete model.

A "complete" model is the key invariant. `firstWithWeights` deliberately requires three files together (`model.safetensors`, `config.json`, `tokenizer.json`) so a partial directory, like an interrupted download or a `/tmp` leftover with only weights, is skipped rather than selected and then failing later with a missing-config error.

```mermaid
flowchart TD
    subgraph app["AppModel.resolvedModelDir()"]
        saved["Saved picker dir<br/>UserDefaults omni.modelDir<br/>(only if complete)"]
    end
    subgraph locator["ModelLocator.resolve()"]
        env["OMNI_MODEL_DIR env"]
        legacy["~/Library/Application Support/<br/>Omni/model (legacy)"]
        nano["resolve(variant: .nano)"]
        small["resolve(variant: .small)"]
    end
    subgraph variant["resolve(variant:) order"]
        appsup["App Support Omni/&lt;variant&gt;"]
        dev["/private/tmp dev path"]
        hub["HuggingFace cache snapshots<br/>~/.cache/huggingface/hub + ext volume"]
    end
    gate{"firstWithWeights:<br/>model.safetensors +<br/>config.json + tokenizer.json"}

    saved -->|miss| env --> legacy --> nano --> small
    nano --> variant
    small --> variant
    appsup --> dev --> hub
    variant --> gate
    locator --> gate
    gate -->|complete dir found| ready["modelPath set -> bootstrap"]
    gate -->|none| nomodel["Phase .noModel -> Onboarding"]
```

The precedence inside `ModelLocator` is:

1. **Overrides that win regardless of variant** — the `OMNI_MODEL_DIR` environment variable, then the legacy single-model path `Application Support/Omni/model`.
2. **Nano**, then **Small** as the default variant order (Nano is smaller and faster, so it wins when both are present).
3. For each variant, `resolve(variant:)` checks `Application Support/Omni/<variant>`, then a dev staging path (`/private/tmp/omni-model` for Small, `/private/tmp/omni-nano` for Nano), then HuggingFace cache snapshots under `~/.cache/huggingface/hub` and an external-volume hub root.

```swift
// Sources/OmniKit/OmniEngine.swift
private static let hubRoots = [
    FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".cache/huggingface/hub"),
    URL(fileURLWithPath: "/Volumes/One Touch/ai-models/huggingface/hub"),
]
```

Because the highest-priority signal is a plain path in `OMNI_MODEL_DIR`, the discovery layer stays portable: any model directory laid out with the expected filenames works, whether it came from the HuggingFace cache, a hand-picked folder, or a CI-staged path. The `Makefile` uses exactly this seam to run tests against a fixed local model (`OMNI_MODEL_DIR='$(MODEL)'`).

Sources: [Sources/OmniKit/OmniEngine.swift:7-91](Sources/OmniKit/OmniEngine.swift), [App/AppModel.swift:932-943](App/AppModel.swift), [Makefile:18-19](Makefile)

## First run: the launch state machine

`AppModel` is an `@MainActor @Observable` model with a small `Phase` enum that the UI branches on. `init()` loads persisted settings and kicks off `bootstrap()`; `ContentView` renders one of four detail views depending on `phase`.

```mermaid
stateDiagram-v2
    [*] --> loadingModel
    loadingModel --> noModel: resolvedModelDir() == nil
    loadingModel --> ready: engine + store loaded
    loadingModel --> failed: load throws
    noModel --> loadingModel: downloadModel() / setModelDir()
    failed --> loadingModel: retryBootstrap()
    ready --> loadingModel: switchVariant()
    note right of noModel
        OnboardingView:
        download a variant
        or pick a folder
    end note
```

`bootstrap()` resolves the model directory; if none is found it sets `phase = .noModel` and returns. Otherwise it loads the `VectorStore` and `OmniEngine` concurrently (CPU index read overlapped with weight/tokenizer IO), wires the indexer and the in-process serving controller, sets `phase = .ready`, and kicks a background index pass. Any thrown error becomes `.failed(String)`.

```swift
// App/ContentView.swift
switch model.phase {
case .loadingModel: CenteredStatus(... "Loading the Omni model" ...)
case .noModel:      OnboardingView()
case .failed(let msg): EngineFailedView(message: msg)
case .ready:        ready
}
```

Sources: [App/AppModel.swift:96-99](App/AppModel.swift), [App/AppModel.swift:357-370](App/AppModel.swift), [App/AppModel.swift:993-1044](App/AppModel.swift), [App/ContentView.swift:80-91](App/ContentView.swift)

## Onboarding: what the user sees with no model

When `phase == .noModel`, `OnboardingView` greets the user and offers two paths to get a model on disk:

- **Download a variant.** Two buttons, `Download Omni Nano` (~1.9 GB, faster, the prominent default) and `Download Omni Small` (~3.1 GB, higher quality). Each calls `model.downloadModel(variant)`. While downloading, the buttons are replaced by a progress bar bound to `model.downloadFraction` and a monospaced byte-count label.
- **Choose an existing folder.** "Choose Model Folder…" opens an `NSOpenPanel` (directories only) and routes the chosen URL into `model.setModelDir(url)`, which persists it and re-runs `bootstrap()`.

The footer reinforces the privacy model: indexing and search run on Apple silicon, files never leave the device, and the model downloads once after which no internet is required. A download error surfaces in red via `model.downloadFailed` / `downloadLabel`.

Sources: [App/OnboardingView.swift:1-80](App/OnboardingView.swift), [App/AppModel.swift:925-929](App/AppModel.swift)

## The one-time download flow

`ModelDownloader` fetches only the files the Swift runtime needs (no Python) from the HuggingFace Hub. The repo name is derived from the variant (`jinaai/jina-embeddings-v5-omni-<variant>-mlx`), and files land in `Application Support/Omni/<variant>` via `installDir(for:)`.

```swift
// Sources/OmniKit/ModelDownloader.swift
public static let files = [
    "config.json",
    "tokenizer.json",
    "tokenizer_config.json",
    "adapters/retrieval/adapter_config.json",
    "adapters/retrieval/adapter_model.safetensors",
    "model.safetensors",   // by far the largest
]
```

The download is resume-ish: a file already present with non-zero size is skipped, so an interrupted download continues rather than restarting. Each file streams to a stable temp location and is moved into place; HTML error pages returned with a non-200 status are rejected as model errors. `AppModel.downloadModel` drives progress (only the large `model.safetensors` advances the fraction), then on success refreshes `installedVariants`, sets the active variant, and calls `setModelDir(dest)` to load it.

Sources: [Sources/OmniKit/ModelDownloader.swift:14-123](Sources/OmniKit/ModelDownloader.swift), [App/AppModel.swift:959-991](App/AppModel.swift)

## Signing: the Apple Team ID wired through `project.yml`

Omni reads Documents, Downloads, and Desktop, which macOS gates behind TCC. To keep the user's grant from being re-prompted on every rebuild, the app is signed with a *stable* identity rather than ad-hoc. The repo ships no Apple credentials; instead `project.yml` reads the Team ID from the environment at generation time.

```yaml
# project.yml (target Omni settings)
CODE_SIGN_IDENTITY: "Apple Development"
CODE_SIGN_STYLE: Manual
DEVELOPMENT_TEAM: ${OMNI_TEAM_ID}   # set before `xcodegen generate`
CODE_SIGNING_REQUIRED: YES
ENABLE_HARDENED_RUNTIME: YES
ENABLE_APP_SANDBOX: NO
```

The workflow is: `export OMNI_TEAM_ID=XXXXXXXXXX` then `xcodegen generate`, which bakes the team into `Omni.xcodeproj`. A *free* Apple ID's personal team is enough to build and run locally.

| Context | Team ID source | Signing identity | Extra |
| --- | --- | --- | --- |
| Local dev | `OMNI_TEAM_ID` env (free Apple ID OK) | `Apple Development` (manual) | Hardened runtime on, sandbox off |
| CI release | `APPLE_TEAM_ID` GitHub secret | `Developer ID Application` (paid program) | Notarization via Apple notary service |

CI does not rely on the `project.yml` default; the release workflow passes `DEVELOPMENT_TEAM` and a `Developer ID Application` identity as `xcodebuild` overrides, because the self-hosted runner has no "Apple Development" cert. `Scripts/build-app.sh` forwards any trailing args straight to `xcodebuild` for exactly these signing overrides, and also injects the swift-tokenizers Rust artifact module map that a plain `xcodebuild` would otherwise miss.

Sources: [project.yml:30-47](project.yml), [README.md:57-73](README.md), [.github/workflows/release.yml:84-99](.github/workflows/release.yml), [Scripts/build-app.sh:22-29](Scripts/build-app.sh)

## Putting it together

A clean first run is: export `OMNI_TEAM_ID`, generate and build (signed with your team so TCC remembers the grant), launch. `AppModel.bootstrap()` asks `ModelLocator` for a complete model directory; finding none, it lands on `.noModel` and `OnboardingView` offers a one-time download into `Application Support/Omni/<variant>` or a folder picker. Once a directory with `model.safetensors`, `config.json`, and `tokenizer.json` is resolvable, bootstrap loads the engine, flips to `.ready`, and indexing begins in the background. The deliberate seams — a path-based `OMNI_MODEL_DIR` override, an env-fed Team ID, and a completeness gate on the model directory — are what keep the setup portable and let CI, tests, and local builds each supply their own model and identity without anything proprietary baked into the repo.

Sources: [App/AppModel.swift:993-1044](App/AppModel.swift), [Sources/OmniKit/OmniEngine.swift:84-90](Sources/OmniKit/OmniEngine.swift)
