← vaho vaho

modekit

build an external mode

a modo externo is a .dylib you build separately and drop into a folder on disk — vaho loads it at runtime, no app rebuild, no fork of the vaho repo needed.

native .dylib no rebuild loaded at runtime

⚠ read this first

Read this before anything else: an external mode is native code. It runs in the same process as vaho, with the same permissions, with no sandbox. There is currently no isolation between a mode's code and the rest of the app — a bug in a mode (e.g. a force-unwrap that fails) can crash vaho entirely, and a malicious mode can do anything vaho itself can do (send Apple Events, mute your mic, read your files). Only enable and load modes you wrote yourself, or whose full source you've read and trust. This is deliberately not (yet) a "browse and install" marketplace for anonymous third-party code — see Roadmap below for what that would actually require.

How it works, in one paragraph

NotchModePlugin (the same protocol built-in modes conform to) lives in its own nested Swift package, ModeKit/ — not just a target inside the main vaho package. That split matters: it's what makes vaho's build genuinely link libvahoModeKit.dylib as a separate dynamic library (embedded at vaho.app/Contents/Frameworks/) instead of silently compiling it straight into the executable. Your external mode's own Swift package depends on that same ModeKit/ package and produces its own .dylib, which also links libvahoModeKit.dylib (SwiftPM gives both dylibs the same @rpath/libvahoModeKit.dylib install name by default). When the app dlopens your mode at runtime, macOS's dynamic linker recognizes that install name is already loaded (the app's own copy) and reuses it rather than loading a second copy — so both sides end up sharing the exact same in-memory Swift type metadata for NotchModePlugin/ModeContext/etc., which is what makes protocol dispatch across the .dylib boundary safe. (Two independently-loaded copies of the same Swift module — e.g. if the app statically embedded vahoModeKit instead of dynamically linking it — causes the ObjC/Swift runtime to register duplicate class metadata, which it explicitly warns can cause "spurious casting failures and mysterious crashes." This was caught and fixed during development; it's why the nested-package structure exists.) Your dylib exposes one C-callable entry point (vahoModeEntryPoint) that the app finds with dlsym and calls to get your plugin instance.

