# Permissions model

> Microphone (AVCaptureDevice) and Accessibility (AXIsProcessTrusted) requirements, PermissionState tri-state (granted, notRequested, needsManual), onboarding prompt flow, polling strategy, and the silent relaunch after Accessibility grant.

- Repository: cartesia-ai/InkIt
- GitHub: https://github.com/cartesia-ai/InkIt
- Human docs: https://www.grok-wiki.com/public/docs/cartesia-ai-inkit-18975554254b
- Complete Markdown: https://www.grok-wiki.com/public/docs/cartesia-ai-inkit-18975554254b/llms-full.txt

## Source Files

- `InkIt/PermissionsService.swift`
- `InkIt/OnboardingView.swift`
- `InkIt/AppCoordinator.swift`
- `InkIt/Info.plist`
- `InkIt/InkIt.entitlements`

---

---
title: "Permissions model"
description: "Microphone (AVCaptureDevice) and Accessibility (AXIsProcessTrusted) requirements, PermissionState tri-state (granted, notRequested, needsManual), onboarding prompt flow, polling strategy, and the silent relaunch after Accessibility grant."
---

InkIt gates dictation on two macOS privacy grants tracked by the singleton `PermissionsService`: microphone capture via `AVCaptureDevice` TCC and Accessibility trust via `AXIsProcessTrusted()`. Both must be granted before onboarding advances past the permissions step or before `AppCoordinator.startDictation()` runs the capture-and-paste pipeline.

## Required permissions

| Permission | API | Why InkIt needs it |
| --- | --- | --- |
| Microphone | `AVCaptureDevice.authorizationStatus(for: .audio)` | `AudioCaptureService` records 16 kHz PCM while the hotkey is held and streams it to Cartesia STT. |
| Accessibility | `AXIsProcessTrusted()` | `HotkeyManager` installs suppressing `CGEventTap`s for Fn/Globe and bare-modifier bindings; `PasteService` synthesizes Cmd+V; `FocusedEditable` walks the AX tree to find editable targets. |

Carbon `RegisterEventHotKey` combos work without Accessibility, but the default Fn binding and paste-at-cursor both depend on the trust bit.

<Note>
InkIt ships **without App Sandbox** (`ENABLE_APP_SANDBOX: NO` in `project.yml`). Synthesized paste and global event taps require an unsandboxed binary.
</Note>

### Bundle declarations

Microphone and Apple Events usage strings live in `Info.plist`:

- `NSMicrophoneUsageDescription` — explains voice capture for Cartesia STT.
- `NSAppleEventsUsageDescription` — explains pasting into the focused app.

Entitlements in `InkIt.entitlements`:

- `com.apple.security.device.audio-input` — microphone access.
- `com.apple.security.automation.apple-events` — Apple Events for paste automation.

## PermissionState tri-state

Each permission maps to a `PermissionState` enum that drives onboarding card UI and manual-fix flows:

<ResponseField name="granted" type="PermissionState">
  The OS reports the permission as active. Onboarding shows an "Enabled" checkmark; Settings shows "Enabled".
</ResponseField>

<ResponseField name="notRequested" type="PermissionState">
  The system prompt has not been fired yet (microphone: `.notDetermined`; Accessibility: no prior `requestAccessibility()` call and no `resumeOnboardingAtPermissions` flag). The card offers an **Enable** button that triggers the TCC prompt.
</ResponseField>

<ResponseField name="needsManual" type="PermissionState">
  macOS will not show the prompt again — after denial, restriction, or a prior prompt attempt. The UI switches to numbered manual steps and an **Open System Settings** button that deep-links to the correct pane without re-firing TCC.
</ResponseField>

### Mapping rules

**Microphone** derives directly from `AVCaptureDevice.authorizationStatus(for: .audio)`:

| `AVAuthorizationStatus` | `PermissionState` |
| --- | --- |
| `.authorized` | `granted` |
| `.notDetermined` | `notRequested` |
| `.denied`, `.restricted` | `needsManual` |

**Accessibility** has no denied/restricted API — `AXIsProcessTrusted()` is simply `true` or `false`. InkIt infers `needsManual` when either:

- `requestAccessibility()` has already fired `AXIsProcessTrustedWithOptions(prompt: true)`, setting in-memory `axRequestedAt` and persisting `resumeOnboardingAtPermissions` in `UserDefaults`, or
- the `resumeOnboardingAtPermissions` flag is set (survives silent relaunch).

```mermaid
stateDiagram-v2
    [*] --> notRequested
    notRequested --> granted: User grants in TCC prompt
    notRequested --> needsManual: User denies or prompt already fired
    needsManual --> granted: User toggles in System Settings
    granted --> needsManual: User revokes in System Settings
```

