Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 11 additions & 6 deletions .github/workflows/cd-rust-cua-driver.yml
Original file line number Diff line number Diff line change
Expand Up @@ -248,11 +248,16 @@ jobs:
- name: Assemble CuaDriver.app bundle
working-directory: libs/cua-driver-rs
run: |
# Copy the bundle skeleton (Info.plist) from scripts/ and drop
# the universal binary into Contents/MacOS/cua-driver. The
# assembled bundle goes into every directory tarball so
# install.sh can `ditto` it to /Applications/CuaDriver.app
# for the TCC auto-relaunch path.
# Copy the bundle skeleton (Info.plist) from
# scripts/CuaDriverBundle/ and drop the universal binary into
# Contents/MacOS/cua-driver. The skeleton lives under a non-
# `.app` directory so macOS LaunchServices on developer
# machines doesn't index it as a second installed app with
# the `com.trycua.driver` bundle id (which used to surface as
# a "ghost CuaDriver" entry in System Settings → Privacy &
# Security). The assembled bundle goes into every directory
# tarball so install.sh can `ditto` it to
# /Applications/CuaDriver.app for the TCC auto-relaunch path.
#
# No codesigning at this layer — the bundle ships ad-hoc
# signed (the bare binary inherits whatever signature was
Expand All @@ -264,7 +269,7 @@ jobs:
# Swift driver does.
VERSION="${{ steps.version.outputs.version }}"
mkdir -p release/CuaDriver.app
cp -R scripts/CuaDriver.app/Contents release/CuaDriver.app/Contents
cp -R scripts/CuaDriverBundle/Contents release/CuaDriver.app/Contents
cp release/universal/cua-driver \
release/CuaDriver.app/Contents/MacOS/cua-driver
chmod +x release/CuaDriver.app/Contents/MacOS/cua-driver
Expand Down
46 changes: 28 additions & 18 deletions docs/content/docs/cua-driver/guide/getting-started/installation.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -346,21 +346,31 @@ If a grant still reads `NOT granted` after granting in the dialog, open **System
<Callout type="info">
**First-launch permissions gate (`cua-driver serve`).** On the Rust port,
`cua-driver serve` runs an interactive permissions gate at startup. If
Accessibility or Screen Recording is missing it presents a small native
`CuaDriver Permissions` window listing the missing grants with **Open
System Settings** and **Continue anyway** buttons, then polls until you
grant the missing items. When both grants are already active the gate is
a transparent no-op.

**Headless / terminal-only fallback.** The native window only shows when
the daemon is launched from the bundled `.app` (e.g.
`open -n -g -a CuaDriver --args serve`). Bare-binary launches
(`./target/release/cua-driver serve`), `CUA_DRIVER_RS_PERMISSIONS_PANEL=0`,
and environments with no graphical session fall back to the historical
terminal banner + polling flow.

**CI / headless runners** should skip the entire gate so the daemon does
not block waiting for a TTY-attached human:
Accessibility or Screen Recording is missing it prints a clear terminal
banner, opens the matching System Settings pane(s), and polls TCC every
second until you grant the missing items. When both grants are already
active the gate is a transparent no-op.

**Experimental native panel.** A `CuaDriver Permissions` NSPanel with
live status rows + auto-dismiss is also available behind an opt-in env
var. The terminal flow is the safer default because the panel relies on
a chain of macOS behaviours that are easy to break in dev environments
(TCC responsible-process attribution, session-level TCC caches that
survive `tccutil reset`, ad-hoc codesign identity mismatches, AppKit
modal-run-loop modes). Opt in with:

```bash
CUA_DRIVER_RS_PERMISSIONS_PANEL=1 cua-driver serve
```

Accepted "on" values (case-insensitive): `1`, `true`, `yes`, `on`. Any
other value (or unset) keeps the terminal flow. The panel also requires
the daemon to be launched from the bundled `.app` (e.g.
`open -n -g -a CuaDriver --args serve`); bare-binary launches always
use the terminal flow regardless of the env var.

**CI / headless runners** should skip the entire gate so the daemon
does not block waiting for a TTY-attached human:

```bash
# As a flag …
Expand All @@ -370,9 +380,9 @@ If a grant still reads `NOT granted` after granting in the dialog, open **System
CUA_DRIVER_RS_PERMISSIONS_GATE=0 cua-driver serve
```

Accepted "off" values for both env-vars (case-insensitive): `0`, `false`,
`no`, `off` — so `FALSE`, `Off`, `NO` etc. all work. Any other value
(including unset) leaves the corresponding behaviour active.
Accepted "off" values for the gate env-var (case-insensitive): `0`,
`false`, `no`, `off` — so `FALSE`, `Off`, `NO` etc. all work. Any
other value (including unset) leaves the gate active.
</Callout>

