macOS Target (Experimental)
Prowl can drive native macOS apps — including menu bar extras (NSStatusItem + NSMenu) — through Apple's Accessibility API, in addition to the web. You point target.type at macos, write the same portable steps you already know, and Prowl runs them against a real app instead of a browser page.
This target is experimental. The macOS code path ships in every prowl-tools install, but its Swift helper binary, prowl-macdriver, is not yet distributed as a signed release. The prowl macdriver install command exists (added in 0.1.7), but until the first signed helper release is cut it returns a clear "no release yet — build from source" error. Building the helper from source is the working path today (see Requirements); you can also point PROWL_MACDRIVER_BIN at a binary you built. The API, selector dialect, and step coverage may still change, and the target stays experimental until the two-minute signed install lands.
prowl init scaffolds a macos-hello.yml starter that drives TextEdit — on every Mac — so you can exercise the macOS target before wiring up your own app. See Starter Templates.
Requirements
- macOS 13 or newer (the helper's platform target).
- A Swift toolchain — Xcode or the Xcode Command Line Tools — to build the helper from source (the working path today).
- A source checkout of the
prowlrepository only to buildprowl-macdriver. Install the CLI normally from npm withnpm install -g prowl-tools; a source checkout is not required for CLI installation. - Accessibility permission for the terminal that hosts Prowl — and Screen Recording permission too, if your hunt takes screenshots. See Permissions.
Enabling it
1. Get the helper
The macOS target is powered by a small Swift helper, prowl-macdriver, that talks to the Accessibility API.
Build it from source (the working path today). The signed, prebuilt helper has not been released yet, so build it once from a source checkout of the prowl repo:
cd macdriver
swift build -c release
To point at a binary you built elsewhere, export PROWL_MACDRIVER_BIN:
export PROWL_MACDRIVER_BIN="/path/to/prowl-macdriver"
The prowl macdriver commands (0.1.7). Prowl ships two subcommands for managing the helper:
prowl macdriver install # download + verify + install the signed helper
prowl macdriver status # show the resolved binary, versions, and permissions
prowl macdriver install will download the pinned, signed, notarized helper from GitHub Releases, verify its checksum and Developer ID signature, and install it under ~/.prowl/macdriver/<version>/ — once the first signed release is cut. Until then it exits with a clear message pointing you back to the source build:
No published prowl-macdriver release for macdriver-vX.Y.Z yet.
The signed binary is cut by the maintainer; until then build from source:
cd macdriver && swift build -c release
prowl macdriver status reports which binary resolved and how, the installed versions, whether the binary runs, and Accessibility / Screen Recording guidance — useful for confirming your source build is picked up.
Prowl resolves the helper in this order:
$PROWL_MACDRIVER_BIN— an absolute path to a prebuilt binary, if set;- the user install at
~/.prowl/macdriver/<version>/prowl-macdriver(onceprowl macdriver installcan run); - the repo-local source build (
macdriver/.build/releasethenmacdriver/.build/debug).
If none exist, Prowl fails with a clear message that leads with prowl macdriver install and names the source build as the contributor fallback.
2. Point your config at a macOS target
# .prowl/config.yml
target:
type: macos
app: com.example.MyMenuBarApp # bundle id, or an absolute /path/to/App.app
3. Grant permission and run
Grant Accessibility permission to your terminal, then run a hunt exactly as you would for the web:
prowl run my-macos-hunt
Configuration
The target block carries a discriminant, type:
target.type | Required fields | Notes |
|---|---|---|
web (default) | url | The original web target. type is optional, so existing target: { url } configs are unchanged and keep defaulting to web. |
macos | app | A bundle id (e.g. com.example.App) or an absolute path to an .app bundle. url is not accepted on this target. |
target:
type: macos
app: com.example.MyMenuBarApp
url and app are mutually exclusive: the macos branch rejects url, and a target with neither url nor a macOS app fails config validation with a union error. Your existing web configs are unaffected — omitting type still selects the web target.
App-launch timeout
browser.timeout (default 30000 ms) does double duty on this target: besides bounding page-style operations, it also bounds how long Prowl waits for the target app to launch and become ready. Raise it for apps that are slow to start.
guardrails.allowedApps
allowedApps is the native-scope analog of allowedDomains — it restricts which app a hunt may drive.
guardrails:
allowedApps:
- com.example.MyMenuBarApp
forbiddenSelectors:
- "Quit"
- An empty or omitted
allowedAppslist leaves the scope unset, and the target app is implicitly allowed — mirroring the wayallowedDomainsauto-includes the web target's own host. - When
target.appis an app path, an entry inallowedAppsmay match the exact.apppath, the bundle name (ExampleforExample.app), or the bundle id read fromContents/Info.plistwhen that file is readable. forbiddenSelectorsstill applies on this target, using the same case-sensitive substring semantics as the web target — a handy guard against destructive menu items likeQuit.
prowl login and the allowedDomains / URL guardrails do not apply on the macOS target.
Selector dialect
Native selectors address accessibility identifiers, roles, and labels. Prefer id= (an accessibility identifier) — it is the native analog of data-testid.
| Selector | Matches |
|---|---|
id=openSettings | element whose AXIdentifier equals openSettings |
role=button[name="Save"] | an AXButton whose title / description / value contains Save |
label="Email" | element whose accessibility label equals Email |
text="Save" or bare Save | element whose title / description / value contains the text |
statusItem | opens the app's menu bar status-item menu (and leaves it open) |
menu=<title> | opens the status menu and clicks the item whose title contains the text |
The last two are menu bar magic selectors:
statusItempresses the app's menu bar extra to open its status menu and leaves it open, so subsequent steps can act on the revealed items.menu=<title>opens the status menu and clicks the item whose title contains<title>(case-insensitive) — a one-step shortcut for "open the menu, then click this item".
# Open the menu bar extra, then click an item by its role/name
- click:
selector: statusItem
- click:
selector: role=menuItem[name="Preferences…"]
# Or do both in one step
- click:
selector: menu=Preferences…
Step compatibility
Portable steps run on both targets. Web-only steps in the top-level hunt are rejected up front at validation time on the macOS target — with a friendly error — before anything launches. A runHunt step validates its referenced hunt when that step executes, before the nested hunt starts.
| Portable (web and macOS) | Rejected on macOS |
|---|---|
click, fill, type, press | navigate, waitForUrl, waitForNetworkIdle |
wait, waitForSelector | mockRoute / unmockRoute |
assert: visible / notVisible | evalScript, runScript |
screenshot, assertScreenshot, assertWithAI | onDialog, select / selectOption |
hover, scrollTo | setInputFiles, waitForDownload |
repeat, if, runHunt, copyText | scroll (directional), assert: urlIncludes / urlEquals |
Most rejected steps are web-only and produce this error:
Step "navigate" is not supported by the macOS target. It is web-only;
use a portable step (click, fill, type, press, wait, assert visible, screenshot, etc.).
Directional scroll is the exception — it is not web-only (it runs on the Android and iOS touch targets as a swipe), but there is no macOS accessibility equivalent, so it is rejected here with its own message pointing you at scrollTo (which macOS does support, via AXScrollToVisible). hover and scrollTo are both portable on macOS.
Assertions on the macOS target
Use inline assert: visible / notVisible steps for mid-flow checks. URL assertions (urlIncludes / urlEquals) are web-only and are rejected.
As of 0.1.7, hunt-level assertions: blocks run on this target: selectorExists / selectorNotExists are evaluated against the app (and pass through guardrails.forbiddenSelectors first), while web-only assertion types (urlIncludes, urlEquals, noConsoleErrors, noNetworkErrors) are reported as skipped rather than dropped. See Assertions.
In an assert: visible / notVisible value, anything without a recognized selector prefix is treated as text to match. The recognized prefixes on this path are text=, id=, and role= (plus css= / xpath=). Notably, label= is not recognized here — visible: "label=Email" is matched as the literal text label=Email, not as a label selector.
So in assertions, write text="Settings", id=statusLabel, or role=staticText[name="Settings"]. Reserve label= for interaction steps like click and fill, where the native selector parser does understand it.
# Good — matched as a text selector
- assert:
visible: text="Settings"
# Also fine — plain prose is matched as text
- assert:
visible: "Settings"
The press key vocabulary
As of 0.1.7, press on the macOS target accepts the web target's full key vocabulary (previously it mapped only Enter / Return / Space):
- Named keys:
Escape,Tab,Backspace,Delete,Home,End,PageUp,PageDown, the arrows (ArrowUp/ArrowDown/ArrowLeft/ArrowRight), andF1–F12. - Single printable characters (layout-independent).
- Modifier combos joined with
+, usingControl/Shift/Alt/Meta(withCtrl/Option/Cmd/Commandaliases) — e.g.Meta+s,Control+a,Shift+Tab.
Key names are matched case-insensitively, and an unknown key fails with a category summary of what's supported.
- press:
selector: id=searchField
key: "Meta+a" # select-all in the focused field
- press:
selector: id=searchField
key: "Escape"
A bare Enter / Return / Space still takes the deterministic, focus-independent AXPress fast path when the element supports it. Every other key activates the target app, focuses the resolved element, and posts real keyDown / keyUp events to the app's process — so keystrokes can never land in another application. No extra permission is required beyond the Accessibility grant.
Known limitations
This is an early implementation. Today:
- Screenshots are full-screen. The
screenshot/assertScreenshotsteps use macOSscreencapture, which grabs the whole screen (not a window-scoped image), and therefore need Screen Recording permission. hovermoves the real mouse cursor to the target element, rather than dispatching a synthetic hover.- App teardown quits the target app after the run.
Permissions
macOS gates Accessibility and Screen Recording behind explicit, per-app permission.
Accessibility
The process that hosts Prowl — your terminal (Terminal, iTerm, VS Code, …) or a CI agent — must be granted Accessibility permission. macOS attributes the grant to the hosting app, not to prowl-macdriver.
Grant it under System Settings → Privacy & Security → Accessibility, then enable your terminal app. You can preflight from the helper itself:
macdriver/.build/release/prowl-macdriver check # prints {"trusted": <bool>}; prompts on first run
Screen Recording
The screenshot / assertScreenshot steps additionally need Screen Recording permission for the same hosting app (System Settings → Privacy & Security → Screen Recording).
CI notes (macOS runners)
Headless CI can't click "Allow" in a permission dialog, so the permissions must be pre-provisioned before the run:
-
Grant Accessibility (and Screen Recording, if you screenshot) to the runner's host app ahead of time. The supported route is a PPPC/TCC configuration profile via MDM on a self-hosted runner. Some teams instead seed the TCC database (
/Library/Application Support/com.apple.TCC/TCC.db) withsqlite3in their runner image — be aware that theaccesstable's schema differs across macOS versions (a hardcodedINSERTfrom a blog post will break on another release), and SIP blocks direct writes toTCC.db, so this only works on images with SIP disabled or a shell that has Full Disk Access. -
The target app must be installed and registered with Launch Services on the runner so Prowl can launch it by bundle id.
GitHub-hosted macOS runners do not grant Accessibility, so the macOS target is aimed at self-hosted / MDM-managed runners for now.
Worked example
A minimal menu bar hunt: open the app's status menu, click Settings, wait for the settings window's label, and assert it's visible.
# .prowl/config.yml
target:
type: macos
app: com.example.MyMenuBarApp
guardrails:
allowedApps:
- com.example.MyMenuBarApp
forbiddenSelectors:
- "Quit"
# .prowl/hunts/settings-window.yml
name: settings-window
steps:
- click:
selector: menu=Settings
- waitForSelector:
selector: text="Settings"
timeout: 10000
- assert:
visible: text="Settings"
prowl run settings-window
The menu=Settings selector opens the menu bar extra and clicks the Settings item in one step; waitForSelector then waits for the settings window's Settings label to appear before the inline assertion confirms it's visible.
Testing a macOS app in CI
Driving a real macOS app needs a graphical login session and pre-granted Accessibility (and, for screenshots, Screen Recording) permission — which GitHub-hosted macOS runners do not provide. So macOS hunts run on a self-hosted / MDM-managed runner you control.
One-time runner setup:
- Register a self-hosted macOS runner that runs in a logged-in GUI session (not a headless daemon).
- Pre-provision permissions for the runner's host app (the shell/agent that launches Prowl) — see Permissions. The supported route is a PPPC/TCC configuration profile via MDM; grant Accessibility, plus Screen Recording if hunts screenshot.
- Make the target app installed and registered with Launch Services so Prowl can launch it by bundle id.
- Provide the helper. Until the signed release lands, build it once and expose it via
PROWL_MACDRIVER_BIN(see Get the helper).
A minimal hunt (≤20 lines) — open the app's menu, open Settings, assert it appeared:
# .prowl/hunts/macos-smoke.yml
name: macos-smoke
steps:
- click:
selector: menu=Settings
- waitForSelector:
selector: text="Settings"
timeout: 10000
- assert:
visible: text="Settings"
A workflow on the self-hosted runner (permissions already provisioned). Because this runner has desktop permissions, trigger it only from trusted code paths such as protected-branch pushes or manual dispatches on the main branch; do not run privileged self-hosted macOS jobs on untrusted pull_request code:
name: macOS E2E
on:
workflow_dispatch:
push:
branches: [main]
permissions:
contents: read
jobs:
macos:
if: github.ref == 'refs/heads/main'
runs-on: [self-hosted, macOS]
env:
PROWL_MACDRIVER_BIN: ${{ github.workspace }}/prowl-source/macdriver/.build/release/prowl-macdriver
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.sha }}
persist-credentials: false
- name: Check out Prowl helper source
uses: actions/checkout@v4
with:
repository: prowl-tools/prowl
path: prowl-source
persist-credentials: false
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- run: npm run build
- name: Build the macOS helper
run: (cd prowl-source/macdriver && swift build -c release)
- name: Run macOS hunts
run: npx prowl ci --junit
- name: Upload artifacts
if: always()
uses: actions/upload-artifact@v4
with:
name: macos-artifacts
path: .prowl/runs/**
if-no-files-found: ignore
This mirrors how the Prowl repo dogfoods its own native gates on a self-hosted Mac. Once the signed helper release ships, the "Build the macOS helper" step becomes npx prowl macdriver install and the PROWL_MACDRIVER_BIN override is no longer needed.