curtain/.github/wiki/Troubleshooting.md
Aric Camarata 8c19e960d2 Detection root-cause fix + audit batch: netstat path, UDP activator, settings coherence, refactor, docs
Detection: netstat lives at /usr/sbin/netstat, not /usr/bin — the hardcoded wrong
path silently killed the ESTABLISHED-TCP activator (root cause of the failed live
test). Fixed and live-verified. Added peered-UDP activator (5900-5902) for
High-Performance sessions, per-signal transition logging, unconditional error
logging for dead probe helpers, and probe v2 with full CGSession dictionary
diffing. 7 new parser tests (32 total).

Fixes from a full audit + adversarial review: idle source setting honored
(default now Remote session activity), cover scope reduced to a coherent
two-mode model with legacy migration (per-display toggle was inverted in
onlyMarked and dead in all), curtain test no longer schedules a teardown over a
live session, specific-display password box placement gets a real picker,
refuse-to-arm enforced, activation notification posts a real banner, menu
password gate bypassed when the event tap is dead, shared single-decoder aerial
player with stale-task guard and async playability check, password buffer zeroed
on successful unlock and Esc, XPC interruption/invalidation handlers, modern
Accessibility settings URL, launchPath modernized, codesign failures now abort
release.sh, monotonic CFBundleVersion, install.sh temp cleanup, dead
armDisarmHotkey setting removed.

Refactor: Curtain.swift and PreferencesWindow.swift split into focused files
(largest now 479 lines). Wiki, README, and contributing docs updated to match.
Build clean at 0 warnings, 32/32 tests pass.
2026-06-09 20:36:30 -04:00

6.1 KiB

Troubleshooting

Emergency unlock (always works)

If you are stuck behind the curtain for any reason, press Control + Option + Command + U at the desk. This force-deactivates the curtain immediately. It is a Carbon hotkey, so it works even when Accessibility has not been granted. This is your guaranteed escape.

The curtain will not appear / Curtain refuses to cover

Accessibility permission is not granted. Curtain will not put up a cover it cannot unlock, so when the permission is missing it refuses to show the cover and posts a notification instead.

  1. Open System Settings → Privacy & Security → Accessibility.
  2. Find Curtain in the list and make sure it is enabled.
  3. If it is not in the list, launch Curtain from /Applications once to force a registration attempt.
  4. After granting, relaunch Curtain so the permission takes effect.

macOS blocks the app on first launch (Gatekeeper)

Curtain is ad-hoc signed and not yet notarized, so a clean download is quarantined. Clear the flag once, then open normally:

xattr -dr com.apple.quarantine /Applications/Curtain.app

This is a current limitation that goes away once a notarized build ships.

The app will not launch

Confirm whether the process is running:

pgrep -fl Curtain

If nothing prints, launch Curtain from /Applications. If it launches and immediately exits, open Console.app and filter for Curtain to see the crash or exit reason. The Gatekeeper quarantine flag above is the most common cause of a silent first-launch failure.

The curtain does not arm when Screen Sharing connects

First confirm Accessibility is granted (see above) and that Curtain is armed. The menu-bar icon is the curtains glyph; it tints red while the curtain is active. Open the menu and confirm Armed has a checkmark. If it does not, choose Armed to re-enable.

If the menu-bar icon is missing entirely, Curtain is not running. Launch it from /Applications and check pgrep -fl Curtain.

Use the probe script to verify detection

Run the included probe while a Screen Sharing session is active:

swift Scripts/probe-detection.swift

It prints the live values of all three detection signals:

Line Meaning
captured=true CGSSessionScreenIsCaptured is true. Curtain should activate.
tcp_estab=true An ESTABLISHED inbound TCP connection exists on port 5900.
udp_peered=true A peered UDP socket exists on ports 5900-5902.
tcp_listen=true A LISTEN socket on 5900 exists. This does NOT activate the curtain.
udp=true A UDP socket on 5900-5902 exists but is not peered. This does NOT activate.
DICT … A key in the CGSession dictionary that appeared or changed since last poll.

If captured=false and both tcp_estab and udp_peered are also false while a session is clearly running, there is a detection gap. Note the DICT output and file an issue with the output attached.

Re-grant Accessibility after every rebuild of an ad-hoc build

Rebuilding the app from source produces a new binary with a new code signature. macOS TCC ties the Accessibility grant to the code signature, so the old grant no longer applies. After rebuilding:

  1. Open System Settings → Privacy & Security → Accessibility.
  2. Remove the old Curtain entry if present, then re-add it.
  3. Relaunch Curtain.

The curtain activates when no one is connected

The three activation signals are: CGSSessionScreenIsCaptured, an ESTABLISHED TCP connection on port 5900, and a peered UDP socket on ports 5900-5902. A lingering Screen Sharing process, an idle :5900 LISTEN socket, or a wildcard UDP socket does not activate the curtain. If it arms with no session, run the probe (swift Scripts/probe-detection.swift) to see which signal is true, and check whether something on the machine is capturing the console screen.

This is expected if the monitor has not been marked.

Open Settings → Displays and mark the monitor as DisplayLink. Each display is identified by a stable UUID, so the marking persists. On a DisplayLink monitor the curtain also shows in the remote view. That is by design. See How It Works — DisplayLink for the technical reason.

Multiple displays: the remote view only shows one screen

The Apple Screen Sharing app shows one host display at a time. Switch between them from its View menu. This is normal Screen Sharing behavior, not a Curtain bug.

The session keeps dropping

Curtain debounces disconnect detection: it waits for three consecutive missed polls (about 6 seconds) before declaring the session ended. On a stable network this never trips by accident. If sessions drop on a reliable connection, check whether another process is interfering with port 5900 or restarting Screen Sharing.

The remote operator's mouse and keyboard stop working

This should not happen when the event tap is working correctly. The tap only blocks events with source state ID 1 (physical hardware). Remote events have a different source ID and pass through untouched.

The Mac does not lock when the session ends

The lock uses SACLockScreenImmediate from login.framework. Confirm the lock screen is enabled: System Settings → Lock Screen → Require password. If the OS lock screen is disabled, there is nothing for the lock call to fall back to.

I forgot my password

Settings live in UserDefaults. Reset everything to defaults:

defaults delete io.acamarata.curtain

Relaunch Curtain. It starts fresh and the default password curtain applies again. Set a new one from the settings window.

The disconnect helper is not working

The optional disconnect feature installs a privileged helper and needs one admin approval. On a notarized or Developer-ID build it registers a daemon through SMAppService.daemon, approved once in System Settings. On a local ad-hoc or dev build it falls back to a small current-user-scoped privileged helper installed with one admin prompt. If disconnect actions do nothing, confirm you approved the helper, and re-run Settings → Disconnect → Enable disconnect-remote-on-end to reinstall it.