## PermissionsService

`PermissionsService` is a `@MainActor` `ObservableObject` singleton (`PermissionsService.shared`) exposing:

| Published property | Type | Meaning |
| --- | --- | --- |
| `hasMicrophone` | `Bool` | `AVCaptureDevice` audio authorization is `.authorized` |
| `hasAccessibility` | `Bool` | `AXIsProcessTrusted()` returns `true` |
| `microphoneState` | `PermissionState` | Tri-state for onboarding UI |
| `accessibilityState` | `PermissionState` | Tri-state for onboarding UI |

### Refresh and polling

`refresh()` re-reads both grants and updates all four published properties.

Polling runs on a **0.5 s** repeating timer added to `RunLoop.main` in `.common` mode so updates continue while InkIt is backgrounded and the user is in System Settings. `startPolling()` creates the timer; `stopPolling()` intentionally does **not** invalidate it — polling stays alive for the process lifetime so one UI surface disappearing cannot leave another showing stale state.

An additional refresh fires on `NSApplication.didBecomeActiveNotification`.

### Request methods

**`requestMicrophone(_:)`**

1. `.notDetermined` — calls `AVCaptureDevice.requestAccess(for: .audio)` and, on a background queue, briefly starts an `AVAudioEngine` to force TCC evaluation under hardened runtime.
2. `.authorized` — refreshes and completes with `true`.
3. `.denied` / `.restricted` — opens `x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone` and completes with `false`.

**`requestAccessibility()`**

Fires at most once per untrusted period:

1. First call — `AXIsProcessTrustedWithOptions([kAXTrustedCheckOptionPrompt: true])`, which pre-adds InkIt to the Accessibility list (disabled) so the user can flip the toggle without using the **+** button. Sets `axRequestedAt` and `resumeOnboardingAtPermissions = true`.
2. Subsequent calls — only `openAccessibilitySettings()` (no re-prompt; avoids the "bubble keeps popping" loop).

<Warning>
Call `requestAccessibility()` only from explicit user actions (onboarding **Enable**, Settings **Enable**, throttled hotkey path). Never from the dictation hot path on every key press.
</Warning>

**`openAccessibilitySettings()`** — deep-links to `Privacy_Accessibility` without firing the TCC prompt. Used by `needsManual` cards.

**`confirmAccessibilityGrant()`** — programmatic re-check after the user returns from Settings. Refreshes; if still untrusted after more than 1 s since `axRequestedAt`, triggers silent relaunch. Not currently wired to a Settings UI button, but available for confirmation flows.

## Onboarding prompt flow

Onboarding step `.permissions` (`PermissionsStep`) presents two `PermissionCard` rows bound to `microphoneState` and `accessibilityState`.

<Steps>
<Step title="Land on permissions">
On first launch, the user reaches the permissions step after Welcome. If a silent relaunch just occurred, `OnboardingRootView` reads `resumeOnboardingAtPermissions`, clears it, and resumes directly at `.permissions`.
</Step>

<Step title="Grant microphone">
Tap **Enable** on the Microphone card. macOS shows the TCC dialog. On grant, polling flips `hasMicrophone` and the card shows **Enabled**. On deny, the card expands into the manual-fix layout with steps for **Privacy & Security ▸ Microphone**.
</Step>

<Step title="Grant Accessibility">
Tap **Enable** on the Accessibility card. InkIt fires the system "would like to control this computer" prompt and opens the Accessibility pane. Flip the InkIt toggle. Polling detects the grant via `AXIsProcessTrusted()`.
</Step>

<Step title="Continue">
**Continue** appears only when `hasMicrophone && hasAccessibility`. The progress dots gate forward navigation on the same live checks.
</Step>
</Steps>

### PermissionCard UI modes

| `PermissionState` | Card appearance | Primary action |
| --- | --- | --- |
| `notRequested` | Default row with subtitle | **Enable** → `enable` closure |
| `granted` | Green "Enabled" label | None |
| `needsManual` | Amber-tinted card with numbered steps | **Open System Settings** → `openSettings` (never re-prompts) |

`PermissionsStep.onAppear` calls `refresh()` and `startPolling()`.

## Settings surface

After onboarding, **Settings ▸ General ▸ Permissions** shows two `PermissionRow` entries with the same `requestMicrophone` / `requestAccessibility` actions. Unlike onboarding cards, Settings rows do not render the `needsManual` expanded layout — they show **Enable** until `granted` is true. Search indexes both rows under keywords like `mic`, `accessibility`, and `permission`.