Quick start

  1. The SDK ships inside the app — everything you need is already on disk once vaho is installed, at vaho.app/Contents/Resources/ModeKit-SDK/ (the vaho source repo itself is private; this folder is the public build surface). Copy the template out to somewhere writable:
    cp -R /Applications/vaho.app/Contents/Resources/ModeKit-SDK/templates/ExampleExternalMode \
      ~/my-vaho-mode
    Don't build inside the app bundle — it's read-only and code-signed; work on your copy.
  2. Edit your copy's Package.swift so .package(path:) points at the ModeKit that shipped in the same bundle (an absolute path is simplest):
    .package(path: "/Applications/vaho.app/Contents/Resources/ModeKit-SDK/ModeKit")
    Using the ModeKit from the exact app you'll load into also guarantees the toolchain/ABI match (see Toolchain).
  3. Edit Sources/ExampleMode/ExampleMode.swift: rename the type, change id/icon/label, build your real makePanel view. Keep the @_cdecl("vahoModeEntryPoint") function exactly as-is (only the type it boxes changes).
  4. Edit manifest.json: same id as your plugin, your name/author, abiVersion: 1 (see ABI versioning), dylib = the product's output filename (lib<ProductName>.dylib).
  5. Build it:
    swift build -c release --product ExampleMode
    The .dylib lands at .build/release/libExampleMode.dylib (path varies by arch — check .build/<arch>-apple-macosx/release/).
  6. Create a folder for it and copy both files in:
    mkdir -p ~/.config/vaho/modes/example
    cp manifest.json ~/.config/vaho/modes/example/
    cp .build/release/libExampleMode.dylib ~/.config/vaho/modes/example/
    No extra linking/relinking step needed — see how it works above for why plain swift build output already links correctly.
  7. In vaho, open Ajustes → Modos externos (experimental) and turn the toggle on (it's off by default). This loads immediately — no restart needed for a fresh mode to appear. Your mode's icon shows up in the notch's overflow grid (the slot), and can be pinned to favorites like any built-in mode.

The manifest.json contract

{
    "id": "example-external-mode",
    "name": "Ejemplo",
    "author": "Tu nombre",
    "abiVersion": 1,
    "dylib": "libExampleMode.dylib"
}
FieldMeaning
idMust exactly match your plugin's NotchModePlugin.id. Mismatches are rejected (logged, not loaded). Same freezing rule as built-in modes: this is what favorites persistence keys off, so don't change it after people start using your mode.
nameDisplay name (currently used in logs; may show in a future "loaded modes" list in Settings).
authorFree text, optional.
abiVersionMust equal ModeKitABI.version in the vaho version the user is running. Mismatches are rejected up front — this is what turns a would-be crash into a clean skip.
dylibFilename of the built dylib, sitting next to manifest.json in the same folder.

Folder layout the loader expects, one subfolder per mode:

~/.config/vaho/modes/
  example/
    manifest.json
    libExampleMode.dylib
  another-mode/
    manifest.json
    AnotherMode.dylib

The NotchModePlugin API

One difference from a built-in mode: ModeContext.viewModel is AnyObject, not the concrete NotchViewModel. Built-in modes (compiled inside the app, which knows the real type) can downcast it; external modes currently cannot — there's no public, stable, narrower capability surface exposed to external code yet. For now, build your panel from context.tint and context.defaultSize/your own panelSize, plus whatever state you manage yourself (@State, your own services, network calls, etc.). A stable capability surface (read the current track, battery level, etc.) for external modes is on the roadmap.

import SwiftUI
import vahoModeKit

struct YourMode: NotchModePlugin {
    let id = "your-unique-id"
    let icon = "star.fill"
    let label = "Your Mode"

    func panelSize(_ context: ModeContext) -> ModePanelSize { .height(200) }

    func makePanel(_ context: ModeContext) -> AnyView {
        AnyView(Text("Hello").foregroundColor(context.tint))
    }
}

@_cdecl("vahoModeEntryPoint")
public func vahoModeEntryPoint() -> UnsafeMutableRawPointer {
    Unmanaged.passRetained(ModeBox(YourMode())).toOpaque()
}

ABI versioning

ModeKitABI.version (declared in vahoModeKit) is bumped whenever NotchModePlugin/ModeContext/ModePanelSize change in a way that isn't safely compatible with existing external mode dylibs. ExternalModeLoader checks your manifest's abiVersion against it before attempting to dlopen — a mismatch is skipped with a log line, not a crash. If vaho bumps this, rebuild your mode against the new vahoModeKit and bump your manifest.json's abiVersion to match.

Toolchain compatibility

Build your mode with the same Xcode/Swift version used to build the vaho release you're loading it into. This is the actual practical risk area — more so than the ABI version check above, which only catches intentional protocol changes, not toolchain-level differences. Mismatched Swift compiler versions across the two separately-compiled copies of vahoModeKit are the most likely source of an obscure crash. Pointing .package(path:) at the bundle's ModeKit (as in Quick start) gives you the right sources, but not automatically the right compiler — build with a current, up-to-date Xcode and keep it updated alongside vaho.

Debugging

  • vaho logs every load attempt (success or failure) to /tmp/notch_debug.log, prefixed ExternalModeLoader:. Check there first if your mode doesn't show up.
  • Common failure modes: manifest.json unreadable/malformed (check it's valid JSON with all required fields), abiVersion mismatch, dylib filename typo, missing vahoModeEntryPoint symbol (check the @_cdecl name is spelled exactly right and the function is public), or id mismatch between the manifest and the plugin.
  • In Settings, "Volver a escanear la carpeta de modos" re-runs the loader without restarting the app (handy while iterating — rebuild, copy the new dylib over, click the button).
  • Disabling the toggle stops future loads but can't safely unload a dylib already dlopen'd this run — fully removing a broken mode from a live session needs an app restart.

Roadmap: a real marketplace

What's here is intentionally a local, trust-based mechanism — a folder you (or someone you trust) put files into by hand. It is not a "browse and click install" marketplace for modes from strangers, and it shouldn't become one in this form: shipping a UI that downloads and dlopens arbitrary binaries from the internet would be a serious security liability (drive-by native code execution, no sandbox, no review).

A genuine public marketplace — install a mode from someone you've never met with one click — needs a fundamentally different, sandboxed mode format: something like a declarative JSON/JS description of the panel, interpreted by JavaScriptCore against a narrow, explicit capability bridge (e.g. "read now-playing track", "read battery level") instead of raw native code and unrestricted AnyObject/NotchViewModel access. That format would be auditable as plain text, portable across vaho versions without recompilation, and safe to run without the author's identity being a factor. This is deliberately out of scope for the current dylib-based system — it's a separate, future project.