<Callout type="info">
Expand Down
8 changes: 6 additions & 2 deletions libs/cua-driver-rs/PARITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -1288,8 +1288,12 @@ Swift.
- `libs/cua-driver-rs/crates/cua-driver/src/proxy.rs` —
`run_proxy` (the stdio loop forwarding `tools/list` and
`tools/call` through the daemon socket).
- `libs/cua-driver-rs/scripts/CuaDriver.app/Contents/Info.plist` —
the bundle the auto-relaunch path lands in.
- `libs/cua-driver-rs/scripts/CuaDriverBundle/Contents/Info.plist` —
skeleton (Info.plist + empty MacOS/) that CD assembles into the
release-tarball `CuaDriver.app` that the auto-relaunch path lands
in. Stored under a non-`.app` directory so LaunchServices on
developer machines doesn't surface a ghost entry alongside the
real install.
- `libs/cua-driver-rs/scripts/install.sh` — drops the bundle to
`/Applications/CuaDriver.app` and symlinks the bin into it.
- Status: implemented on macOS (issue #1525); smoke-tested manually
Expand Down
95 changes: 65 additions & 30 deletions libs/cua-driver-rs/crates/platform-macos/src/permissions/gate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -218,32 +218,26 @@ pub fn run_if_needed(opts: GateOpts) -> Result<()> {

let missing = missing_from_status(initial);

// Phase 1: try to present a native NSPanel before falling back to
// the terminal banner. The presentation result tells us how to
// sequence the rest of the flow:
// Raise the TCC system prompts BEFORE showing our panel. The
// `AXIsProcessTrustedWithOptions` / `CGRequestScreenCaptureAccess`
// calls have a side effect critical to the user flow: they
// register the calling process with the TCC daemon, which is what
// makes the app appear in
// System Settings → Privacy & Security → {Accessibility,Screen Recording}
// with its toggle ready to flip. Without that registration, our
// "Open System Settings" button takes the user to a pane where
// CuaDriver simply isn't listed — they see nothing to grant.
//
// * `NotShown` — historical CLI path: print the banner, then auto-
// open Settings (when `open_settings` is true).
// * `ShownOpenSettings` — the user clicked the primary button; we
// auto-open Settings on their behalf and skip the banner.
// * `ShownDismissed` — the user clicked "Continue anyway" or the
// red dot; we skip both the banner AND the auto-open since the
// user explicitly declined the guided flow. They can still
// grant in their own time and the polling loop will pick it up.
let presentation = present_panel_if_available(&missing);
let should_auto_open_settings = match presentation {
PanelPresentation::NotShown => {
print_banner(&missing, opts.open_settings);
opts.open_settings
}
PanelPresentation::ShownOpenSettings => opts.open_settings,
PanelPresentation::ShownDismissed => false,
};

// The Swift gate did the same registration via the matching
// `Permissions.requestAccessibility()` / `requestScreenRecording()`
// calls before its panel appeared. Moving them earlier here closes
// a UX regression the original Phase 1 wiring introduced: prompts
// used to happen after the panel, racing with the user clicking
// "Open Settings".
//
// These calls are no-ops when the grant is already active so the
// happy-path (both green) sees no UI from this block.
if opts.also_raise_prompts {
// These are no-ops when the grant is already active and are the
// documented way to fire the first-launch system dialogs. Match
// the behaviour of the `check_permissions` MCP tool's default path.
if missing.contains(&MissingPermission::Accessibility) {
let _ = request_accessibility();
}
Expand All @@ -252,17 +246,54 @@ pub fn run_if_needed(opts: GateOpts) -> Result<()> {
}
}

// Try to present a native NSPanel before falling back to the
// terminal banner. The panel's 1 Hz poll can also auto-resolve
// before the user touches a button — the trailing
// `wait_for_grants` loop becomes optional in that case. Outcomes:
//
// * `NotShown` — historical CLI path: print the banner, auto-
// open Settings (when `open_settings` is true), wait.
// * `ShownOpenSettings` — user clicked the primary button; open
// Settings on their behalf, then wait.
// * `ShownDismissed` — user clicked "Continue anyway" or the red
// dot; skip the auto-open since the user declined the guided
// flow, but still wait so a later manual grant unblocks.
// * `ShownAllGranted` — the panel's poll loop saw both grants
// flip green; skip the wait loop entirely.
let presentation = present_panel_if_available(initial);
let should_auto_open_settings;
let skip_wait_loop;
match presentation {
PanelPresentation::NotShown => {
print_banner(&missing, opts.open_settings);
should_auto_open_settings = opts.open_settings;
skip_wait_loop = false;
}
PanelPresentation::ShownOpenSettings => {
should_auto_open_settings = opts.open_settings;
skip_wait_loop = false;
}
PanelPresentation::ShownDismissed => {
should_auto_open_settings = false;
skip_wait_loop = false;
}
PanelPresentation::ShownAllGranted => {
should_auto_open_settings = false;
skip_wait_loop = true;
}
}

if should_auto_open_settings {
// Open *both* missing panes up front. System Settings collapses
// duplicate-open requests to a single navigation, so this isn't
// disruptive even when only one grant is needed.
for m in &missing {
if let Err(e) = open_system_settings_for(*m) {
eprintln!(" (could not auto-open Settings for {}: {e})", m.label());
}
}
}

if skip_wait_loop {
return Ok(());
}
wait_for_grants(&opts)
}

Expand All @@ -277,25 +308,29 @@ enum PanelPresentation {
ShownOpenSettings,
/// Panel shown; user clicked "Continue anyway" or closed the window.
ShownDismissed,
/// Panel shown; its 1 Hz poll loop saw both grants flip green and
/// dismissed automatically. Caller can skip the trailing wait loop.
ShownAllGranted,
}

fn present_panel_if_available(missing: &[MissingPermission]) -> PanelPresentation {
fn present_panel_if_available(initial: PermissionsStatus) -> PanelPresentation {
#[cfg(target_os = "macos")]
{
use crate::permissions::panel;
if !panel::panel_enabled() {
return PanelPresentation::NotShown;
}
match panel::show_modal(panel::PanelOpts {
missing: missing.to_vec(),
initial_status: initial,
}) {
panel::PanelOutcome::OpenSettings => PanelPresentation::ShownOpenSettings,
panel::PanelOutcome::Dismissed => PanelPresentation::ShownDismissed,
panel::PanelOutcome::AllGranted => PanelPresentation::ShownAllGranted,
}
}
#[cfg(not(target_os = "macos"))]
{
let _ = missing;
let _ = initial;
PanelPresentation::NotShown
}
}
Expand Down
Loading
Loading