`GeneralSettingsPane` starts polling on appear and calls `stopPolling()` on disappear (which only refreshes, per the shared polling policy).

## Dictation hot path

`AppCoordinator.startDictation()` refreshes permissions before recording:

1. Missing Cartesia API key → HUD error `"Add your API key"`.
2. `!hasMicrophone` → HUD error `"Mic access needed"`; calls `requestMicrophone`.
3. `!hasAccessibility` → HUD error `"Accessibility needed"`; calls `requestAccessibility()` at most once per **10 s** (`accessibilityPromptThrottle`) so repeated hotkey presses do not yank System Settings forward every time.

When Accessibility flips on mid-session, `AppCoordinator` re-registers the hotkey so Fn bindings upgrade from passive `NSEvent` monitors to suppressing `CGEventTap`s. Revocation triggers re-registration to downgrade and avoid a dead tap swallowing key presses.

### Duplicate bundle instances

At launch, `detectDuplicateRunningCopies()` warns when multiple processes share `ai.cartesia.InkIt`. Accessibility grants are per-bundle-path; running two copies (e.g. `/Applications/InkIt.app` and a local build) causes confusing trust behavior.

## Silent relaunch after Accessibility grant

macOS can leave `AXIsProcessTrusted()` stale in the running process even after the user toggles Accessibility on. When `confirmAccessibilityGrant()` determines trust is still false more than 1 s after the initial prompt, `relaunch()` runs:

```text
PermissionsService.relaunch()
  ├─ UserDefaults["resumeOnboardingAtPermissions"] = true
  ├─ NSWorkspace.openApplication(createsNewApplicationInstance: true)
  └─ NSApp.terminate(nil)   // old process exits
```

The new instance reads `resumeOnboardingAtPermissions` in `OnboardingRootView`, clears the flag, and opens at the permissions step so polling can read the fresh trust bit. On successful grant detection, `confirmAccessibilityGrant()` clears `axRequestedAt` and removes the resume flag.

<Info>
The relaunch is silent from the user's perspective: no dialog, just a brief process swap. If `openApplication` fails, InkIt falls back to opening Accessibility Settings.
</Info>

## Architecture overview

```mermaid
flowchart TB
    subgraph ui [UI surfaces]
        OB[Onboarding PermissionsStep]
        SET[Settings General Permissions]
    end

    subgraph svc [PermissionsService]
        PS[refresh / polling 0.5s]
        RM[requestMicrophone]
        RA[requestAccessibility]
        RL[relaunch]
    end

    subgraph os [macOS TCC]
        MIC[AVCaptureDevice audio TCC]
        AX[AXIsProcessTrusted]
    end

    subgraph coord [AppCoordinator]
        SD[startDictation guards]
        HK[HotkeyManager register]
    end

    OB --> PS
    SET --> PS
    OB --> RM & RA
    SET --> RM & RA
    PS --> MIC & AX
    RM --> MIC
    RA --> AX
    RA --> RL
    SD --> PS
    PS --> HK
```

## Troubleshooting signals

| Symptom | Likely cause | Fix |
| --- | --- | --- |
| **Enable** does nothing for Accessibility | Prior prompt already fired (`needsManual`) | Use **Open System Settings**; toggle InkIt in Accessibility |
| Hotkey works but Globe/Emoji still fires | Accessibility not granted; Fn tap fell back to passive monitor | Grant Accessibility; hotkey re-registers automatically |
| Grant toggled but InkIt still shows disabled | Stale in-process trust bit | Quit and relaunch, or wait for `confirmAccessibilityGrant` / silent relaunch path |
| "Multiple InkIt copies are running" | Two bundle paths both running | Quit duplicate; grant Accessibility to only one copy |
| Mic prompt never appears on first tap | Hardened-runtime TCC quirk | `requestMicrophone` also touches `AVAudioEngine`; retry or use manual Settings path |

## Related pages

<CardGroup>
<Card title="Quickstart" href="/quickstart">
Complete onboarding permissions, API key, and first dictation trial.
</Card>
<Card title="Configure hotkeys and dictation mode" href="/configure-hotkeys-and-mode">
Verify hotkey registration after Accessibility is granted.
</Card>
<Card title="Paste and focus reference" href="/paste-and-focus-reference">
How Accessibility powers Cmd+V paste and editable-field detection.
</Card>
<Card title="Runtime troubleshooting" href="/runtime-troubleshooting">
Duplicate bundle instances, stale Accessibility grants, and debug logging.
</Card>
<Card title="Installation" href="/installation">
Entitlements, no App Sandbox, and bundle identifier `ai.cartesia.InkIt`.
</Card>
</CardGroup>
