Skip to main content

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.

Experimental

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.

Try it without your own app

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 prowl repository only to build prowl-macdriver. Install the CLI normally from npm with npm 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:

  1. $PROWL_MACDRIVER_BIN — an absolute path to a prebuilt binary, if set;
  2. the user install at ~/.prowl/macdriver/<version>/prowl-macdriver (once prowl macdriver install can run);
  3. the repo-local source build (macdriver/.build/release then macdriver/.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.typeRequired fieldsNotes
web (default)urlThe original web target. type is optional, so existing target: { url } configs are unchanged and keep defaulting to web.
macosappA 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
note

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 allowedApps list leaves the scope unset, and the target app is implicitly allowed — mirroring the way allowedDomains auto-includes the web target's own host.
  • When target.app is an app path, an entry in allowedApps may match the exact .app path, the bundle name (Example for Example.app), or the bundle id read from Contents/Info.plist when that file is readable.
  • forbiddenSelectors still applies on this target, using the same case-sensitive substring semantics as the web target — a handy guard against destructive menu items like Quit.

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.

SelectorMatches
id=openSettingselement 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 Saveelement whose title / description / value contains the text
statusItemopens 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:

  • statusItem presses 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, pressnavigate, waitForUrl, waitForNetworkIdle
wait, waitForSelectormockRoute / unmockRoute
assert: visible / notVisibleevalScript, runScript
screenshot, assertScreenshot, assertWithAIonDialog, select / selectOption
hover, scrollTosetInputFiles, waitForDownload
repeat, if, runHunt, copyTextscroll (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.

Use a recognized engine prefix in 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), and F1–F12.
  • Single printable characters (layout-independent).
  • Modifier combos joined with +, using Control / Shift / Alt / Meta (with Ctrl / Option / Cmd / Command aliases) — 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 / assertScreenshot steps use macOS screencapture, which grabs the whole screen (not a window-scoped image), and therefore need Screen Recording permission.
  • hover moves 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) with sqlite3 in their runner image — be aware that the access table's schema differs across macOS versions (a hardcoded INSERT from a blog post will break on another release), and SIP blocks direct writes to TCC.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.

note

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:

  1. Register a self-hosted macOS runner that runs in a logged-in GUI session (not a headless daemon).
  2. 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.
  3. Make the target app installed and registered with Launch Services so Prowl can launch it by bundle id.
  4. 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
note

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.

What's Next​