# The menu bar app

Source: https://heyparlour.app/docs/desktop

> What the app does, how it drives the CLI, and how to run it from a checkout.

The menu bar app handles the everyday things without a terminal: start, stop,
is it listening, what did it just hear, why is it not answering.

It is called Parlour Server, to tell it apart from Hey Parlour, the iPhone app
that talks to it.

`apps/desktop` is a Tauri app, a Rust shell around the `parlour` command with a
window as its only interface. It is not a second implementation. It learns
everything by running the CLI, and writes every setting through
`parlour config write`, so the app and the terminal always see the same
configuration.

## Building it

You need Node 22, pnpm and a Rust toolchain (`rustup`).

```sh
pnpm install
pnpm -C apps/desktop app        # the window from the checkout, with live reload
pnpm -C apps/desktop build      # Parlour Server.app and a dmg, in apps/desktop/src-tauri/target/release/bundle
```

Live reload covers the window. The CLI it drives is whichever `parlour` the
app's settings point at. On a fresh machine that is the globally installed one,
not the checkout. See
[Running the app against a checkout](#running-the-app-against-a-checkout).

A build from your checkout is unsigned, which is fine on the machine that made
it. Copy `Parlour Server.app` into `/Applications` and right-click to open it the
first time. A release dmg is signed with a Developer ID certificate and
notarised, so it opens normally on any Mac with Apple silicon, which is what
it is built for. Each release is on
[GitHub](https://github.com/mrprkr/parlour/releases/latest), and the newest
dmg is always at
[`releases/latest/download/Parlour-Server-arm64.dmg`](https://github.com/mrprkr/parlour/releases/latest/download/Parlour-Server-arm64.dmg).

The app does not bundle Node or the package. It looks for `parlour` on your
login shell's PATH, so nvm, fnm and volta installs are found, then in
`/opt/homebrew/bin`, `/usr/local/bin` and `~/.npm-global/bin`. If it cannot
find one, it offers to run `npm install -g parlour` at its own version, which
needs Node 22 or newer. One copy of Parlour on the machine is better than two
that can disagree.

## Signing

Notarising requires the hardened runtime, which blocks the microphone unless
the app asks for it. `src-tauri/Entitlements.plist` asks, and `Info.plist` next
to it holds the sentence macOS shows the user. ffmpeg does the recording, but
macOS attributes the request to the app that started it, so both files belong
to the app.

The desktop release workflow sets the Apple environment variables Tauri reads. Tauri
creates its own keychain for the certificate, signs the app, notarises
`Parlour Server.app` and staples the ticket into it. It signs the dmg but does not
notarise it, and macOS refuses a disk image without its own ticket. So
`scripts/notarise.sh`, at the root of the repository, notarises and staples
the dmg, then asks Gatekeeper to assess both and checks the app's signature
carries the hardened runtime and the microphone entitlement. Run on a
machine with no Apple credentials, it says so and stops.

[`.github/workflows/desktop-release.yml`](https://github.com/mrprkr/parlour/blob/main/.github/workflows/desktop-release.yml)
lists the six repository secrets behind it and what each one is.

## Setting up

On a machine that is not set up yet, the app opens on setup instead of the
tabs, and walks you through it one step at a time. Anything optional stays
folded away until you ask for it. **Run setup again** in Settings brings it
back later, skipping the welcome.

1. **Install.** One button installs `parlour` from npm if it is missing, then
   runs `parlour init --porcelain --yes` for the rest (ffmpeg and whisper from
   Homebrew, the models, the config). The log stays folded away unless you ask
   for it or something fails. **Already have parlour somewhere else?** takes a
   path instead.
2. **Voice.** Allow the microphone, choose the wake word, and optionally a
   different microphone.
3. **Home.** The Home Assistant address and token. Skip it and timers,
   questions and search still work.
4. **Extras.** Cloud help (the Anthropic key) and letting other devices in with
   an access token, each behind its own switch.
5. **Finish.** The doctor's checks, and a button to start listening.

## The tabs

The header carries a dot that follows Parlour through idle, listening,
thinking and speaking, and a Start or Stop button.

| Tab | What you will find there |
| --- | --- |
| Status | The last thing it heard, the last thing it said and which model said it. How many tools it has and whether cloud escalation is on. The address phones and Home Assistant should use, and **Pair a phone**, which shows the QR code the [iOS app](/docs/ios) scans. A Check button that runs `parlour doctor --json`. |
| Connectors | What your household has signed in to, and a form to add another. `parlour connectors`, with buttons. |
| Settings | Where `parlour` is, whether to start listening when the app opens, wake word, sensitivity, microphone, silence timeout, voice, the local and cloud models, the Home Assistant address and the secrets, including the access token. It reads `parlour config show --raw` (the file as written, so saving does not freeze every default into it) and writes through `parlour config write` and `parlour secrets set`. |
| Logs | Parlour's output, live. |

The tray menu has Open, Start listening, Stop and Quit. Quit asks first,
because it stops more than the app. The agent and the services it keeps
running (whisper and the local model) are stopped too, so they do not keep
holding memory for a server nobody can reach. They start again when you open
the app, or at the next login if they are installed as services. When a
client restarts the agent to apply a setting, the app starts it again
itself, as launchd would.

## How it fits together

```text
src/
  App.tsx           the header, the tabs, and the state the panels read
  panels/           one file per tab, plus the setup overlay and its parts in onboarding/
  lib/bridge.ts     every Rust command, typed. Nothing else calls invoke()
  components/ui/    shadcn primitives
  |  invoke()
src-tauri/src/
  main.rs           the tray, the window, one generic parlour(args, stdin) command, the mic check
  supervisor.rs     parlour start --events as a child process, and its NDJSON events
  setup.rs          parlour init --porcelain, and npm install -g parlour, streamed
  settings.rs       the app's own two facts: where parlour is, and whether to start it
```

Apart from running ffmpeg to list microphones and raise the permission
prompt, the Rust side knows the path to the `parlour` binary and nothing else.
The window asks for config, secrets, the doctor and the connectors through the
CLI's `--json` and stdin interfaces. The supervisor reads the NDJSON events
that `parlour start --events` prints (`ready`, `state`, `heard`, `reply`,
`muted`, `error`) instead of scraping log text. Anything else it prints is log
output, and that is where a failure to start shows up.

Stopping sends SIGINT, not SIGKILL. Parlour shuts ffmpeg down on SIGINT, and an
orphaned ffmpeg would keep hold of the microphone.

The app's own settings live at
`~/Library/Application Support/io.parlour.desktop/settings.json`:
`{ "parlourBin": "/opt/homebrew/bin/parlour", "autostart": false }`.

## Running the app against a checkout

`pnpm -C apps/desktop app` reloads the window from the checkout, but every CLI
call goes to the `parlourBin` in `settings.json`. The app fills that in by
asking your login shell for `parlour`. So the app does not exercise your
changes to `packages/parlour` until you point it there.

`packages/parlour/bin/parlour-dev` runs the CLI from source. Point the app at
it in either of two ways:

- Put its absolute path in **The parlour command** on the Settings tab (or
  under **Already have parlour somewhere else?** on the Install step of
  setup). This takes effect at once.
- Write it to the file and restart the app, which reads the file once at
  launch:

```json
{ "parlourBin": "/path/to/parlour/packages/parlour/bin/parlour-dev", "autostart": false }
```

The app keeps the path while it is executable and will not swap in the global
copy. The dev build and the installed `Parlour Server.app` share this settings file,
so when you are done, put the global path back, or delete the file and let the
app find `parlour` again.

## Three things to know

- **Use the app or the LaunchAgent, not both.** Two copies of Parlour means
  two processes fighting over one microphone and one port. `parlour init` asks
  which you want and sets up only that one. When the app runs `init`, it
  leaves the agent to the app, and warns if a LaunchAgent is already
  installed. `parlour service status` tells you whether a LaunchAgent exists,
  and `parlour service uninstall` removes it.
- **Microphone permission belongs to whatever starts Parlour.** macOS grants it
  to the process it holds responsible, which for anything the app spawns is
  the app. Setup opens the device for a moment as early as it can, which
  brings up the system prompt. The bundle carries the
  `NSMicrophoneUsageDescription` the prompt needs. Without it, macOS refuses
  instead of asking. The grant is tied to the bundle identifier, so it
  survives rebuilds, and it covers Parlour as the app's child. A LaunchAgent
  needs its own grant and asks the first time it runs.
- **So does the local network.** Since macOS 15, a process has to be allowed
  onto the local network before phones, satellites and Home Assistant can
  find or reach it, and until then Bonjour fails with "no route to host". The
  doctor sends one Bonjour question, which brings up the prompt during setup
  rather than when the phone first looks, and fails with the way to the
  switch if it was refused: System Settings, Privacy & Security, Local
  Network. The bundle carries `NSLocalNetworkUsageDescription` for it. The
  Mac has no multicast entitlement to add: Apple's is for iOS, and on macOS
  the permission is the whole story.

## Deliberate choices

- **No Tauri plugins.** Everything the app does happens in Rust, where it is
  easy to reason about. The window's capability list is just `core:default`.
- **A true menu bar app.** The activation policy is Accessory, so there is no
  Dock icon and no menu bar of its own. The tray is the way back to the
  window. Closing the window leaves Parlour running. Quitting from the tray
  asks, then stops the agent and everything it keeps warm.
- **Generated icons.** The mark is drawn once, in `packages/design`, and
  `pnpm exec nx run design:icons` writes `src-tauri/icon.png` and the menu bar's
  `src-tauri/tray.png` from it (see [the design system](/docs/design)).
  `pnpm icons` derives every other size into `src-tauri/icons/`, which is not
  in git, and both `app` and `build` run it first. The source sits outside
  that directory on purpose. `tauri icon` writes an `icon.png` into its
  output, so a source kept there would overwrite itself on every build and
  leave the working tree dirty.
