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.
This commit is contained in:
Aric Camarata 2026-06-09 20:36:30 -04:00
parent 30d5a77ffa
commit 8c19e960d2
53 changed files with 5496 additions and 904 deletions

40
.github/CONTRIBUTING.md vendored Normal file
View file

@ -0,0 +1,40 @@
# Contributing to Curtain
Thanks for taking the time to help. Curtain is a small, focused macOS menu-bar app, and contributions that keep it that way are welcome.
## Building
Curtain is a single Swift Package Manager package. You need a recent Xcode toolchain on macOS 13 or later.
```bash
swift build -c release
```
To build the app bundle, generate the icon, and set up the login item and the optional disconnect helper, run the release script:
```bash
Scripts/release.sh
```
The settings window has a "Test (10s)" button for a quick visual check. For a real test, connect to the machine over Screen Sharing and confirm that remote control works, desk input is dead, a desk keypress raises the password box, the password ends the session, and idle or disconnect locks the Mac and sleeps the displays.
## Code style
- One responsibility per file. Match the existing module layout in `Sources/`.
- Keep the critical invariants intact. The detection path, the physical-vs-remote input split, and the cover window sharing types are load-bearing. Read the wiki before changing them.
- Comment the why, not the what.
## Pull requests
- Branch from `main` and keep each PR scoped to one change.
- Describe what you changed and why, and how you tested it.
- Make sure the project builds clean before you open the PR.
- Be patient and civil in review. Small, well-explained PRs get merged faster.
## No AI attribution
Do not add AI co-author tags, "generated by" notes, or any reference to AI tooling in commits, code, comments, or docs. Commit history and source are attributed to human contributors only.
## License
Curtain is MIT licensed. By contributing, you agree that your contributions are licensed under the same terms.

View file

@ -1,153 +1,207 @@
# Architecture
## File and module overview
## Module overview
| File | Type | Responsibility |
|---|---|---|
| `main.swift` | Entry point | Creates `NSApplication`, sets `AppDelegate` as delegate, runs the run loop. |
| `AppDelegate.swift` | Coordinator | Wires the three subsystems together, owns the menu-bar item, handles lifecycle events (connect, disconnect, idle, password unlock). |
| `SessionMonitor.swift` | Detector | Polls for an active Screen Sharing connection and tracks idle time. Fires `onConnect`, `onDisconnect`, `onIdleTimeout` callbacks. |
| `InputFilter.swift` | Input blocker | Installs and manages a `CGEventTap`. Blocks physical hardware events; passes remote events. Routes physical key-downs to the password box. |
| `Curtain.swift` | Cover + password UI | Creates and manages the full-screen cover windows and the password box overlay. |
| `System.swift` | System calls | Thin wrappers over screen lock, display sleep, display-sleep prevention, session termination, and display serial lookup. |
| `Config.swift` | Persistent settings | Reads and writes `~/Library/Application Support/Curtain/config.json`. Stores enabled state, salted password hash, DisplayLink serials, idle timeout. |
All app source lives in `Sources/Curtain/`. A shared target `Sources/CurtainShared/` holds the XPC contract, and a second executable `Sources/CurtainHelper/` is the privileged disconnect daemon. Each file has a single responsibility.
### `Sources/Curtain/`
| File | Role |
|---|---|
| `main.swift` | Entry point. Sets `NSApplication` activation policy to `.accessory` (no Dock icon). Contains a hidden `--render-icon <dir>` build helper used by the release script to generate the app icon without shipping image assets. |
| `AppDelegate.swift` | Wires all pieces together. No logic of its own: it connects callbacks between the coordinator, the menu bar, and the settings window. Registers defaults, reconciles the login item state, and shows onboarding on first launch. |
| `SessionCoordinator.swift` | The brain. An explicit state machine (idle, active, unlocking) that owns the curtain, the input filter, and the monitor. Responds to connect/idle/end/password events and drives `ActionRunner`. Exposes `activateNow`, `deactivateNow`, and `testCurtain` for manual control. |
| `SessionMonitor.swift` | Combines the capture probe, process presence, and netstat into one connect/disconnect signal. Reads idle time. Fires `onConnect`, `onIdleTimeout`, and `onDisconnect`. Disconnect is debounced over 3 consecutive misses of the combined signal. |
| `CaptureProbe.swift` | The detection primitive. Reads `CGSessionScreenIsCaptured` from `CGSessionCopyCurrentDictionary()`. Transport-independent: true whether the remote streams over TCP or UDP. Also reports whether the captured session is the local console (curtain applies) or a different user's virtual session (Curtain stands down). |
| `InputFilter.swift` | Installs a `CGEventTap` covering keyboard, mouse, scroll, and `.systemDefined` (media and brightness keys) events. Blocks physical events (`sourceStateID == 1`), passes remote events. Routes physical `keyDown` events to `onPhysicalKey`. Re-enables the tap on timeout/disable, and retries automatically once Accessibility is granted. A convenience filter, not a security boundary. |
| `CurtainController.swift` | Manages the set of per-display cover windows (replaces the cover-management code that was in `Curtain.swift`). Creates, reconciles, and destroys `CoverWindow` instances keyed by display UUID. Handles hotplug via `didChangeScreenParametersNotification`. |
| `CoverContentView.swift` | SwiftUI view rendered inside each cover window. Draws the chosen cover style: solid, message, blur, logo, or aerial video. One shared `AVPlayer` decoder services all displays when the aerial style is active. |
| `PasswordBox.swift` | The on-curtain password entry UI. Appears on a chosen display when a physical key is pressed. Manages timeout, attempt backoff, and routing the result to the coordinator. |
| `Actions.swift` | `ActionSet` struct (activateCurtain, disconnect, lock, screenOff, deactivateCurtain) and `ActionRunner` that executes a set in a defined order: disconnect first, deactivate, lock, then sleep displays last. |
| `System.swift` | Thin wrappers: `lockScreen` (SACLockScreenImmediate via dlopen/dlsym + fallback), `sleepDisplays`, `preventDisplaySleep`/`allowDisplaySleep` (IOPMAssertion), display UUID/serial helpers, `isDisplayLink(_:)`. |
| `DisconnectClient.swift` | Client side of the disconnect XPC. Talks to `CurtainHelper` over the shared `DisconnectXPC` protocol when the optional disconnect daemon is installed. |
| `Settings.swift` | All preferences backed by `UserDefaults`. Defines `Key` constants shared with `@AppStorage`. Typed accessors for the coordinator. Password stored as a salted PBKDF2-HMAC-SHA256 hash. Default password `curtain` when no hash is set. |
| `PrefGeneralTab.swift` | SwiftUI view for the General settings tab. |
| `PrefAppearanceTab.swift` | SwiftUI view for the Appearance tab. |
| `PrefIdleEndTab.swift` | SwiftUI view for the On Session Idle / On Session End tabs. |
| `PrefSecurityTab.swift` | SwiftUI view for the Security tab. |
| `PrefDisconnectTab.swift` | SwiftUI view for the Disconnect tab. |
| `PrefDisplaysTab.swift` | SwiftUI view for the Displays tab. |
| `PrefAdvancedTab.swift` | SwiftUI view for the Advanced tab. |
| `PreferencesWindow.swift` | `NSWindow` host for the per-tab SwiftUI settings views. Binds to `@AppStorage` keys so changes apply live. |
| `OnboardingWindow.swift` | First-launch walkthrough: explains the Accessibility grant, the optional disconnect daemon, and a quick visual test. |
| `MenuBarController.swift` | Optional `NSStatusItem` showing the curtains glyph. Menu: Open Settings, Activate Now, Deactivate, Test (10s), Quit. Icon tints red when active, template when idle. |
| `CurtainIcon.swift` | Draws the curtains logo in code using `NSBezierPath`. Produces a menu-bar template image and a full-color `.iconset` of PNGs. Renders into an offscreen `NSBitmapImageRep` (`NSImage(flipped:)` hangs headless). |
| `LoginItem.swift` | Thin wrapper over `SMAppService.mainApp`. Registers or unregisters the app as a login item. Only works for an installed bundle. |
### `Sources/CurtainShared/`
| File | Role |
|---|---|
| `DisconnectXPC.swift` | The `@objc` protocol shared between the app and the helper. Defines the single privileged operation: end the active Screen Sharing session. |
### `Sources/CurtainHelper/`
The privileged disconnect daemon. Registered optionally via `SMAppService.daemon`, it vends the `DisconnectXPC` service over XPC and performs the session termination as root. No sudoers rule, no shell helper on disk.
## Key macOS APIs
### Session detection
### Session detection: three signals
`SessionMonitor` runs a two-second timer and shells out to check for an established VNC connection:
Three independent signals each independently activate the curtain. The first that fires is enough.
```
netstat -an | grep '.5900 ' | grep ESTABLISHED
**Signal 1 — `CGSSessionScreenIsCaptured` (primary)**
```swift
let dict = CGSessionCopyCurrentDictionary() as? [String: Any]
let captured = dict?["CGSSessionScreenIsCaptured"] as? Bool ?? false
let onConsole = dict?["kCGSSessionOnConsoleKey"] as? Bool ?? false
```
`lsof` does not work here — Screen Sharing sockets are owned by the `_rmd` system account and are invisible to user-context `lsof`.
Transport-independent. Reports true for classic Screen Sharing (TCP) and for the macOS 14+ high-performance mode (UDP, Apple Silicon). Combined with the on-console key, it also distinguishes a local-console capture from a different-user virtual session.
Idle time comes from IOKit:
**Signal 2 — ESTABLISHED TCP on port 5900**
```
ioreg -c IOHIDSystem => HIDIdleTime (nanoseconds)
A genuinely established inbound TCP connection on port 5900 catches a classic session in the window before the capture flag settles. A `:5900` LISTEN socket (idle machine waiting for connections) does not activate.
**Signal 3 — Peered UDP on ports 5900-5902**
macOS 14+ High-Performance Screen Sharing on Apple Silicon uses UDP. The corroborating signal is a bound, peered UDP socket on ports 5900-5902. A wildcard or LISTEN-state UDP socket does not activate.
Probe helpers (`/usr/sbin/netstat`, not `/usr/bin/netstat` — the latter does not exist on macOS) log launch failures loudly so a misconfigured path is immediately visible in the system log rather than silently returning no results. Process presence (`ScreenSharingAgent`, `ScreenSharingSubscriber`, `screensharingd`) is checked but never activates the curtain on its own.
The combined signal is debounced over 3 consecutive misses (~6 seconds) before declaring the session gone.
Idle time comes from the event system. The source is configurable in Settings (see [Settings — On Session Idle](Settings#on-session-idle)):
```swift
// "Remote session activity" (default)
CGEventSource.secondsSinceLastEventType(.combinedSessionState, eventType: .null)
// "This Mac's physical input only"
CGEventSource.secondsSinceLastEventType(.hidSystemState, eventType: .null)
```
### Physical vs remote input (`CGEventTap`)
`InputFilter` creates the tap with:
### Physical vs remote input: `CGEventTap` + `eventSourceStateID`
```swift
CGEvent.tapCreate(
tap: .cgSessionEventTap,
place: .headInsertEventTap,
options: .defaultTap, // active tap — can block events
eventsOfInterest: <mask covering keyDown/keyUp/flagsChanged + all mouse + scroll>,
options: .defaultTap, // active — can block by returning nil
eventsOfInterest: <keyDown/keyUp/flagsChanged + all mouse + scroll + systemDefined>,
callback: ...,
userInfo: ...
)
```
Inside the callback, each event is classified by source:
Inside the callback:
```swift
let physical = (event.getIntegerValueField(.eventSourceStateID) == 1)
if physical { return nil } // block desk input
return Unmanaged.passUnretained(event) // pass remote input
```
- Source state ID `1` = physical hardware (`kCGEventSourceStateHIDSystemState`). Block it (return `nil`).
- Any other ID = Screen Sharing injected. Pass it (return `Unmanaged.passUnretained(event)`).
Physical source ID `1` is `kCGEventSourceStateHIDSystemState`. Remote events carry a large, per-session ID that is never `1`. The mask includes `.systemDefined` so media and brightness keys from the desk are masked too. The tap re-enables itself on `.tapDisabledByTimeout` and `.tapDisabledByUserInput`, and retries creation once Accessibility is granted. This is a convenience filter to keep desk input out of your session, not a hardened security boundary.
The tap handles `.tapDisabledByTimeout` and `.tapDisabledByUserInput` by re-enabling itself.
### Cover windows: per-display, keyed by UUID
### Cover window (`NSWindow.sharingType`)
Windows are keyed by `CGDisplayCreateUUIDFromDisplayID`, because identical monitors report serial `0` and cannot be told apart by serial alone. The set rebuilds on `NSApplication.didChangeScreenParametersNotification`, so hotplug, resolution change, and display rearrangement mid-session all keep every panel covered.
The curtain window on each display is borderless, opaque, level `CGWindowLevelForKey(.maximumWindow)`, with:
- `sharingType = .none`: excluded from screen capture. Opaque at the desk, invisible to the remote. Used for native displays.
- `sharingType = .readOnly`: included in capture. Visible to the remote. Required for DisplayLink displays, which only exist through screen capture.
- `ignoresMouseEvents = true` — never intercepts remote cursor.
- `canBecomeKey = false` — never steals keyboard focus.
Cover appearance is configurable: solid color, a message, a blur, or the logo, with an optional clock. When the aerial video style is selected, one shared `AVPlayer` decoder services all cover windows rather than one decoder per display. Windows are `ignoresMouseEvents = true` and `canBecomeKey = false`, so they never interfere with the remote cursor or steal focus. A `ScreenCaptureKit` self-test verifies that `.none` covers are excluded from capture.
For native displays: `sharingType = .none`. The window is excluded from screen capture, so the remote operator sees the real desktop behind it.
Cover scope is a two-mode setting: **All displays** (default, fail-safe) or **Per-display Cover toggles** (each display's Cover switch in Settings determines whether it is covered). An unknown or newly attached display is covered under both modes.
For DisplayLink displays: `sharingType = .readOnly`. The window is visible to screen capture, which is required because DisplayLink monitors only exist through screen capture.
### Screen lock: `SACLockScreenImmediate`
### Screen lock (`SACLockScreenImmediate`)
`CGSession -suspend` was removed in recent macOS. `osascript` for Ctrl+Cmd+Q requires Accessibility and is unreliable from a LaunchAgent context. Curtain uses a private symbol in `login.framework`:
`CGSession -suspend` was removed from recent macOS. `osascript` Ctrl+Cmd+Q needs Accessibility and a GUI context. `SACLockScreenImmediate` is a private symbol in `login.framework`:
```swift
dlopen("/System/Library/PrivateFrameworks/login.framework/Versions/Current/login", RTLD_LAZY)
dlsym(handle, "SACLockScreenImmediate")
```
This locks immediately with no additional permission and no GUI context requirement.
Locks immediately, no extra permission, works from a background agent. Falls back to a scripted lock if the symbol is unavailable.
### Display sleep prevention (`IOPMAssertion`)
While a session is active, Curtain holds an IOKit power assertion to keep the displays on:
### Display-sleep prevention: `IOPMAssertion`
```swift
IOPMAssertionCreateWithName(kIOPMAssertionTypeNoDisplaySleep, ..., "Curtain active", &assertionID)
IOPMAssertionCreateWithName(
kIOPMAssertionTypeNoDisplaySleep as CFString,
IOPMAssertionLevel(kIOPMAssertionLevelOn),
"Curtain active" as CFString,
&assertionID
)
```
The assertion is released when the session ends. Using an in-process assertion avoids the PID-tracking issues that come with external `caffeinate` processes.
Held for the duration of a session. Released explicitly on deactivate. In-process, so it goes away if the process exits. No orphaned PIDs. After locking, displays are slept via the same path that `pmset displaysleepnow` would trigger.
### Session termination (root helper)
The Screen Sharing connection is owned by `_rmd`/root. A user process cannot kill it. `install.sh` places `/usr/local/bin/curtain-endsession` and adds a NOPASSWD sudoers rule:
```
/etc/sudoers.d/curtain-endsession
```
The app calls:
### Session termination: `SMAppService.daemon` + XPC
```swift
Process() { launchPath = "/usr/bin/sudo"; arguments = ["-n", "/usr/local/bin/curtain-endsession"] }
SMAppService.daemon(plistName: "com.acamarata.curtain.helper.plist").register()
```
macOS respawns the Screen Sharing listener automatically after the helper kills the session processes.
The optional disconnect daemon (`CurtainHelper`) vends the `DisconnectXPC` service. The app calls it through `DisconnectClient` to end the active session as root. No sudoers rule and no shell helper on disk. If the daemon is not installed, disconnect actions are simply unavailable.
## Data flow: connect to end
### Login item: `SMAppService.mainApp`
```swift
SMAppService.mainApp.register() // open at login
SMAppService.mainApp.unregister() // remove from login items
```
Modern API, macOS 13+. Requires an installed app bundle. No LaunchAgent plist.
### Settings: `UserDefaults` + `@AppStorage`
`Settings.swift` defines `Key` constants as static strings. The coordinator reads preferences via typed accessors (`Settings.onIdle`, `Settings.idleMinutes`, etc.). The SwiftUI view binds to the same keys via `@AppStorage(Settings.Key.xxx)`. Changes in the view are immediately visible to the coordinator.
### Icon rendering: offscreen `NSBitmapImageRep`
The app icon is drawn in code and exported as a PNG at each required size. `NSImage(flipped:)` hangs when called from a headless process (the `--render-icon` build step). Instead, `CurtainIcon` renders into an `NSBitmapImageRep` directly, which works in any context.
## Data flow: connect through end
```
1. SessionMonitor detects ESTABLISHED on :5900
1. CaptureProbe: CGSSessionScreenIsCaptured == true, on console
(corroborated by ScreenSharingAgent/Subscriber/screensharingd + widened netstat)
|
v
2. AppDelegate.sessionStarted()
- CurtainController.show() => black cover windows appear on all displays
- System.preventDisplaySleep() => IOPMAssertion held
- InputFilter.start() => CGEventTap installed
2. SessionCoordinator: idle -> active
ActionRunner.activateCover():
- CurtainController.show() per-display covers, keyed by UUID
- System.preventDisplaySleep() IOPMAssertion held
- InputFilter.start() CGEventTap installed (retries on grant)
|
v
3. Remote operator works normally. Physical input is dropped at the tap.
Physical key-downs forwarded to PasswordBox via onPhysicalKey callback.
3. Session active.
Physical input (incl. media/brightness keys) blocked at tap. Remote input passes.
Physical keyDown -> InputFilter.onPhysicalKey -> CurtainController.physicalKey -> PasswordBox (on chosen display)
|
+-- Password correct
| => InputFilter.stop() + CurtainController.hide()
| => System.allowDisplaySleep()
| => Alert: "Disconnect remote?" => System.endScreenShareSession()
+-- Correct password (coordinator: active -> unlocking)
| ActionRunner.deactivateCover()
| - InputFilter.stop()
| - CurtainController.hide()
| - System.allowDisplaySleep()
| Optional disconnect -> DisconnectClient -> CurtainHelper (XPC, root)
|
+-- Idle timeout fires (SessionMonitor)
| => System.endScreenShareSession()
| => sessionEnded(lock: true)
+-- Idle timeout (SessionMonitor, CGEventSource idle)
| ActionRunner.run(Settings.onIdle):
| DisconnectClient.disconnect() (if daemon installed)
| CurtainController.hide() + InputFilter.stop()
| System.lockScreen()
| System.sleepDisplays() (after 1s delay)
|
+-- Remote disconnects (SessionMonitor: 3 consecutive netstat misses)
=> sessionEnded(lock: true)
|
v
4. AppDelegate.sessionEnded(lock: true)
- InputFilter.stop() => tap removed
- CurtainController.hide() => cover windows removed
- System.allowDisplaySleep() => assertion released
- System.lockScreen() => SACLockScreenImmediate called
- System.sleepDisplays() => pmset displaysleepnow (after 1s delay)
+-- Disconnect (3 consecutive misses of the combined signal)
ActionRunner.run(Settings.onEnd):
CurtainController.hide() + InputFilter.stop()
System.lockScreen()
System.sleepDisplays() (after 1s delay)
```
## Configuration storage
`~/Library/Application Support/Curtain/config.json`:
| Field | Type | Default | Notes |
|---|---|---|---|
| `enabled` | bool | `true` | When false, no curtain shows on connect. |
| `passwordHash` | string | `""` | Salted SHA-256. Empty means use default password `curtain`. |
| `salt` | string | random | Per-install 16-byte hex salt. |
| `displayLinkSerials` | `[UInt32]` | `[]` | Serials of DisplayLink displays. |
| `idleMinutes` | int | `30` | Minutes of HID idle before forced disconnect + lock. |
Actions within each `ActionSet` run in a fixed order: disconnect first (so the operator is gone before the screen changes), then deactivate the curtain, then lock, then sleep the displays last so the lock is in place before the panels go dark.

49
.github/wiki/Home.md vendored
View file

@ -1,35 +1,42 @@
# Curtain
Curtain is a privacy layer for macOS Screen Sharing. When you connect remotely to your Mac, it covers the physical displays so no one at the desk can see what you're doing, and it makes the desk keyboard and mouse inert. When the session ends or goes idle, it locks the Mac and sleeps the displays.
A privacy curtain for macOS Screen Sharing. When you remote into your Mac, Curtain hides the screen from anyone sitting at it and makes the local keyboard and mouse do nothing to your apps, while you keep full control from your laptop. When the session goes idle or ends, it can lock the Mac and sleep the displays.
It lives in the menu bar. It does one job.
macOS does Screen Sharing well. Curtain is the missing privacy layer around it: a lightweight menu-bar agent with a SwiftUI settings window, in the spirit of Caffeine.
## Behavior
## Behavior at a glance
| Event | What happens |
| Event | Default (all configurable) |
|---|---|
| **Screen Sharing connects** | Every physical display goes black. Desk keyboard and mouse are blocked from reaching apps. Remote keyboard and mouse work normally. |
| **Someone presses a key at the desk** | A password box appears over the curtain. |
| **Correct password entered at the desk** | Curtain drops, remote session ends (you're asked to confirm). |
| **Session idle for ~30 minutes** | Remote session is disconnected, Mac locks, displays sleep. |
| **Remote operator disconnects** | Mac locks, displays sleep. |
| Remote session connects | Cover every physical display. Block desk keyboard and mouse from reaching apps. Keep displays awake. Remote input works normally. |
| Key pressed at the desk | A password box appears on the curtain. Correct password reveals the desktop and offers to disconnect the remote. |
| Session idle (default 30 min, tracks remote activity by default) | Disconnect remote, lock Mac, turn off displays, deactivate curtain. |
| Remote session disconnects | Lock Mac, turn off displays, deactivate curtain. |
## Requirements
## The key idea
- macOS 13 (Ventura) or later. Built and used on macOS 26 / Apple Silicon.
- Screen Sharing enabled: System Settings > General > Sharing > Screen Sharing.
- Accessibility permission granted to Curtain (required for desk input blocking).
Your laptop and the desk share one login session (standard Screen Sharing shares the console). A window that blocks input would block your remote too. Curtain detects sessions with three independent signals: a transport-independent macOS capture flag, an established TCP connection on port 5900, and a peered UDP socket on ports 5900-5902. It then filters input by **event source**: macOS tags real hardware events differently from injected remote events. Curtain blocks events with source ID `1` (physical hardware) and passes everything else. No virtual display, no second account.
**Emergency unlock:** press **Control + Option + Command + U** at the desk to force-deactivate at any time. It works even without Accessibility granted.
## Pages
- [Installation](Installation) — clone, run the install script, grant Accessibility, set a password.
- [How It Works](How-It-Works) — plain-language explanation of the lifecycle and the physical-vs-remote input trick.
- [Architecture](Architecture) — module breakdown, key APIs, and data flow.
- [Lessons Learned](Lessons-Learned) — what was discovered building this, including things that did not work.
- [Troubleshooting](Troubleshooting) — common issues and how to fix them.
| Page | What it covers |
|---|---|
| [Installation](Installation) | Clone, `install.sh`, Accessibility grant, password setup, DisplayLink, uninstall |
| [Settings](Settings) | Every option in the settings window explained |
| [How It Works](How-It-Works) | Lifecycle walkthrough, the physical-vs-remote trick, DisplayLink caveat |
| [Security](Security) | Threat model, input-filter limits, password storage, the optional helper, distribution trust |
| [Architecture](Architecture) | 12-module breakdown, macOS APIs, data flow |
| [Lessons Learned](Lessons-Learned) | What was discovered building this, including what did not work |
| [Troubleshooting](Troubleshooting) | Common problems and fixes |
## A note on DisplayLink monitors
## Requirements
DisplayLink displays exist only through screen capture. Because of how the curtain window hides itself from screen capture, those monitors cannot be hidden invisibly. On a DisplayLink monitor the curtain shows in your remote view too. Native (directly attached) displays are hidden from the desk while staying clear on your remote screen.
- macOS 13 (Ventura) or later. Built and tested on macOS 26 / Apple Silicon.
- Screen Sharing enabled: System Settings → General → Sharing → Screen Sharing.
- Accessibility permission for Curtain, granted once after install.
See [How It Works](How-It-Works#displaylink) for details.
## License
MIT © Aric Camarata

View file

@ -1,81 +1,140 @@
# How It Works
## The core challenge
## The hard constraint
When you use macOS Screen Sharing, your laptop and the physical Mac share one login session. There is no separate "remote session" at the OS level — it is the same desktop, the same running apps, the same input system.
When you use macOS Screen Sharing, your laptop and the physical Mac share one login session. There is no separate "remote session" at the OS level. It is the same desktop, the same running apps, the same input system.
That creates a hard constraint: you cannot simply put up a window that blocks input, because that would block both the desk keyboard and your remote keyboard at the same time. You also cannot put up a click-through window (one that ignores mouse clicks and key presses), because then desk input still reaches your apps.
That creates a real problem: you cannot put up a window that blocks input from the desk, because that would also block your remote input. And you cannot put up a click-through window (one that ignores the desk), because then desk keyboard and mouse still reach your apps. Several approaches hit this wall. See [Lessons Learned](Lessons-Learned) for the full list.
Curtain solves this by working at a lower level: it inspects every input event before it reaches any app and decides, for each event individually, whether it came from physical hardware or from the Screen Sharing connection.
The solution is to work below the window system. Curtain inspects every input event before any app sees it, and classifies each one by where it came from.
## Physical vs remote input
macOS tags every input event with a source identifier. Events from real, physical hardware (keyboard, mouse, trackpad) consistently carry source ID `1`. Events injected by Screen Sharing carry a large, arbitrary number that is different from `1` and changes with each new connection.
macOS tags every input event with a source state ID. Events from physical hardware (keyboard, trackpad, mouse) consistently report source state ID `1` (`kCGEventSourceStateHIDSystemState`). Events injected by Screen Sharing carry a large, arbitrary number that is different from `1` and changes with each connection.
Curtain installs a system-level event tap (a `CGEventTap`) that runs before any app sees the event. The rule is simple: if the source ID is `1`, the event came from the desk — block it. If the source ID is anything else, the event came from the remote operator — let it through.
Curtain installs a `CGEventTap` at the session level, in head-insert active mode, so it runs before any app sees the event. The rule: if source ID is `1`, the event came from the desk, so return `nil` to block it. Anything else, pass it through. The tap also masks `.systemDefined` events, so the desk cannot reach media or brightness keys.
This is what lets you type and click freely on your laptop while the desk keyboard and mouse do nothing.
This is what lets you type and click freely from your laptop while the desk keyboard and mouse do nothing. No virtual display, no second user account. It is a convenience filter that keeps desk input out of your session, not a hardened security boundary, and it depends on the Accessibility grant.
Physical key presses are not completely ignored. When a key comes in from the desk, Curtain reads it and feeds it into the password box, so the person at the desk can type a password to reclaim the machine.
Because the cover is useless without the input block, Curtain refuses to raise the cover at all when Accessibility has not been granted, notifying you instead. And independent of all of this, a Carbon hotkey, **Control + Option + Command + U**, force-deactivates the curtain. Carbon hotkeys do not need Accessibility, so this escape works in every state.
Physical key presses are not simply discarded. When a physical `keyDown` arrives, Curtain reads it and feeds it to the on-curtain password box so the person at the desk can type a password to reclaim the machine.
## The lifecycle
```
Screen Sharing connects
Screen Sharing connects (CGSSessionScreenIsCaptured == true, local console)
|
+-- Curtain covers all physical displays (black screen, "Remote Session Active")
+-- Input tap activates (physical input blocked, remote input passes through)
+-- Display sleep prevention starts (displays stay on for the remote view)
+-- Cover windows appear on every physical display
| Configurable: solid, message, blur, lock logo, Curtain logo, or aerial video,
| with an optional clock (default: lock logo)
+-- Input tap activates (physical input blocked, remote input passes)
+-- IOKit display-sleep assertion held (displays stay on for the remote view)
|
While connected:
|
+-- Someone at the desk presses a key
| -> Password box appears
| -> Correct password: remote session ends, curtain drops
| -> Wrong password: box clears, try again
| -> Esc or no key for 6 seconds: box hides
+-- Reveal trigger at the desk (any key, or a user-defined combo)
| Password box appears
| Correct password: curtain drops; remote stays connected or disconnects per On Curtain Unlock
| Wrong password: box clears, try again
| Esc, or no key for the box timeout: box hides
|
+-- No HID input for 30 minutes (idle timeout)
| -> Remote session is terminated
| -> Mac locks, displays sleep
+-- Emergency hotkey (Control + Option + Command + U)
| Force-deactivate the curtain, even without Accessibility
|
+-- Remote operator disconnects
-> Mac locks, displays sleep
+-- No physical input for N minutes (idle timeout, default 30 min)
| Disconnect remote (via the helper daemon, if installed)
| Lock Mac (SACLockScreenImmediate)
| Sleep displays
| Deactivate curtain
|
+-- Remote disconnects (3 consecutive misses of the capture signal, ~6 seconds)
Lock Mac
Sleep displays
Deactivate curtain
```
## Ending the remote session
The Screen Sharing connection process (`screensharingd`) is owned by a system account, not your user. Curtain cannot kill it directly. During installation, a small helper binary is placed at `/usr/local/bin/curtain-endsession` with a sudoers rule that allows Curtain to run it without a password. The helper kills the session processes. macOS then respawns the listener automatically, so Screen Sharing remains available for the next connection.
## How the cover works
The cover is a full-screen, borderless window placed at the highest window level, covering each physical display. It has two properties that matter:
- `ignoresMouseEvents = true` — the window never intercepts the remote cursor.
- `canBecomeKey = false` — the window never steals keyboard focus from the remote session.
Input blocking is done entirely by the event tap, not the window itself.
## What the desk sees
The cover shows a black screen with a lock icon and the text "Remote Session Active". When a key is pressed, a small password panel appears centered on the primary display.
## What the remote sees
On native (directly attached) displays, the cover window is marked with `sharingType = .none`. That means it is excluded from screen capture entirely. Your remote view shows your actual desktop, not the black cover.
## DisplayLink
DisplayLink monitors work differently. A DisplayLink display only exists through screen capture — the DisplayLink driver captures the framebuffer and sends it over USB. A window marked `sharingType = .none` is invisible to screen capture, which means it is also invisible to the DisplayLink driver, and the cover does not appear on a DisplayLink monitor at all.
For those monitors, Curtain uses `sharingType = .readOnly` instead. The cover is visible to screen capture and therefore shows on the DisplayLink monitor. The trade-off: the cover also shows in your remote view for those displays.
This is why the install step includes **Mark Current Externals as DisplayLink** — Curtain needs to know which displays require the different cover mode.
## Idle detection
Curtain reads the system's HID idle time from IOKit (`ioreg -c IOHIDSystem`, `HIDIdleTime` field). This is the time since the last physical input event, in nanoseconds. When it exceeds the configured threshold (default 30 minutes), the idle timeout fires.
All the actions at idle and on disconnect are individually toggleable in settings.
## Session detection
Curtain polls `netstat` every two seconds and looks for an ESTABLISHED connection on port 5900 (the VNC port used by Screen Sharing). `lsof` cannot be used here because the Screen Sharing sockets are owned by a system account and are invisible to a user-context `lsof`. The disconnect is debounced: three consecutive misses (~6 seconds) are required before a disconnect is reported, to avoid false disconnects from transient network blips.
Curtain uses three signals to detect an active session. The signals are evaluated together; any one of them activates the curtain.
**1. `CGSSessionScreenIsCaptured` (primary)**
```swift
let dict = CGSessionCopyCurrentDictionary() as? [String: Any]
let captured = dict?["CGSSessionScreenIsCaptured"] as? Bool ?? false
```
This is the transport-independent primary signal. It is true whenever the local screen is being captured, over any transport: classic TCP or the macOS 14+ high-performance UDP mode on Apple Silicon. The same session dictionary tells Curtain whether the capture is the local console (apply the curtain) or a different user's virtual session (stand down and do nothing).
**2. Established inbound TCP on port 5900**
An `ESTABLISHED` connection on port 5900 catches a classic Screen Sharing session in the brief window before the capture flag settles. The socket must be `ESTABLISHED`; a `:5900` socket in `LISTEN` state (the machine idle and waiting) does not activate the curtain.
**3. Peered UDP on ports 5900-5902**
macOS 14+ High-Performance Screen Sharing on Apple Silicon streams over UDP rather than TCP. The corroborating signal for this path is a bound, peered UDP socket on ports 5900-5902. A LISTEN-state or wildcard UDP socket does not activate.
Lingering `ScreenSharingAgent`, `ScreenSharingSubscriber`, or `screensharingd` processes do **not** activate the curtain on their own. Treating those as a session was an earlier false-activation bug.
Disconnect is debounced: three consecutive misses of all three signals (~6 seconds) are required before declaring the session gone. Without this, a transient blip fires a false disconnect and kills a live session.
**Idle time** comes from the event system, using the source configured in Settings:
```swift
// "Remote session activity" (default) — idle = remote operator stopped interacting
CGEventSource.secondsSinceLastEventType(.combinedSessionState, eventType: .null)
// "This Mac's physical input only" — idle = desk stopped being used
CGEventSource.secondsSinceLastEventType(.hidSystemState, eventType: .null)
```
## The cover window
One borderless window per display, placed at the maximum window level, keyed by the display's UUID. Two properties matter:
- `ignoresMouseEvents = true`, so the window never intercepts the remote cursor.
- `canBecomeKey = false`, so the window never steals keyboard focus from the remote session.
The set of covers rebuilds on `didChangeScreenParametersNotification`. Plug in a monitor, change a resolution, or rearrange displays mid-session, and every panel stays covered. Input blocking is done entirely by the event tap. The window just hides the screen. Its look is configurable: a solid color, a message, a blur, the lock logo, the Curtain logo, or a muted looping system aerial video, with an optional clock. The aerial style falls back to the lock logo when no system aerial `.mov` is available, and the lock logo is the default cover. When the aerial style is active, one shared video decoder feeds every display, rather than a separate decoder per display.
**Cover scope** is a two-mode setting in Settings → Displays:
- **All displays** (default) — every display is covered, no per-display toggle needed. The fail-safe choice.
- **Per-display Cover toggles** — the Cover toggle on each display in Settings decides whether that display is covered.
A display that Curtain does not recognize is covered by default under either mode, so a newly attached monitor never shows the desktop.
## What the desk sees vs what the remote sees
On native (directly attached) displays, the cover window has `sharingType = .none`. This flag excludes the window from screen capture entirely. The remote operator's view shows the real desktop behind the cover. The cover is physically opaque at the desk and invisible to the remote. A `ScreenCaptureKit` self-test verifies that `.none` covers really are excluded from capture before relying on them.
## DisplayLink
DisplayLink monitors work differently. A DisplayLink display has no direct framebuffer connection. The DisplayLink driver captures the framebuffer over screen capture and sends the image over USB. A window with `sharingType = .none` is excluded from screen capture, which means it is also excluded from the DisplayLink output. The cover does not appear on the DisplayLink monitor at all.
For those monitors, Curtain uses `sharingType = .readOnly`. The window is capturable, so it appears on the DisplayLink monitor. The trade-off: it also shows in your remote view for those displays.
Mark your DisplayLink monitors in settings (Displays, then Mark Externals as DisplayLink). Native displays remain invisible to the remote. DisplayLink displays show the cover in both views, which keeps the desk covered.
Identifying DisplayLink displays by USB vendor ID is unreliable because EDID passthrough makes all monitors report the same vendor and model. Identical monitors also report serial `0`, so Curtain keys covers by `CGDisplayCreateUUIDFromDisplayID`, which is stable. The Identify Displays button flashes each monitor's index so you can match them up.
## Ending the remote session
The Screen Sharing connection process is owned by `_rmd`/root. A user process cannot kill it. Curtain ships an optional privileged helper, `CurtainHelper`. On a notarized or Developer-ID build it registers through `SMAppService.daemon`; the app talks to it over XPC using a shared `DisconnectXPC` protocol, and the daemon ends the session processes as root. A local ad-hoc or dev build cannot register an `SMAppService` daemon, so it falls back to a small privileged helper installed with one admin prompt, scoped to the current user. Either way macOS respawns the listener, so Screen Sharing remains available for new connections, and a public notarized build never installs a sudoers rule. If no helper is installed, disconnect actions are simply unavailable and the other actions still run.
## Screen lock
`CGSession -suspend` was removed from macOS. Calling `osascript` to send Ctrl+Cmd+Q requires Accessibility and a GUI context, and is unreliable from a background agent. Curtain uses `SACLockScreenImmediate`, a private symbol in `login.framework`, accessed via `dlopen`/`dlsym`. It locks immediately with no extra permission and works from a background process, with a scripted fallback if the symbol is unavailable.
## Display sleep prevention
While a session is active, Curtain holds an IOKit assertion:
```swift
IOPMAssertionCreateWithName(kIOPMAssertionTypeNoDisplaySleep, ...)
```
This keeps displays on for the remote view. The assertion is released when the session ends. Using an in-process assertion avoids the orphaned-PID problem that comes with external `caffeinate` processes.

View file

@ -2,78 +2,78 @@
## Prerequisites
- macOS 13 or later (Apple Silicon recommended).
- Screen Sharing enabled in System Settings > General > Sharing > Screen Sharing.
- Admin rights on the machine (the install script needs one sudo prompt).
- macOS 13 (Ventura) or later. Apple Silicon recommended.
- Screen Sharing enabled: System Settings → General → Sharing → Screen Sharing.
## Install
1. Download `Curtain-1.0.0.dmg` from the [GitHub Releases page](https://github.com/acamarata/curtain/releases).
2. Open the DMG.
3. Drag `Curtain.app` into the `Applications` folder.
4. Launch Curtain from `/Applications`.
On first launch an onboarding flow walks you through setup: Welcome → grant Accessibility → optional disconnect helper → optional password → finish. When it completes, the curtains icon appears in the menu bar.
## First launch: Gatekeeper
Curtain is currently ad-hoc signed, not yet notarized. On a clean download macOS Gatekeeper blocks the first launch and reports the app is damaged or from an unidentified developer. This is expected for now.
Clear the quarantine flag once, then open the app normally:
```bash
git clone https://github.com/acamarata/curtain.git
cd curtain
./Scripts/install.sh
xattr -dr com.apple.quarantine /Applications/Curtain.app
```
The script does the following in one go:
| What it sets up | Where | Why |
|---|---|---|
| `Curtain.app` bundle | `/Applications/Curtain.app` | Ad-hoc codesigns the bundle so TCC (Accessibility) grants a stable identity. |
| Login LaunchAgent | `~/Library/LaunchAgents/com.acamarata.curtain.plist` | Starts Curtain automatically when you log in. |
| Root helper | `/usr/local/bin/curtain-endsession` | Lets Curtain kill the Screen Sharing connection (owned by root) without a password prompt. |
| Sudoers rule | `/etc/sudoers.d/curtain-endsession` | NOPASSWD for the helper only. Requires one admin prompt during install. |
After the script finishes, Curtain starts automatically and a 👁 icon appears in the menu bar.
Then double-click `Curtain.app`. Right-clicking and choosing Open is no longer enough on recent macOS, so use the command above. Once a notarized build ships, this step goes away and Curtain opens straight from the DMG.
## Grant Accessibility
Curtain needs Accessibility permission to block physical keyboard and mouse input. Without it the curtain still covers the screen, but desk input reaches your apps.
Curtain needs Accessibility permission to block the desk keyboard and mouse. Without it, Curtain refuses to show the cover at all and posts a notification instead. This prevents putting up a screen that cannot be unlocked. The emergency hotkey **Control + Option + Command + U** always works regardless.
1. Open **System Settings > Privacy & Security > Accessibility**.
The onboarding flow deep-links you straight to the right pane. You can also open it yourself:
1. Open **System Settings → Privacy & Security → Accessibility**.
2. Find **Curtain** in the list and turn it on.
3. Relaunch Curtain: `launchctl kickstart -k gui/$(id -u)/com.acamarata.curtain`
3. Relaunch Curtain so the new permission takes effect.
If Curtain does not appear in the Accessibility list, run:
If Curtain does not appear in the Accessibility list, launch it once from `/Applications`, then check again.
```bash
open -a Curtain
```
After every rebuild of a local ad-hoc build, re-grant Accessibility. Rebuilding produces a new code signature and macOS does not carry over the old grant automatically.
Then check again.
## Open at login
Curtain manages login startup itself with SMAppService. Turn on **Open at login** in the settings window. macOS tracks this under **System Settings → General → Login Items**, where you can also toggle it off. There is no LaunchAgent and no plist to manage by hand.
## Set a password
From the menu-bar icon, choose **Set Password…** and type the password you want to use at the desk to end a remote session.
Open the settings window (click the menu-bar icon) and type a password in the **Security** section. This is what someone at the desk types to get past the curtain.
If you never set a password, the default is `curtain`.
If you never set a password, the default is `curtain`. The password is stored as a salted PBKDF2-HMAC-SHA256 hash in UserDefaults. The plaintext is never saved.
Passwords are stored as a salted SHA-256 hash in `~/Library/Application Support/Curtain/config.json`. The plaintext is never saved.
## Disconnect helper (optional)
## Mark DisplayLink monitors (if you use them)
The optional "disconnect the remote session" feature is off by default. When you enable it (in settings or during onboarding), Curtain registers a privileged helper through SMAppService and asks for one approval in System Settings. There is no sudoers rule.
If you have any DisplayLink USB monitors, choose **Mark Current Externals as DisplayLink** from the menu. This tells Curtain to use a different cover mode for those displays.
Under the current ad-hoc build, this helper may fail to register. The privileged-helper path needs a notarized or Developer ID signed build to install cleanly. Until then, leave the feature off or expect the registration to be rejected.
To see which display is which first, choose **Identify Displays** — each monitor flashes its index number and serial for six seconds.
## Mark DisplayLink monitors (if you have them)
See [How It Works — DisplayLink](How-It-Works#displaylink) for why this matters.
If any external monitor is DisplayLink, open **Settings → Displays** and mark it as DisplayLink. This tells Curtain to use a capturable cover mode for that display.
Displays are identified by a stable UUID, so the marking survives reboots and reconnects. Detection works with both classic and high-performance Screen Sharing.
See [How It Works](How-It-Works#displaylink) for why this matters.
## Confirm Curtain is running
```bash
pgrep -fl Curtain
```
You can also open Activity Monitor and search for Curtain.
## Uninstall
```bash
./Scripts/uninstall.sh
```
Quit Curtain, then drag `Curtain.app` from `/Applications` to the Trash.
This removes the app bundle, LaunchAgent, helper binary, and sudoers rule.
## Manual LaunchAgent management
```bash
# Stop
launchctl unload ~/Library/LaunchAgents/com.acamarata.curtain.plist
# Start
launchctl load ~/Library/LaunchAgents/com.acamarata.curtain.plist
# Restart
launchctl kickstart -k gui/$(id -u)/com.acamarata.curtain
```
If you had an older script-based install on this machine, `Scripts/uninstall.sh` in the repo cleans up any legacy LaunchAgent, helper binary, or sudoers rule left behind.

View file

@ -43,13 +43,21 @@ The first approach used `lsof -i :5900` to detect an active Screen Sharing conne
The working approach:
```
netstat -an | grep '.5900 ' | grep ESTABLISHED
/usr/sbin/netstat -an | grep '.5900 ' | grep ESTABLISHED
```
`netstat` does not filter by process owner and sees all connections.
**Debounce disconnect:** a single missed netstat poll is not a real disconnect. Without debouncing, a transient network blip fires a false disconnect that kills a live session. The fix is to require three consecutive misses (~6 seconds at a 2-second poll interval) before treating it as a real disconnect.
## Probe tool paths must be verified on-device
`netstat` on macOS lives at `/usr/sbin/netstat`. There is no `/usr/bin/netstat`. A probe subprocess launched with the wrong absolute path fails silently: the process exits immediately with "no such file", the parser receives empty output, the probe returns zero results, and the detector never fires. No error surfaces anywhere unless the launch failure is logged explicitly.
The lesson: when shelling out to a system tool, use its verified on-device path, not a guessed or Unix-conventional one. Verify with `which netstat` or `xcrun -f netstat` on the actual target hardware before writing the path in code. Probe helpers must log launch failures loudly (path, error code) so a misconfigured path is visible in Console.app immediately rather than silently producing no detections.
This is the same class of bug as the `lsof` failure: a unit-tested parser that looked correct in isolation, fed by a subprocess that was silently dead.
## Screen lock
Three approaches were tried:
@ -99,3 +107,38 @@ Do not re-attempt these:
- **lsof for session detection.** Returns nothing for Screen Sharing sockets (wrong process owner).
- **CGSession -suspend for lock.** Removed from macOS.
- **caffeinate for display sleep prevention.** Orphaned PIDs kept displays awake after daemon reload.
- **`/usr/bin/netstat`.** Does not exist on macOS. Use `/usr/sbin/netstat`.
- **Three-mode cover scope (onlyMarked / allExceptMarked / all).** The "only marked" and "all except marked" modes were logically inverted in parts of the code, and the distinction confused settings. Replaced with a two-mode model: all displays, or per-display Cover toggles.
## v1.0 hardening (2026-06-02)
The v1.0 pass replaced or corrected several detection and distribution choices that did not survive contact with current macOS.
### netstat:5900 was not a reliable session signal, and the probe path was wrong
The `netstat | grep .5900 | grep ESTABLISHED` detector silently failed in two independent ways:
1. On macOS Sequoia, high-performance Screen Sharing moved to a UDP transport. There is no `ESTABLISHED` TCP state to match and the detector saw nothing, so the curtain never activated.
2. The probe helper was launched with the path `/usr/bin/netstat`, which does not exist on macOS. The real path is `/usr/sbin/netstat`. The launch failed silently every time. The unit-tested parser was correct, but the subprocess feeding it was dead. The ESTABLISHED-TCP activator was entirely non-functional until this was fixed.
Both were silent failures. No error was logged, no signal reached the detector.
The fixes: use `CGSessionScreenIsCaptured` as the primary signal (transport-independent, covers classic TCP and high-performance UDP); keep the TCP-ESTABLISHED and peered-UDP probes as secondary signals with the correct `/usr/sbin/netstat` path; and log every probe launch failure loudly so a path regression is visible immediately.
### The event-source filter is convenience, not security
The input split classifies events by `sourceStateID == 1` (physical HID). This is documented honestly as a convenience filter, not a security boundary. `sourceStateID` is spoofable by local code: a process running on the machine can inject events that claim any source ID. The filter is the right tool for keeping desk and remote input apart during normal use, but it is not a defense against a hostile local program, and the docs no longer imply otherwise.
### Mid-session display hotplug left monitors uncovered
There was no handler for `didChangeScreenParametersNotification`, so attaching a display during a session left that monitor showing the live desktop. v1.0 listens for that notification and reconciles the cover set on every change, applying the New-display policy (cover by default, fail-safe).
Display identity also moved. `CGDisplaySerialNumber` returns 0 for many monitors, and returns the same value for two identical monitors, so it could not key per-display settings reliably. Identity now uses `CGDisplayCreateUUIDFromDisplayID`, which is stable and unique per monitor across reconnects and reboots.
### Privileged disconnect moved off sudoers
The old approach dropped a root helper at `/usr/local/bin/curtain-endsession` with a NOPASSWD sudoers rule. That is replaced by an optional `SMAppService.daemon` plus an XPC connection, off by default. The user opts in, approves the helper once in System Settings, and the disconnect runs through XPC instead of a shell-out to sudo. Nothing privileged is installed unless the feature is enabled.
### Distribution: ad-hoc for v1.0, notarization next
v1.0 ships ad-hoc signed. Gatekeeper requires a one-time quarantine strip after download before the app will launch. The ad-hoc build also cannot register the `SMAppService.daemon`, which is why disconnect-remote-on-end is unavailable until a notarized or Developer-ID build exists. Notarization is planned and will remove both the quarantine step and the daemon limitation.

52
.github/wiki/Security.md vendored Normal file
View file

@ -0,0 +1,52 @@
# Security
This page describes what Curtain protects against, what it does not, and how its sensitive pieces work. Read it before you rely on Curtain for anything that matters to you.
## Threat model
Curtain is built for one situation: you own a Mac, you are remoting into it from your own laptop, and someone is physically sitting at that Mac's desk. Curtain hides your screen from that person and stops the desk keyboard and mouse from reaching your apps while you keep full control from the remote side.
That is the whole scope. Curtain is **not** a defense against malicious software already running on the Mac. If an attacker can run code on your machine, they can read your screen, log your keys, and bypass Curtain directly. Treat Curtain as a privacy curtain against a person at the desk, not as a security product against software.
## Input filtering is a convenience filter, not a security boundary
Curtain blocks desk input with a `CGEventTap` that classifies each event as physical or remote. The test is `eventSourceStateID == 1`, which macOS sets for real hardware events. Curtain drops those and passes everything else.
This works against a person typing on the desk keyboard. It does not stop a local program. Any process with the right APIs can post synthetic events and choose which classification they carry, so it could spoof an event as either physical or remote. A malicious local process is therefore out of scope by design. The filter is a convenience that ignores the desk's hardware, not a wall that a determined program cannot climb over.
## Password storage
The desk-unlock password (the one that reveals the desktop when someone presses a key at the desk) is never stored in plaintext. Curtain keeps a PBKDF2-HMAC-SHA256 hash, salted, run at roughly 200,000 iterations, in the app's UserDefaults plist. Wrong guesses trigger a repeated-attempt backoff that slows brute forcing.
If you never set a password, the default is `curtain`. That default exists so you can never lock yourself out of your own Mac. It is an unlock convenience, not full-disk security. Anyone who reads this page knows the default, so set your own password if the desk is not trusted, and remember that this protects the curtain reveal, not your data at rest. For data at rest, use FileVault.
## Emergency unlock and no-Accessibility safety
Two design choices make sure you can never be trapped behind the curtain:
- **Emergency hotkey.** Pressing **Control + Option + Command + U** at the desk force-deactivates the curtain. It is registered as a Carbon hotkey, so it fires even when Accessibility has not been granted. This is the guaranteed escape regardless of state.
- **No cover without Accessibility.** The input block depends on the Accessibility grant. If Accessibility is not granted, Curtain refuses to show the cover at all and notifies you, rather than putting up a screen it cannot unlock. You get a passive, clearly flagged state instead of a locked-out one.
## The optional disconnect helper
Ending the remote Screen Sharing session from the Mac side needs elevated rights. This is **off by default**, and most people never turn it on or see an admin prompt.
When you enable it, Curtain installs a privileged helper. On a notarized or Developer-ID build it registers a daemon through `SMAppService.daemon`, the current Apple API for this. The app talks to the helper over XPC, and the helper checks the caller's code signature before doing anything. On a local ad-hoc or dev build, which cannot register an `SMAppService` daemon, Curtain falls back to a small privileged helper installed with one admin prompt, scoped to the current user. A public notarized build never installs a sudoers rule. The older approach of dropping a NOPASSWD entry into sudoers for everyone is gone.
## Private API for locking
Locking the Mac calls `SACLockScreenImmediate` from Apple's `login.framework`, loaded at runtime with `dlopen`. This is a private symbol, so a documented fallback path exists in case it is unavailable. All of this runs on your own machine against your own login session.
## Permissions
Curtain needs exactly one TCC permission: Accessibility, granted once after install, so it can run the event tap that blocks desk input. It does not run in the App Sandbox, because a sandbox is incompatible with a global event tap. It requests no network access.
The activation trigger is deliberately narrow. Curtain raises the cover only when one of three signals fires: `CGSSessionScreenIsCaptured` (primary), a genuinely ESTABLISHED inbound TCP connection on port 5900, or 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 it, which prevents false activation while the machine is simply listening for connections.
## Distribution trust
Version 1.0 ships ad-hoc signed from GitHub Releases. Verify the published SHA-256 of the `.dmg` against what you downloaded before you install. Notarized Developer-ID builds are planned. Until those land, macOS Gatekeeper will quarantine the download, so a one-time quarantine strip is required (see Installation).
## Multi-display behavior
Curtain never leaves a display exposed at the desk. Native displays are hidden from the remote viewer with `sharingType = .none`, so the remote session does not even see them. DisplayLink displays cannot be hidden that way, so Curtain covers them with a visible cover (`.readOnly`). Either way the desk sees a cover, not your work. A display Curtain does not recognize is covered by default rather than left open.

307
.github/wiki/Settings.md vendored Normal file
View file

@ -0,0 +1,307 @@
# Settings
Open the settings window by clicking the curtains icon in the menu bar, or by reopening `Curtain.app` from Finder or Spotlight. Changes take effect immediately. The window is grouped into the sections below, matching the app's tabs.
---
## General
### Armed
The master switch. When Armed is off, Curtain ignores every session event: it will not cover, block input, disconnect, lock, or sleep displays no matter what happens. Nothing in the other sections fires while disarmed. Turn this off when you want Curtain installed but completely dormant.
Default: **on**.
### Open at login
Registers or unregisters Curtain as a login item using `SMAppService` (macOS 13+). When on, Curtain starts automatically after you log in. Requires the installed app bundle at `/Applications/Curtain.app`. Has no effect if you run the binary loose.
Default: **on**.
### Show in menu bar
Shows or hides the curtains icon in the menu bar. Turning this off does not stop Curtain from running. It keeps monitoring and reacting in the background. To get the settings window back after hiding the icon, reopen `Curtain.app` from Finder or Spotlight.
Default: **on**.
### Activate Now
Covers all displays and starts blocking physical input immediately, without waiting for a remote session. Useful for testing the curtain manually.
### Test (10s)
Same as Activate Now, but automatically deactivates after 10 seconds. A quick visual check that the cover and icon are working.
### Emergency unlock hotkey
Pressing **Control + Option + Command + U** at any time force-deactivates the curtain. It is registered as a Carbon hotkey, so it works even when Accessibility has not been granted. This is the guaranteed way to drop the cover from the desk.
### Reset to Defaults
Restores every setting on every tab to its shipped default. Your password is not changed by this.
### Export…
Writes all current settings to a `.json` file you choose. Useful for copying a known-good configuration to another Mac. The password hash is not included.
### Import…
Reads a settings file written by Export and applies it. Settings take effect immediately.
---
## Activation
Controls what happens when a remote session begins.
### Activate curtain when a remote session begins
When a remote session is detected, Curtain covers the displays and starts blocking physical input. Turn this off to leave Curtain running but passive: it still responds to idle and end events, but does not activate automatically on connect.
Default: **on**.
### Connect grace seconds
A debounce before covering. Curtain waits this many seconds after detecting a connection before it activates, so a brief blip or a probe that drops right away does not flash the cover. Range: 0 to 30 seconds.
Default: **2 seconds**.
### Notify on activate
Posts a macOS user notification when the curtain activates, so you have a record that a session started.
Default: **on**.
### Play sound on activate
Plays a short sound when the curtain activates.
Default: **off**.
---
## Appearance
What the person at the desk sees while the curtain is up.
### Cover style
Choose what the cover displays:
- **Solid color** — a flat fill in the chosen cover color.
- **Message** — the cover color with your text centered on it.
- **Blur** — a frosted blur over the desktop.
- **Lock logo** — a lock glyph centered on the cover color.
- **Curtain logo** — the Curtain logo centered on the cover color.
- **Aerial video** — plays a muted, looping system aerial `.mov` in the cover window. If no aerial video is found on the machine, it falls back to the lock logo.
Default: **lock logo**.
### Reveal trigger
What wakes the password box at the desk:
- **Any key** — any physical key press shows the box.
- **Key combo** — only a key combination you define shows the box.
Default: **any key**.
### Cover color
The fill color used by the solid, message, and logo styles.
### Cover message text
The text shown when Cover style is set to Message.
### Show clock
Overlays the current time on the cover. Lets the person at the desk see the machine is alive and on time.
Default: **on**.
---
## On session idle
### Act after idle
Enables the idle timeout feature. When on, Curtain watches for inactivity and fires the chosen actions once the threshold passes.
Default: **on**.
### Idle timeout
How many minutes of inactivity trigger the idle actions. Range: 1 to 240 minutes.
Default: **30 minutes**.
### Idle source
What counts as activity:
- **Remote session activity** — time since the last input event in the combined session (remote operator). Idle means the remote operator stopped interacting. This is the product default because it tracks whether the remote session itself is active.
- **This Mac's physical input only** — time since the last physical hardware event at the desk.
Default: **Remote session activity**.
### Idle actions
Any combination of the following fires when the idle timeout passes:
- **Disconnect** — ends the remote session.
- **Lock** — locks the Mac.
- **Turn off displays** — sleeps all displays.
- **Deactivate** — hides the cover and removes the input block.
Defaults: all **on**.
---
## On session end (disconnect)
These actions fire when a remote session is detected as dropped.
- **Lock** — locks the Mac when the session ends.
- **Turn off displays** — sleeps the displays after locking.
- **Deactivate** — hides the cover and removes the input block. If left off, the cover stays up after the remote disconnects.
Defaults: all **on**.
---
## Security
### On Curtain Unlock
What happens to the remote session when the correct password is entered at the desk:
- **Keep session active** — the curtain drops and the remote operator stays connected.
- **Disconnect remote** — the curtain drops and the active remote session is ended.
Default: **keep session active**.
### Password box timeout
How long the on-curtain password box stays visible after a physical key wakes it before it hides again. Range: 5 to 60 seconds.
Default: **15 seconds**.
### Require password to deactivate from the menu
When on, choosing Deactivate from the menu prompts for the password first. The fallback password `curtain` always works even when a custom password is set, so you can never lock yourself out from the menu.
Default: **off**.
### Accessibility-missing behavior
The input block cannot work without Accessibility. When the permission is not granted, Curtain never shows the cover: on connect it stays down and posts a notification instead. This prevents putting up a screen that cannot be unlocked. The picker controls what arming itself does in that state: **Warn** (default) lets the app arm and warns at connect time; **Refuse to arm** rejects the master switch outright with a notification until the permission is granted.
Grant Accessibility in System Settings → Privacy & Security → Accessibility, then relaunch Curtain. The emergency hotkey **Control + Option + Command + U** still works regardless of Accessibility state.
### Set password
Type a new password and click **Set**. The password is stored as a salted PBKDF2-HMAC-SHA256 hash in `UserDefaults`. The plaintext is never saved.
If no password has been set, the default password `curtain` is accepted. The window shows your current state ("A password is set." or "No password set (default: 'curtain')."). The `curtain` fallback always works regardless of any custom password.
---
## Disconnect
### Enable disconnect-remote-on-end
Lets Curtain end the remote session for the idle and session-end actions. This is **off by default** because it needs elevated rights.
Turning it on installs a privileged helper. On a notarized or Developer-ID-signed build, Curtain registers a daemon through `SMAppService.daemon`, which prompts for one approval in System Settings. On a local ad-hoc or dev build, Curtain falls back to a small privileged helper installed with one admin prompt, scoped to the current user. A public notarized build never installs a sudoers rule. The helper performs the disconnect; macOS respawns the listener so new connections stay possible. See [How It Works](How-It-Works).
Default: **off**.
---
## Displays
Per-display controls, keyed by each monitor's stable display UUID so they survive reboots and reconnection.
### Cover (per display)
Marks whether a given display participates in covering. Used with Cover scope below.
### DisplayLink (per display)
Marks a display as a DisplayLink monitor. Curtain uses a capturable cover (`sharingType = .readOnly`) for these instead of the invisible cover (`sharingType = .none`) it uses for native displays. The cover on a DisplayLink display is also visible in the remote view, a hardware constraint, not a bug.
### Cover scope
Which displays get covered when the curtain activates:
- **All displays** — cover every display. This is the fail-safe default. No per-display toggling needed.
- **Per-display Cover toggles** — each display's Cover switch in the list below decides whether it is covered.
Default: **All displays**.
### Password-box display
Which display shows the on-curtain password box when a physical key is pressed. Choose from the list of connected displays shown in the picker. The list updates when displays are added or removed.
Default: **Primary display**.
### New-display policy
What to do when a display is hotplugged mid-session:
- **Cover** — cover it immediately (fail-safe default).
- **Leave uncovered** — do not cover it.
- **Treat as DisplayLink** — cover it with the capturable cover.
Default: **cover**. Covering is the fail-safe so a newly attached monitor never leaks the desktop.
### Identify Displays
Flashes each connected display with a large label showing its index and identifier. Use this before marking displays.
### Mark Externals as DisplayLink
Records every external (non-built-in) display as a DisplayLink display in one step.
---
## Advanced
### Diagnostics logging
Enables verbose logging for troubleshooting. Off by default to keep logs quiet.
Default: **off**.
### Open Setup…
Re-runs the first-run onboarding flow (permission checks, display marking, password setup).
### Version footer
Shows the running version of Curtain.
---
## Safe first-run defaults
Curtain ships configured to fail safe:
- Armed on, activate-on-connect on, with a 2-second connect grace.
- Cover scope is All displays, so every display is covered without any per-display configuration.
- New displays are covered by default, so a mid-session hotplug never exposes the desktop.
- Idle source is Remote session activity, so idle detection tracks whether the remote operator is still present.
- The `curtain` fallback password always works, so you can always drop the cover at the desk.
- The Control + Option + Command + U emergency hotkey force-deactivates the curtain even without Accessibility.
- Disconnect-remote-on-end is off, so no privileged helper is installed until you opt in.
- Without Accessibility, Curtain never shows the cover and notifies you instead, rather than putting up a screen it cannot unlock.
## Dangerous-combination warnings
The settings window flags combinations that can leave you in a bad state:
- **Low idle timeout plus disconnect.** A short idle timeout combined with disconnect-on-idle can kill a live session during a normal pause in work. The window warns when the timeout is low and disconnect is on.
- **Screen off without deactivate.** Turning displays off without also deactivating leaves the cover up behind a black screen. When you wake the display you are still behind the curtain. The window warns on this pair.
- **"Dead but unlocked."** A configuration that turns displays off or deactivates on end without locking can leave the Mac reachable and unlocked once the cover is gone. The window warns when an end or idle action sleeps or deactivates but does not lock.
- **Accessibility not granted.** When the permission is missing, the window shows that Curtain will refuse to cover, so you are not surprised that nothing happens on connect. The Control + Option + Command + U hotkey still works as the escape.

View file

@ -1,120 +1,109 @@
# Troubleshooting
## Curtain covers the screen but desk keyboard input still reaches apps
## Emergency unlock (always works)
Accessibility permission is not granted, or the tap is not active.
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.
1. Open System Settings > Privacy & Security > Accessibility.
## 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, run `open -a Curtain` once to force a registration attempt.
4. After granting, relaunch Curtain: `launchctl kickstart -k gui/$(id -u)/com.acamarata.curtain`
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.
If the app is not properly code-signed (e.g. you compiled it yourself outside `install.sh`), TCC may not give it a stable identity. Run `install.sh` to ensure the bundle is ad-hoc signed.
## macOS blocks the app on first launch (Gatekeeper)
## The curtain shows but the remote operator's mouse/keyboard stops working
Curtain is ad-hoc signed and not yet notarized, so a clean download is quarantined. Clear the flag once, then open normally:
This should not happen if 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 are passed through.
```bash
xattr -dr com.apple.quarantine /Applications/Curtain.app
```
Check that Curtain is not the key window and not blocking mouse events at the window level. The cover window should have `ignoresMouseEvents = true`. If you built Curtain from source outside the normal build, verify these properties are set.
This is a current limitation that goes away once a notarized build ships.
## The app will not launch
Confirm whether the process is running:
```bash
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:
```bash
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.
## DisplayLink monitor is not covered
This is expected if the monitor has not been marked.
From the menu-bar icon, choose **Mark Current Externals as DisplayLink**. If you have multiple external monitors and only some are DisplayLink, use **Identify Displays** first to flash each display's index and serial number, then verify the serials in `~/Library/Application Support/Curtain/config.json`.
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](How-It-Works#displaylink) for the technical reason.
Note: on a DisplayLink monitor, the curtain shows in your remote view too. That is by design. See [How It Works — DisplayLink](How-It-Works#displaylink) for the technical reason.
## Multiple displays: the remote view only shows one screen
## The session keeps dropping every ~30-60 seconds
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.
This is the debounce not triggering correctly, or a network issue causing repeated transient disconnects.
## The session keeps dropping
Curtain requires three consecutive netstat misses (~6 seconds) before declaring a disconnect. If the network is unstable enough to miss three consecutive polls, the session may drop. Check your network connection.
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.
If this happens even on a stable network, 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 function uses `SACLockScreenImmediate` from `login.framework`. If this symbol is unavailable (unlikely but possible after a major macOS update), it falls back to an `osascript` Ctrl+Cmd+Q shortcut, which requires Accessibility.
Check that the lock screen is enabled: System Settings > Lock Screen > Require password. If the lock screen is disabled at the OS level, `SACLockScreenImmediate` has nothing to lock to.
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
The default password is `curtain`. If you set a custom password and forgot it, delete the config file to reset:
Settings live in UserDefaults. Reset everything to defaults:
```bash
rm ~/Library/Application\ Support/Curtain/config.json
defaults delete io.acamarata.curtain
```
Curtain recreates it with a fresh salt and no password hash on next launch, and `curtain` becomes the password again. Set a new password from the menu.
Relaunch Curtain. It starts fresh and the default password `curtain` applies again. Set a new one from the settings window.
## The curtain does not appear when Screen Sharing connects
## The disconnect helper is not working
Check that Curtain is armed. The menu-bar icon shows `👁` when armed and `○` when disarmed. If it shows `○`, choose **Armed** from the menu to re-enable.
Also confirm the menu-bar icon is present. If Curtain is not running:
```bash
launchctl list | grep curtain
```
If the LaunchAgent is not loaded:
```bash
launchctl load ~/Library/LaunchAgents/com.acamarata.curtain.plist
```
## How to check if the LaunchAgent is running
```bash
launchctl list | grep curtain
```
A running agent shows its PID in the first column. An exit code in the third column means it crashed or exited.
To see the last exit reason:
```bash
launchctl print gui/$(id -u)/com.acamarata.curtain
```
To restart:
```bash
launchctl kickstart -k gui/$(id -u)/com.acamarata.curtain
```
## How to check Accessibility permission status
```bash
# Prints 1 if trusted, 0 if not
/usr/bin/swift -e 'import Cocoa; print(AXIsProcessTrusted() ? 1 : 0)'
```
Or open System Settings > Privacy & Security > Accessibility and look for Curtain in the list.
## The install script fails asking for a password
The script needs admin rights once to install the root helper and sudoers rule. This is expected. Enter your admin password when prompted. After that, no further sudo prompts should appear during normal use.
## The "end session" helper is missing
If `System.endScreenShareSession()` silently does nothing, the helper may not be installed:
```bash
ls -la /usr/local/bin/curtain-endsession
cat /etc/sudoers.d/curtain-endsession
```
If either is missing, re-run `install.sh`. This step requires one admin prompt.
## Screen Sharing stops working after a disconnect
The helper kills the active session processes. macOS respawns the listener automatically. If Screen Sharing does not accept new connections after a Curtain-initiated disconnect, try:
```bash
sudo launchctl kickstart -k system/com.apple.screensharing
```
Or toggle Screen Sharing off and on in System Settings > General > Sharing.
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.

4
.gitignore vendored
View file

@ -9,6 +9,7 @@ DerivedData/
# AI working memory (gitignored)
.claude/
.opencode/
# Local secrets / config
*.local
@ -22,3 +23,6 @@ DerivedData/
.windsurf/
.gemini/
.codeium/
# Release artifacts
dist/

View file

@ -5,9 +5,24 @@ let package = Package(
name: "Curtain",
platforms: [.macOS(.v13)],
targets: [
.target(
name: "CurtainShared",
path: "Sources/CurtainShared"
),
.executableTarget(
name: "Curtain",
dependencies: ["CurtainShared"],
path: "Sources/Curtain"
),
.executableTarget(
name: "CurtainHelper",
dependencies: ["CurtainShared"],
path: "Sources/CurtainHelper"
),
.testTarget(
name: "CurtainSharedTests",
dependencies: ["CurtainShared"],
path: "Tests/CurtainSharedTests"
)
]
)

View file

@ -1,80 +1,62 @@
# Curtain
A privacy curtain for macOS Screen Sharing. When you remote into your Mac, Curtain hides the screen from anyone sitting at it and makes the local keyboard and mouse do nothing to your apps, while you keep full control from your laptop. When the session goes idle or ends, it can lock the Mac and sleep the displays.
A menu-bar privacy layer for macOS Screen Sharing.
macOS already does Screen Sharing well. Curtain is the missing privacy layer around it: a lightweight menu-bar agent with a simple settings window, in the spirit of Caffeine.
When you remote into your Mac, Curtain hides the screen from anyone sitting at the desk and makes the local keyboard and mouse do nothing to your apps, while your remote control keeps working normally. When the session goes idle or disconnects, it can lock the Mac and sleep the displays. It runs as a small menu-bar agent with a simple settings window, in the spirit of Caffeine.
<p align="center"><em>Connect → screen covered, desk input dead, you control remotely. Idle/disconnect → lock + displays off.</em></p>
## Why it works
## Why it works (the one hard part)
Your laptop and the desk share one login session, so a window that blocks input would block you too. Curtain instead filters input by **source**: macOS tags real hardware events differently from injected remote events, so Curtain blocks the desk's keyboard/mouse while letting your remote control pass through. No virtual display, no second account.
Your laptop and the desk share one login session, so a window that blocks input would block you too. Curtain takes a different route. It detects sessions using three signals: a transport-independent macOS capture flag (works with both classic TCP and the high-performance UDP mode on macOS 14+ Apple Silicon), an established TCP connection on port 5900, and a peered UDP socket on ports 5900-5902. It then filters input by event source: physical hardware events from the desk are blocked, while your injected remote events pass through untouched. No virtual display, no second account.
## What it does
| Event | Default behavior (all configurable) |
| Event | Behavior (all configurable) |
|---|---|
| **Remote session starts** | Curtain covers every physical display; desk keyboard/mouse are blocked from apps; your remote input works; displays kept awake. |
| **Key pressed at the desk** | A password box appears (on the desk only). Correct password reveals the desktop and offers to disconnect the remote. |
| **Session idle (default 30 min)** | Disconnect remote · lock Mac · sleep displays · deactivate curtain. |
| **Session ends (disconnect)** | Lock Mac · sleep displays · deactivate curtain. |
| **Remote session starts** | Covers every physical display, blocks desk keyboard and mouse, keeps the displays awake. Posts a notification. Your remote input works as usual. |
| **Reveal trigger at the desk** | A password box appears on the desk on any key, or on a key combo you define. The correct password reveals the desktop and can optionally keep or disconnect the remote operator. |
| **Session idle** (default 30 min) | Any of: disconnect the remote, lock the Mac, sleep the displays, deactivate the curtain. |
| **Disconnect** | Any of: lock the Mac, sleep the displays, deactivate the curtain. |
## Install
1. Download `Curtain-1.0.0.dmg` from the [Releases](../../releases) page.
2. Open the DMG and drag `Curtain.app` to Applications.
3. Launch Curtain. First launch walks you through granting Accessibility, setting an optional password, and installing an optional disconnect helper.
Curtain needs Accessibility permission to block desk input, so grant it when prompted (System Settings, Privacy & Security, Accessibility). If Accessibility is not granted, Curtain refuses to show the cover and notifies you, rather than putting up a screen it cannot unlock.
**Emergency unlock:** press **Control + Option + Command + U** at any time to force-deactivate the curtain. This works even without Accessibility granted (it uses a Carbon hotkey), so it is your guaranteed way out.
The current builds are ad-hoc signed, not yet notarized, so macOS Gatekeeper will refuse the first launch. Clear the quarantine flag once, then open the app:
```bash
git clone https://github.com/acamarata/curtain.git
cd curtain
./Scripts/install.sh
xattr -dr com.apple.quarantine /Applications/Curtain.app
```
The installer builds the app to `/Applications/Curtain.app`, generates the curtains icon, registers a login agent, and sets up a small root helper (one admin prompt) used to disconnect a Screen Sharing session.
Notarized builds are planned, which will remove this step. To confirm your download is intact, verify the DMG against its published SHA-256 (the `.sha256` file is attached to the release):
Then, **once**:
1. Grant **Accessibility** to Curtain: System Settings → Privacy & Security → Accessibility. This lets it block desk input. (Curtain prompts you on first launch.)
2. Open Curtain (menu-bar icon, or launch the app) → **Set a password** and, if you use DisplayLink monitors, **Mark Externals as DisplayLink**.
```bash
shasum -a 256 Curtain-1.0.0.dmg
```
Uninstall: `./Scripts/uninstall.sh`
## Settings
## The settings window
Everything is a setting. Open the window from the menu-bar curtains icon or by reopening `Curtain.app`. Changes take effect immediately. You control arming, what the desk sees (solid color, message, blur, lock logo, Curtain logo, or aerial video), the reveal trigger, the idle and disconnect actions, the idle source (remote session activity or physical HID), the password and idle timeout, what happens to the remote session on unlock, per-display cover scope, password-box placement, and DisplayLink marking. See the [Settings](../../wiki/Settings) page for the full reference.
Open it from the menu-bar curtains icon, or by launching Curtain.app. Everything is a toggle; changes take effect immediately.
## Multi-display note
### Application
- **Open at login** — run Curtain automatically (via `SMAppService`).
- **Show in menu bar** — show or hide the curtains icon. Hidden still runs in the background; reopen the app to get settings back.
- **Activate Now** / **Test (10s)** — show the curtain on demand.
### On session start
- **Activate curtain when a remote session begins** — the core behavior. Turn off to leave Curtain armed but passive.
### On session idle
- **Act after the session is idle** + **Idle timeout** (1240 min).
- Independent toggles for what happens at idle: **Disconnect the remote session**, **Lock the Mac**, **Turn off the displays**, **Deactivate the curtain**.
### On session end (disconnect)
- Independent toggles: **Lock the Mac**, **Turn off the displays**, **Deactivate the curtain**.
### Security
- **Disconnect remote when password is entered at the desk** — on unlock, offer to kick the remote operator.
- **Set password** — typed at the desk to get past the curtain. Stored as a salted SHA256 hash. If unset, the default is `curtain` so you are never locked out.
### Displays
- **Identify Displays** — flashes each display's index and serial.
- **Mark Externals as DisplayLink** — marks every external monitor as DisplayLink.
## DisplayLink monitors
DisplayLink displays exist only through screen capture, so they can't be hidden invisibly the way directly-attached displays can. On those monitors the curtain also appears in your remote view. Native displays stay clear in your session while hidden at the desk. Mark your DisplayLink monitors in settings so they get covered correctly.
Curtain covers every physical display. The Apple Screen Sharing app shows one host display at a time, so on a multi-monitor Mac you switch between them in its View menu. That is standard Screen Sharing behavior, not something Curtain changes.
## Requirements
- macOS 13 (Ventura) or later. Built and used on macOS 26 / Apple Silicon.
- Screen Sharing enabled: System Settings → General → Sharing → Screen Sharing.
- Accessibility permission for Curtain (to block desk input).
- macOS 13 Ventura or later
- Apple Silicon (built and tested on a Mac Mini M4 running macOS 26)
- Screen Sharing enabled (System Settings, General, Sharing, Screen Sharing)
- Accessibility permission for Curtain
## How it works / architecture / lessons
## Documentation
Full detail in the [wiki](../../wiki): architecture, the macOS APIs involved, and the lessons learned (including the things that did not work).
The [wiki](../../wiki) covers everything in depth: [Installation](../../wiki/Installation), [Settings](../../wiki/Settings), [How It Works](../../wiki/How-It-Works), [Architecture](../../wiki/Architecture), [Security](../../wiki/Security), [Lessons Learned](../../wiki/Lessons-Learned), and [Troubleshooting](../../wiki/Troubleshooting).
## License

View file

@ -1,89 +1,45 @@
#!/bin/bash
# Curtain installer — builds the app, installs it as a login menu-bar agent,
# and sets up the (optional) root helper that disconnects a Screen Sharing session.
# Curtain local installer (developer convenience — end users drag the .app
# from the .dmg instead). Builds a release Curtain.app and drops it in
# /Applications, ad-hoc signed.
#
# Login-at-login and the optional Screen Sharing disconnect helper are now
# managed from inside the app via SMAppService — this script no longer writes
# a LaunchAgent, a /usr/local/bin helper, or a sudoers rule.
set -euo pipefail
REPO="$(cd "$(dirname "$0")/.." && pwd)"
APP="/Applications/Curtain.app"
AGENT="$HOME/Library/LaunchAgents/io.acamarata.curtain.plist"
HELPER="/usr/local/bin/curtain-endsession"
SUDOERS="/etc/sudoers.d/curtain-endsession"
echo "==> Building (release)…"
cd "$REPO"
swift build -c release
BIN="$REPO/.build/release/Curtain"
# Prefer the full release pipeline (icon, daemon plist, dmg) when present.
if [ -x "$REPO/Scripts/release.sh" ]; then
echo "==> Building via release pipeline…"
"$REPO/Scripts/release.sh"
SRC="$REPO/dist/Curtain.app"
else
echo "==> Building (release)…"
cd "$REPO"
swift build -c release
TMP_PARENT="$(mktemp -d)"
trap 'rm -rf "$TMP_PARENT"' EXIT
SRC="$TMP_PARENT/Curtain.app"
mkdir -p "$SRC/Contents/MacOS"
cp "$REPO/.build/release/Curtain" "$SRC/Contents/MacOS/Curtain"
cp "$REPO/.build/release/CurtainHelper" "$SRC/Contents/MacOS/CurtainHelper"
codesign --force --options runtime --sign - "$SRC" 2>/dev/null || true
fi
echo "==> Installing $APP"
rm -rf "$APP"
mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources"
cp "$BIN" "$APP/Contents/MacOS/Curtain"
cp -R "$SRC" "$APP"
echo "==> Generating app icon…"
ICONSET="$(mktemp -d)/Curtain.iconset"
"$BIN" --render-icon "$ICONSET" || true
if [ -d "$ICONSET" ]; then
iconutil -c icns "$ICONSET" -o "$APP/Contents/Resources/AppIcon.icns" 2>/dev/null || true
rm -rf "$ICONSET"
fi
cat > "$APP/Contents/Info.plist" <<PLIST
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"><dict>
<key>CFBundleName</key><string>Curtain</string>
<key>CFBundleIdentifier</key><string>io.acamarata.curtain</string>
<key>CFBundleExecutable</key><string>Curtain</string>
<key>CFBundleIconFile</key><string>AppIcon</string>
<key>CFBundlePackageType</key><string>APPL</string>
<key>CFBundleShortVersionString</key><string>1.0.0</string>
<key>LSUIElement</key><true/>
<key>LSMinimumSystemVersion</key><string>13.0</string>
</dict></plist>
PLIST
# Ad-hoc sign so TCC (Accessibility) can pin a stable identity.
codesign --force --deep --sign - "$APP" 2>/dev/null || true
echo "==> Installing root helper (disconnect Screen Sharing on idle/unlock)…"
TMP_HELPER="$(mktemp)"
cat > "$TMP_HELPER" <<'HELPER'
#!/bin/bash
# Ends the active Screen Sharing session. launchd respawns the listener,
# so Screen Sharing stays available for the next connection.
pkill -f ScreenSharingSubscriber 2>/dev/null
pkill -x screensharingd 2>/dev/null
pkill -f "RemoteManagement.*[Ss]creen" 2>/dev/null
exit 0
HELPER
osascript -e "do shell script \"install -m 755 '$TMP_HELPER' '$HELPER' && printf 'admin ALL=(root) NOPASSWD: $HELPER\n' > '$SUDOERS' && chmod 440 '$SUDOERS' && visudo -cf '$SUDOERS'\" with administrator privileges"
rm -f "$TMP_HELPER"
echo "==> Installing login agent…"
mkdir -p "$HOME/Library/LaunchAgents"
cat > "$AGENT" <<PLIST
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"><dict>
<key>Label</key><string>io.acamarata.curtain</string>
<key>ProgramArguments</key><array><string>$APP/Contents/MacOS/Curtain</string></array>
<key>RunAtLoad</key><true/>
<key>KeepAlive</key><true/>
<key>LimitLoadToSessionType</key><string>Aqua</string>
</dict></plist>
PLIST
launchctl unload "$AGENT" 2>/dev/null || true
launchctl load -w "$AGENT"
cat <<EOF
✅ Curtain installed.
ONE manual step (required so Curtain can block desk input):
System Settings → Privacy & Security → Accessibility → enable "Curtain".
Then from the Curtain menu (🔒/👁 in the menu bar): quit & it relaunches at login,
or run: launchctl kickstart -k gui/\$(id -u)/io.acamarata.curtain
First-time setup from the menu bar:
• Set Password… (typed at the desk to end a session)
• Mark Current Externals as DisplayLink (if you use DisplayLink monitors)
EOF
echo
echo "✅ Curtain installed to $APP"
echo
echo "Manual step (required so Curtain can block desk input):"
echo " System Settings → Privacy & Security → Accessibility → enable \"Curtain\"."
echo
echo "Open Curtain, then in its settings:"
echo " • Open at Login and the optional disconnect daemon are registered from"
echo " inside the app (SMAppService) — no terminal commands needed."
echo " • Set a desk password, and mark DisplayLink monitors if you use them."

View file

@ -0,0 +1,151 @@
#!/usr/bin/env swift
//
// probe-detection.swift on-device verification tool for Curtain's detection.
//
// Run: swift Scripts/probe-detection.swift
//
// Prints one timestamped line per second showing every raw signal Curtain can use
// to detect a Screen Sharing session: the CGSession capture key, the on-console
// flag, the presence of screen-sharing helper processes, and the netstat rows on
// the VNC ports (TCP 5900 + UDP 5900-5902). It ALSO diffs the full CGSession
// dictionary and prints any key that appears, disappears, or changes value so a
// live connection reveals every session-dictionary signal macOS exposes, including
// ones we do not know about yet. Start it, then open a real Screen Sharing session
// to this Mac and watch which signals flip. Ctrl-C to stop.
//
// Reading the output during a live test:
// captured=true -> the authoritative capture key works; detection is solid.
// tcp_estab>=1 -> classic VNC transport visible to netstat.
// udp_peered>=1 -> High-Performance (UDP) transport visible to netstat.
// dict change lines -> candidate signals if none of the above fired.
import Cocoa
import CoreGraphics
import Foundation
let captureKey = "CGSSessionScreenIsCaptured"
let consoleKey = kCGSessionOnConsoleKey as String
func sessionDict() -> [String: Any] {
(CGSessionCopyCurrentDictionary() as? [String: Any]) ?? [:]
}
func boolValue(_ dict: [String: Any], _ key: String) -> Bool {
(dict[key] as? Bool) ?? false
}
func shell(_ path: String, _ args: [String]) -> String {
let process = Process()
process.executableURL = URL(fileURLWithPath: path)
process.arguments = args
let pipe = Pipe()
process.standardOutput = pipe
process.standardError = Pipe()
do {
try process.run()
} catch {
// A probe tool must fail loudly: a silent "" here once masked a dead
// netstat path and made every socket count read as zero.
print("!! probe helper failed to launch: \(path)\(error)")
return ""
}
let data = pipe.fileHandleForReading.readDataToEndOfFile()
process.waitUntilExit()
return String(data: data, encoding: .utf8) ?? ""
}
func screenShareProcesses() -> String {
let out = shell("/usr/bin/pgrep", ["-fl", "ScreenSharingAgent|ScreenSharingSubscriber|screensharingd"])
let trimmed = out.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty { return "none" }
// Collapse to a compact one-line summary of matched process names.
let names = trimmed.split(separator: "\n").compactMap { line -> String? in
line.split(separator: " ").dropFirst().first.map(String.init)
}
return names.isEmpty ? "present" : names.joined(separator: ",")
}
func isVNCLocal(_ local: String) -> Bool {
local.hasSuffix(".5900") || local.hasSuffix(".5901") || local.hasSuffix(".5902")
}
func isRealPeer(_ foreign: String) -> Bool {
foreign != "*.*" && !foreign.hasSuffix(".*")
}
func vncSockets() -> String {
// netstat lives in /usr/sbin on macOS NOT /usr/bin.
let out = shell("/usr/sbin/netstat", ["-an"])
var estab = 0 // ESTABLISHED inbound TCP on 5900 a real classic VNC session
var listen = 0 // 5900 LISTEN always present when Screen Sharing is enabled
var udpTotal = 0 // any UDP socket on 5900-5902 informational
var udpPeered = 0 // UDP on 5900-5902 with a real foreign peer High-Performance session
for raw in out.split(separator: "\n") {
let line = String(raw)
let fields = line.split(separator: " ", omittingEmptySubsequences: true).map(String.init)
guard fields.count >= 5 else { continue }
let proto = fields[0].lowercased()
let local = fields[3]
let foreign = fields[4]
guard isVNCLocal(local) else { continue }
if proto.hasPrefix("tcp") {
let state = fields.count >= 6 ? fields[5] : ""
if state == "ESTABLISHED", isRealPeer(foreign) {
estab += 1
} else if state == "LISTEN" {
listen += 1
}
} else if proto.hasPrefix("udp") {
udpTotal += 1
if isRealPeer(foreign) { udpPeered += 1 }
}
}
return "tcp_estab=\(estab) tcp_listen=\(listen) udp=\(udpTotal) udp_peered=\(udpPeered)"
}
let formatter = DateFormatter()
formatter.dateFormat = "HH:mm:ss"
func flatten(_ dict: [String: Any]) -> [String: String] {
var flat: [String: String] = [:]
for (k, v) in dict { flat[k] = String(describing: v) }
return flat
}
print("Curtain detection probe — Ctrl-C to stop")
// Dump the full session dictionary once so the baseline is on record.
var lastDict = flatten(sessionDict())
print("CGSession dictionary at start:")
for (k, v) in lastDict.sorted(by: { $0.key < $1.key }) {
print(" \(k) = \(v)")
}
print("time | captured | onConsole | processes | netstat 5900-5902")
while true {
let dict = sessionDict()
let captured = boolValue(dict, captureKey)
let onConsole = boolValue(dict, consoleKey)
let stamp = formatter.string(from: Date())
let line = "\(stamp) | "
+ "captured=\(captured) | "
+ "onConsole=\(onConsole) | "
+ "procs=\(screenShareProcesses()) | "
+ vncSockets()
print(line)
// Diff the full dictionary: any key that moves during a connection is a
// candidate detection signal, even ones we have never heard of.
let now = flatten(dict)
for (k, v) in now.sorted(by: { $0.key < $1.key }) where lastDict[k] != v {
print("\(stamp) | DICT \(k): \(lastDict[k] ?? "(absent)") -> \(v)")
}
for k in lastDict.keys.sorted() where now[k] == nil {
print("\(stamp) | DICT \(k): removed")
}
lastDict = now
fflush(stdout)
Thread.sleep(forTimeInterval: 1.0)
}

193
Scripts/release.sh Executable file
View file

@ -0,0 +1,193 @@
#!/bin/bash
# Curtain release pipeline (maintainer-only).
#
# Builds both executables, assembles a distributable Curtain.app with its
# privileged-helper daemon and baked icon, code-signs it, and packages a
# drag-to-Applications .dmg with a checksum.
#
# Default signing is ad-hoc ("-"), which ships today without an Apple Developer
# account. The notarization swap is a single guarded block near the signing
# step: set SIGN_IDENTITY to your Developer ID and uncomment the notarytool
# lines to graduate to a fully notarized build.
set -euo pipefail
REPO="$(cd "$(dirname "$0")/.." && pwd)"
cd "$REPO"
VERSION="$(tr -d '[:space:]' < "$REPO/VERSION")"
# Derive a monotonically-increasing build number from the commit count so
# CFBundleVersion never regresses across releases, even on rebuilds of the same tag.
BUILD_INT="$(git -C "$REPO" rev-list --count HEAD 2>/dev/null || echo 1)"
APP_ID="io.acamarata.curtain"
HELPER_LABEL="io.acamarata.curtain.helper"
ENTITLEMENTS="$REPO/curtain.entitlements"
# Signing identity. "-" = ad-hoc (ships now). Override with a Developer ID for
# notarized builds, e.g. SIGN_IDENTITY="Developer ID Application: Aric Camarata (TEAMID)".
SIGN_IDENTITY="${SIGN_IDENTITY:--}"
BUILD_DIR="$REPO/.build/release"
DIST="$REPO/dist"
APP="$DIST/Curtain.app"
DMG="$DIST/Curtain-$VERSION.dmg"
echo "==> Curtain release $VERSION (build $BUILD_INT)"
# --- a. Build both executables ------------------------------------------------
echo "==> swift build -c release"
swift build -c release
CURTAIN_BIN="$BUILD_DIR/Curtain"
HELPER_BIN="$BUILD_DIR/CurtainHelper"
[ -x "$CURTAIN_BIN" ] || { echo "ERROR: $CURTAIN_BIN not built"; exit 1; }
[ -x "$HELPER_BIN" ] || { echo "ERROR: $HELPER_BIN not built"; exit 1; }
# --- b. Assemble Curtain.app --------------------------------------------------
echo "==> Assembling Curtain.app"
rm -rf "$APP"
mkdir -p "$APP/Contents/MacOS" \
"$APP/Contents/Resources" \
"$APP/Contents/Library/LaunchDaemons"
cp "$CURTAIN_BIN" "$APP/Contents/MacOS/Curtain"
cp "$HELPER_BIN" "$APP/Contents/MacOS/CurtainHelper"
# Privileged helper daemon plist. SMAppService.daemon(plistName:) loads this
# from Contents/Library/LaunchDaemons. BundleProgram is relative to the bundle.
# Placement: Contents/MacOS/ is intentional per launchd.plist(5); both MacOS and
# Contents/Library/LaunchDaemons placements are valid — this repo uses MacOS for
# binary-colocation with the main executable.
cat > "$APP/Contents/Library/LaunchDaemons/$HELPER_LABEL.plist" <<PLIST
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>$HELPER_LABEL</string>
<key>BundleProgram</key>
<string>Contents/MacOS/CurtainHelper</string>
<key>MachServices</key>
<dict>
<key>$HELPER_LABEL</key>
<true/>
</dict>
<key>AssociatedBundleIdentifiers</key>
<array>
<string>$APP_ID</string>
</array>
</dict>
</plist>
PLIST
# Bake the icon at build time (the binary is the asset source via --render-icon).
echo "==> Baking app icon"
ICON_TMP="$(mktemp -d)"
ICONSET="$ICON_TMP/Curtain.iconset"
"$CURTAIN_BIN" --render-icon "$ICONSET"
iconutil -c icns "$ICONSET" -o "$APP/Contents/Resources/AppIcon.icns"
rm -rf "$ICON_TMP"
# Info.plist
cat > "$APP/Contents/Info.plist" <<PLIST
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleName</key><string>Curtain</string>
<key>CFBundleDisplayName</key><string>Curtain</string>
<key>CFBundleIdentifier</key><string>$APP_ID</string>
<key>CFBundleExecutable</key><string>Curtain</string>
<key>CFBundlePackageType</key><string>APPL</string>
<key>CFBundleInfoDictionaryVersion</key><string>6.0</string>
<key>CFBundleVersion</key><string>$BUILD_INT</string>
<key>CFBundleShortVersionString</key><string>$VERSION</string>
<key>CFBundleIconFile</key><string>AppIcon</string>
<key>CFBundleIconName</key><string>AppIcon</string>
<key>LSUIElement</key><true/>
<key>LSMinimumSystemVersion</key><string>13.0</string>
<key>LSApplicationCategoryType</key><string>public.app-category.utilities</string>
<key>NSHumanReadableCopyright</key><string>Copyright © 2026 Aric Camarata. MIT License.</string>
<key>NSPrincipalClass</key><string>NSApplication</string>
<key>NSHighResolutionCapable</key><true/>
</dict>
</plist>
PLIST
# --- c. Code sign ------------------------------------------------------------
# Sign inner-out: the helper binary first, then the app bundle. --options runtime
# opts into the Hardened Runtime; --timestamp requests a secure timestamp (this
# warns under ad-hoc signing and is harmless — a real timestamp lands once a
# Developer ID identity is used).
#
# curtain.entitlements carries NO App Sandbox key: a CGEventTap and a global
# Accessibility client cannot run sandboxed, and Curtain depends on both.
# disable-library-validation is intentionally left OFF (false): Curtain only
# loads Apple-signed frameworks (login.framework, IOKit), which pass library
# validation on their own. The file is comment-free because AMFI's entitlements
# parser rejects XML comments at sign time.
echo "==> Code signing (identity: $SIGN_IDENTITY)"
codesign --force --options runtime --timestamp \
--entitlements "$ENTITLEMENTS" \
--sign "$SIGN_IDENTITY" \
"$APP/Contents/MacOS/CurtainHelper" 2>&1 | sed 's/^/ /'
[[ ${PIPESTATUS[0]} -eq 0 ]] || { echo "ERROR: codesign failed (helper)"; exit 1; }
codesign --force --options runtime --timestamp \
--entitlements "$ENTITLEMENTS" \
--sign "$SIGN_IDENTITY" \
"$APP" 2>&1 | sed 's/^/ /'
[[ ${PIPESTATUS[0]} -eq 0 ]] || { echo "ERROR: codesign failed (app bundle)"; exit 1; }
# === NOTARIZATION SWAP (when enrolled in the Apple Developer Program) =========
# To graduate to a notarized build:
# 1. Set the identity at invocation:
# SIGN_IDENTITY="Developer ID Application: Aric Camarata (TEAMID)" ./Scripts/release.sh
# (the codesign block above already uses $SIGN_IDENTITY and the entitlements
# file, so no other signing change is needed).
# 2. Uncomment the submit + staple lines below. notarytool needs a stored
# keychain profile created once with:
# xcrun notarytool store-credentials curtain-notary \
# --apple-id "alisalaah@gmail.com" --team-id "TEAMID" --password "<app-specific-pw>"
#
# NOTARY_PROFILE="curtain-notary"
# NOTARIZE_ZIP="$DIST/Curtain-$VERSION-notarize.zip"
# echo "==> Notarizing"
# ditto -c -k --keepParent "$APP" "$NOTARIZE_ZIP"
# xcrun notarytool submit "$NOTARIZE_ZIP" --keychain-profile "$NOTARY_PROFILE" --wait
# xcrun stapler staple "$APP"
# rm -f "$NOTARIZE_ZIP"
# ==============================================================================
# --- d. Package the .dmg (drag-to-Applications layout) -----------------------
echo "==> Building $DMG"
rm -f "$DMG"
STAGE="$(mktemp -d)/Curtain"
mkdir -p "$STAGE"
cp -R "$APP" "$STAGE/Curtain.app"
ln -s /Applications "$STAGE/Applications"
# hdiutil keeps this dependency-free. create-dmg would give a prettier window,
# but a plain drag layout (app + /Applications symlink) is enough and portable.
hdiutil create -volname "Curtain $VERSION" \
-srcfolder "$STAGE" \
-ov -format UDZO \
"$DMG" >/dev/null
rm -rf "$(dirname "$STAGE")"
shasum -a 256 "$DMG" | awk '{print $1}' > "$DMG.sha256"
# --- e. Summary --------------------------------------------------------------
echo
echo "==> Done."
echo " App: $APP"
echo " DMG: $DMG"
echo " SHA-256: $(cat "$DMG.sha256") ($(basename "$DMG"))"
echo
echo "==> codesign verify:"
codesign --verify --strict --verbose=2 "$APP" 2>&1 | sed 's/^/ /' || true
echo "==> spctl assessment (ad-hoc/unnotarized will be rejected — expected):"
spctl -a -vv "$APP" 2>&1 | sed 's/^/ /' || true
echo
echo "Note: an ad-hoc build is unnotarized. End users opening the .dmg may need to"
echo "strip the quarantine flag once: xattr -dr com.apple.quarantine /Applications/Curtain.app"
echo "Enroll in the Apple Developer Program and use the NOTARIZATION SWAP block above to remove that step."

View file

@ -1,19 +1,34 @@
#!/bin/bash
# Curtain uninstaller — removes the app, login agent, and root helper.
# Curtain uninstaller. Removes /Applications/Curtain.app. The login item and
# disconnect daemon are unregistered from inside the app (SMAppService) before
# you delete it; this script also defensively cleans up files left by older
# installs (LaunchAgent, /usr/local/bin helper, sudoers rule) — only prompting
# for admin if any of those legacy files actually exist.
set -uo pipefail
AGENT="$HOME/Library/LaunchAgents/io.acamarata.curtain.plist"
LEGACY_HELPER="/usr/local/bin/curtain-endsession"
LEGACY_SUDOERS="/etc/sudoers.d/curtain-endsession"
echo "==> Stopping agent…"
launchctl unload "$AGENT" 2>/dev/null || true
rm -f "$AGENT"
echo "==> Stopping Curtain…"
pkill -x Curtain 2>/dev/null || true
# Legacy LaunchAgent (newer installs use SMAppService.mainApp instead).
if [ -f "$AGENT" ]; then
echo "==> Removing legacy login agent…"
launchctl unload "$AGENT" 2>/dev/null || true
rm -f "$AGENT"
fi
echo "==> Removing app + settings…"
rm -rf "/Applications/Curtain.app"
rm -rf "$HOME/Library/Application Support/Curtain"
echo "==> Removing root helper (needs admin)…"
osascript -e "do shell script \"rm -f /usr/local/bin/curtain-endsession /etc/sudoers.d/curtain-endsession\" with administrator privileges" || true
# Legacy root helper + sudoers rule. Only escalate if something is actually
# there, so a clean uninstall never triggers an admin prompt.
if [ -e "$LEGACY_HELPER" ] || [ -e "$LEGACY_SUDOERS" ]; then
echo "==> Removing legacy root helper (needs admin)…"
osascript -e "do shell script \"rm -f '$LEGACY_HELPER' '$LEGACY_SUDOERS'\" with administrator privileges" || true
fi
echo "✅ Curtain uninstalled. You may also remove it from System Settings → Privacy → Accessibility."
echo "✅ Curtain uninstalled. You may also remove it from System Settings → Privacy & Security → Accessibility."

View file

@ -1,9 +1,14 @@
import Foundation
/// Purpose: A composable set of lifecycle actions. Each phase (start / idle / end)
/// maps to an ActionSet; the runner performs only the enabled actions.
/// Keeping actions independent and data-driven means new behaviors are a
/// field here, not a branch scattered across the app.
/// maps to an ActionSet built from Settings; the runner performs only the
/// enabled actions. Keeping actions independent and data-driven means a new
/// behavior is a field here, not a branch scattered across the app.
/// Inputs: five booleans, all defaulting off so an empty set is a safe no-op.
/// Outputs: none (the runner mutates live curtain + system state).
/// Constraints: stored property names (disconnect, lock, screenOff,
/// deactivateCurtain, activateCurtain) are part of the contract with
/// Settings.onIdle / Settings.onEnd do not rename them.
/// SPORT: MASTER-ACTIONS
struct ActionSet {
var activateCurtain = false
@ -13,34 +18,95 @@ struct ActionSet {
var deactivateCurtain = false
}
/// Performs an ActionSet against the live curtain + system. Ordering is deliberate:
/// disconnect the operator first, deactivate/cover the screen, then lock, then sleep
/// displays last (so the lock is in place before the panels go dark).
struct ActionRunner {
/// Purpose: Perform an ActionSet against the live curtain + system. Ownership of
/// the cover lifecycle (show/hide, input tap, display-sleep assertion)
/// lives here so the coordinator stays a pure state machine.
/// Inputs: a CurtainController and an InputFilter, both owned by the coordinator.
/// Outputs: none.
/// Constraints: ordering matters for privacy. The remote must never see a bare
/// desktop frame, so when a set both reveals and disconnects, the
/// disconnect/lock run BEFORE the cover comes down. The screen-off step is
/// a cancelable work item: any new activate/deactivate/run cancels a
/// pending sleep so a stale timer can't black out a screen we just brought
/// back. Contradictory sets (activate + deactivate together) are rejected.
/// SPORT: MASTER-ACTIONS
@MainActor
final class ActionRunner {
let curtain: CurtainController
let input: InputFilter
func run(_ set: ActionSet) {
if set.activateCurtain { activateCover() }
if set.disconnect { System.endScreenShareSession() }
if set.deactivateCurtain { deactivateCover() }
if set.lock { System.lockScreen() }
if set.screenOff {
// Give the lock a beat to take hold before the displays sleep.
DispatchQueue.main.asyncAfter(deadline: .now() + 1) { System.sleepDisplays() }
}
/// Pending displays-off work, held so it can be canceled if state changes first.
private var pendingScreenOff: DispatchWorkItem?
init(curtain: CurtainController, input: InputFilter) {
self.curtain = curtain
self.input = input
}
// MARK: - Cover lifecycle
/// Bring the cover up: windows, display-sleep assertion, and the input tap.
/// If the tap can't install yet (Accessibility not granted), the cover still
/// hides the desktop visually; we leave input unblocked and retry the tap in the
/// background, flipping the cover's input-blocked state once the grant lands.
func activateCover() {
cancelPendingScreenOff()
guard !curtain.isShown else { return }
curtain.show()
System.preventDisplaySleep()
_ = input.start() // no-op result: cover still hides even without Accessibility
if input.start() {
curtain.setInputBlocked(true)
} else {
curtain.setInputBlocked(false)
input.retryUntilTrusted { [weak curtain] in curtain?.setInputBlocked(true) }
}
}
/// Take the cover down: stop any pending tap retry, tear down the tap, hide the
/// windows, and release the display-sleep assertion.
func deactivateCover() {
cancelPendingScreenOff()
input.cancelRetry()
input.stop()
curtain.hide()
System.allowDisplaySleep()
}
// MARK: - Set execution
func run(_ set: ActionSet) {
cancelPendingScreenOff()
// A set can't both reveal and conceal; honoring deactivate here would race
// the cover we were just asked to raise. Keep the cover, drop the reveal.
var set = set
if set.activateCurtain && set.deactivateCurtain {
NSLog("Curtain: contradictory ActionSet (activate + deactivate) — skipping deactivate")
set.deactivateCurtain = false
}
// Privacy ordering: raise the cover first if asked, then sever the remote
// and lock while still covered, and only then reveal the desktop. The
// remote never sees an uncovered frame.
if set.activateCurtain { activateCover() }
if set.disconnect { System.endScreenShareSession() }
if set.lock { System.lockScreen() }
if set.deactivateCurtain { deactivateCover() }
if set.screenOff { scheduleScreenOff() }
}
// MARK: - Cancelable screen-off
/// Sleep the displays after a short beat so a lock has time to take hold first.
/// Held as a work item so a later state change can cancel it.
private func scheduleScreenOff() {
let work = DispatchWorkItem { System.sleepDisplays() }
pendingScreenOff = work
DispatchQueue.main.asyncAfter(deadline: .now() + 1, execute: work)
}
private func cancelPendingScreenOff() {
pendingScreenOff?.cancel()
pendingScreenOff = nil
}
}

View file

@ -1,32 +1,54 @@
import Cocoa
/// Purpose: App entry orchestration. Owns the coordinator, the optional menu bar,
/// and the settings window. Keeps logic out of the UI: it just wires
/// callbacks between the pieces.
/// the settings window, and the onboarding window. Keeps logic out of the
/// UI: it just wires callbacks between the pieces and drives cleanup on quit.
/// Inputs: NSApplicationDelegate lifecycle callbacks; Settings/UserDefaults state.
/// Outputs: A running agent with menu bar, settings, and (first run) onboarding wired.
/// Constraints: @MainActor owns AppKit objects and SessionCoordinator (itself @MainActor).
/// cleanup() must be idempotent: it runs on quit, on SIGTERM, and on terminate.
/// SPORT: MASTER-APP
@MainActor
final class AppDelegate: NSObject, NSApplicationDelegate {
private let coordinator = SessionCoordinator()
private lazy var menuBar = MenuBarController(coordinator: coordinator)
private lazy var prefs = PreferencesWindowController(coordinator: coordinator)
let coordinator = SessionCoordinator()
lazy var menuBar = MenuBarController(coordinator: coordinator)
lazy var prefs = PreferencesWindowController(coordinator: coordinator)
lazy var onboarding = OnboardingWindowController(coordinator: coordinator)
func applicationDidFinishLaunching(_ n: Notification) {
Settings.registerDefaults()
Notifier.requestAuthorization()
System.startupLockProbe()
coordinator.onStateChange = { [weak self] active in self?.menuBar.reflect(active: active) }
coordinator.onArmedChange = { [weak self] armed in self?.menuBar.reflect(armed: armed) }
coordinator.start()
// Reconcile the optional privileged disconnect helper with its saved setting.
DisconnectClient.shared.syncWithSettings()
menuBar.onOpenSettings = { [weak self] in self?.prefs.show() }
menuBar.onOpenSetup = { [weak self] in self?.onboarding.show() }
menuBar.onQuit = { [weak self] in self?.quit() }
if Settings.showInMenuBar { menuBar.show() }
prefs.onMenuBarToggle = { [weak self] on in on ? self?.menuBar.show() : self?.menuBar.hide() }
prefs.openOnboarding = { [weak self] in self?.onboarding.show() }
// First run: no password and no menu bar would be confusing open settings.
if !Settings.hasPassword && !Settings.showInMenuBar { prefs.show() }
if !AXIsProcessTrusted() { requestAccessibility() }
// First run drives the Accessibility grant and password setup via onboarding.
// After onboarding, fall back to settings only if there's nothing to find the app by.
if !Settings.hasOnboarded {
onboarding.show()
} else if !Settings.showInMenuBar && !Settings.hasPassword {
prefs.show()
}
// Reconcile the login-item state with the saved preference.
LoginItem.set(Settings.launchAtLogin)
// Soft, non-prompting check once onboarding has happened onboarding/Settings
// surface any warning, so there's nothing to do here but note the state.
if Settings.hasOnboarded { _ = AXIsProcessTrusted() }
}
/// Re-opening the app from Finder shows the settings window.
@ -34,10 +56,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
prefs.show(); return true
}
private func quit() { coordinator.deactivateNow(); NSApp.terminate(nil) }
func applicationWillTerminate(_ notification: Notification) { cleanup() }
private func requestAccessibility() {
let opts = [kAXTrustedCheckOptionPrompt.takeUnretainedValue(): true] as CFDictionary
_ = AXIsProcessTrustedWithOptions(opts)
}
/// Idempotent teardown: drop the curtain and release input/display assertions.
/// Called on user quit, on SIGTERM (launchd / `kill`), and on app termination.
func cleanup() { coordinator.deactivateNowForQuit() }
private func quit() { cleanup(); NSApp.terminate(nil) }
}

View file

@ -0,0 +1,131 @@
import CoreGraphics
import Foundation
import os
import CurtainShared
/// Purpose: Decide whether the console session is currently being screen-captured
/// (Screen Sharing / VNC), independent of the network transport in use.
/// Inputs: none. Reads live system state via CGSession and a few short shell probes.
/// Outputs: boolean signals; `combinedCaptureActive()` is the one the monitor polls.
/// Constraints: the authoritative signal is CGSession's `CGSSessionScreenIsCaptured`
/// key, which is true only when THIS console session is being captured.
/// It is transport-independent, so it survives macOS Sequoia/26
/// High-Performance Screen Sharing (UDP, no ESTABLISHED state) and any
/// remapped port. The only other things allowed to activate the curtain
/// are network rows that REQUIRE a real foreign peer: an ESTABLISHED
/// inbound TCP connection on port 5900 (classic VNC) or a peered UDP
/// socket on 5900-5902 (the high-performance transport). Process
/// presence and LISTEN/wildcard sockets must never activate:
/// screensharingd / ScreenSharingSubscriber linger with no session, and
/// a 5900 LISTEN socket is always present whenever Screen Sharing is
/// merely enabled. Those false positives kept the curtain up overnight
/// with no session. Shell probes are cheap but block, so the monitor
/// calls these off the main thread.
/// SPORT: MASTER-CAPTUREPROBE
enum CaptureProbe {
/// CGSession dictionary key (a CFBoolean) that is true when the console session
/// is being screen-captured. This is the primary, transport-independent signal.
private static let captureKey = "CGSSessionScreenIsCaptured"
/// Primary signal. True when the current console session is being captured.
/// A different-user virtual session reports this as false in the console
/// session, which is exactly the stand-down behavior we want.
static func isConsoleScreenCaptured() -> Bool {
guard let dict = CGSessionCopyCurrentDictionary() as? [String: Any] else { return false }
guard let captured = dict[captureKey] as? Bool else { return false }
return captured
}
/// Diagnostics only. Whether a screen-sharing helper process is running. This is
/// NOT an activation signal: ScreenSharingSubscriber / screensharingd linger long
/// after a session ends (and while Screen Sharing is merely enabled), so treating
/// process presence as "active" produced overnight false positives. Kept for the
/// probe script and troubleshooting; never call it from combinedCaptureActive.
static func screenShareProcessesPresent() -> Bool {
let out = shell("/usr/bin/pgrep", ["-fl", "ScreenSharingAgent|ScreenSharingSubscriber|screensharingd"])
return !out.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
/// A genuinely ESTABLISHED inbound TCP connection on local port 5900 (a real
/// classic VNC session). A LISTEN socket is always present when Screen Sharing
/// is enabled, so it must never count we require state ESTABLISHED AND a real
/// foreign peer (not `*.*`). We match the LOCAL address column so an outbound
/// VNC client connection (this Mac controlling another) never reads as a session.
static func establishedVNC() -> Bool {
NetstatParse.hasEstablishedVNC(netstatOutput())
}
/// A UDP socket on local port 5900-5902 connected to a real foreign peer the
/// High-Performance Screen Sharing transport (macOS 14+, Apple silicon, UDP).
/// Wildcard listeners never count, so this cannot fire at rest. This is the
/// network corroborator for high-performance sessions, where there is no
/// ESTABLISHED TCP row at all.
static func peeredUDPVNC() -> Bool {
NetstatParse.hasPeeredUDPVNC(netstatOutput())
}
/// One snapshot of every activation signal, so the monitor can log exactly
/// which signal saw the session. Equatable so transitions are cheap to detect.
struct CaptureSignals: Equatable, Sendable {
let captured: Bool
let tcpEstablished: Bool
let udpPeered: Bool
var any: Bool { captured || tcpEstablished || udpPeered }
}
/// Read all signals from one netstat snapshot (netstat is the expensive probe;
/// never run it twice per tick).
static func signals() -> CaptureSignals {
let netstat = netstatOutput()
return CaptureSignals(
captured: isConsoleScreenCaptured(),
tcpEstablished: NetstatParse.hasEstablishedVNC(netstat),
udpPeered: NetstatParse.hasPeeredUDPVNC(netstat)
)
}
/// The signal the monitor consumes. The capture key is authoritative; a real
/// ESTABLISHED inbound TCP session on 5900 (classic) or a peered UDP socket on
/// 5900-5902 (high-performance) are the only other things that may activate the
/// curtain. Process presence and LISTEN sockets are ignored.
static func combinedCaptureActive() -> Bool {
signals().any
}
// MARK: - Shell
/// netstat lives in /usr/sbin on macOS (NOT /usr/bin a wrong path here once
/// silently killed the entire network corroborator: Process.run() threw, shell()
/// returned "", and every netstat-based signal read false forever).
private static func netstatOutput() -> String {
shell("/usr/sbin/netstat", ["-an"])
}
/// Paths we have already complained about, so a permanently-broken tool logs
/// once per launch instead of every 2-second tick.
private static let warnedPaths = OSAllocatedUnfairLock(initialState: Set<String>())
private static func shell(_ path: String, _ args: [String]) -> String {
let process = Process()
process.executableURL = URL(fileURLWithPath: path)
process.arguments = args
let pipe = Pipe()
process.standardOutput = pipe
process.standardError = Pipe()
do {
try process.run()
} catch {
let firstTime = warnedPaths.withLock { warned in
warned.insert(path).inserted
}
if firstTime {
Log.error("probe helper failed to launch: \(path)\(error.localizedDescription)")
}
return ""
}
let data = pipe.fileHandleForReading.readDataToEndOfFile()
process.waitUntilExit()
return String(data: data, encoding: .utf8) ?? ""
}
}

View file

@ -0,0 +1,208 @@
import Cocoa
import AVFoundation
/// Purpose: The cover backdrop for one display. Renders per Settings.coverStyle
/// (solidColor / message / blur / logo / curtainLogo / aerial), optionally
/// a live clock, and an Accessibility warning banner when desk input is not
/// blocked. The "aerial" style attaches a layer to the shared AVQueuePlayer
/// provided by CurtainController; a solid opaque base is always installed
/// first so a failed or black video never exposes the desktop beneath.
/// Inputs: aerialPlayer (optional, from CurtainController), updateClock/
/// setWarningVisible from the controller tick.
/// Outputs: none (pure display).
/// Constraints: @MainActor (AppKit). Layer state torn down in teardownAerialLayer()
/// before the window is discarded; deinit captures locals so the
/// off-main landing is safe.
/// SPORT: MASTER-CURTAIN
@MainActor
final class CoverContentView: NSView {
private var blurView: NSVisualEffectView?
private var brandIcon: NSImageView?
private let glyph = NSTextField(labelWithString: "")
private let messageLabel = NSTextField(labelWithString: "")
private let clockLabel = NSTextField(labelWithString: "")
private let warning = NSTextField(labelWithString: "")
// Aerial-video backdrop state. CoverContentView owns only its own AVPlayerLayer;
// the AVQueuePlayer and AVPlayerLooper are shared and owned by CurtainController.
// Only playerLayer is retained here; torn down in teardownAerialLayer().
private var playerLayer: AVPlayerLayer?
/// Whether this view currently has an aerial player layer attached. Used by
/// CurtainController to track how many aerial covers remain after a reconcile.
var hasAerialLayer: Bool { playerLayer != nil }
/// Designated initialiser. Pass a non-nil aerialPlayer when the current cover
/// style is "aerial"; nil for all other styles.
init(frame frameRect: NSRect, aerialPlayer: AVQueuePlayer?) {
super.init(frame: frameRect)
wantsLayer = true
autoresizingMask = [.width, .height]
build(aerialPlayer: aerialPlayer)
}
required init?(coder: NSCoder) { fatalError() }
deinit {
// deinit can land off the main actor; capture the view's own AVPlayerLayer
// and remove it on main. The shared AVQueuePlayer/AVPlayerLooper are owned
// and torn down by CurtainController, never here.
let layer = playerLayer
Task { @MainActor in
layer?.removeFromSuperlayer()
}
}
/// Stop and remove the aerial video layer (called before a window is dropped or
/// rebuilt). Idempotent. Releases the AVPlayerLayer; the shared player/looper
/// are owned by CurtainController and released there after all layers are gone.
func teardownAerialLayer() {
playerLayer?.removeFromSuperlayer()
playerLayer = nil
}
/// Re-render this view at the logo style when the async playability check
/// determines the aerial asset cannot be decoded. Tears down any existing aerial
/// layer first so the slot is clean, then applies a static logo appearance.
func applyLogoFallback() {
teardownAerialLayer()
glyph.stringValue = "🔒"
messageLabel.stringValue = "Remote Session Active"
Log.event("aerial unavailable for cover, switched to logo")
}
private func build(aerialPlayer: AVQueuePlayer?) {
let style = Settings.coverStyle
let base = color(fromHex: Settings.coverColorHex) ?? NSColor(red: 0.03, green: 0.03, blue: 0.05, alpha: 1)
layer?.backgroundColor = base.cgColor
// The aerial video renders behind every other cover element. If no shared
// player was provided (asset not found / style not aerial) the view falls
// through to the branded logo cover. An opaque base color is always set
// first so a failed or black video never exposes the desktop.
// Legacy "screensaver" configs are mapped to the safe static logo cover.
var effectiveStyle = style
if style == "screensaver" {
effectiveStyle = "logo"
} else if style == "aerial" {
if let player = aerialPlayer {
installAerialLayer(player: player)
Log.event("aerial layer attached")
effectiveStyle = "solidColor" // video is the backdrop; suppress glyph/message
} else {
Log.event("aerial unavailable, using logo")
effectiveStyle = "logo"
}
}
let renderStyle = effectiveStyle
if renderStyle == "blur" {
let v = NSVisualEffectView(frame: bounds)
v.autoresizingMask = [.width, .height]
v.material = .fullScreenUI
v.blendingMode = .behindWindow
v.state = .active
v.appearance = NSAppearance(named: .darkAqua)
addSubview(v)
blurView = v
}
// Logo glyph + tagline (logo style, and a sensible default for others).
glyph.frame = NSRect(x: 0, y: bounds.midY + 12, width: bounds.width, height: 72)
configureLabel(glyph, size: 56, weight: .thin, color: NSColor(white: 0.30, alpha: 1))
glyph.autoresizingMask = [.width, .minYMargin, .maxYMargin]
addSubview(glyph)
messageLabel.frame = NSRect(x: 0, y: bounds.midY - 40, width: bounds.width, height: 36)
configureLabel(messageLabel, size: 20, weight: .regular, color: NSColor(white: 0.50, alpha: 1))
messageLabel.autoresizingMask = [.width, .minYMargin, .maxYMargin]
addSubview(messageLabel)
switch renderStyle {
case "message":
glyph.stringValue = ""
messageLabel.stringValue = Settings.coverMessage.isEmpty ? "Remote Session Active" : Settings.coverMessage
messageLabel.font = .systemFont(ofSize: 28, weight: .light)
case "solidColor":
glyph.stringValue = ""
messageLabel.stringValue = ""
case "curtainLogo":
// Branded look: the app's own curtains artwork drawn large and centered,
// over a dark backdrop, with a quiet subtitle below it.
glyph.stringValue = ""
layer?.backgroundColor = NSColor(red: 0.03, green: 0.03, blue: 0.05, alpha: 1).cgColor
let side = min(bounds.width, bounds.height) * 0.3
let iv = NSImageView(frame: NSRect(x: (bounds.width - side) / 2,
y: bounds.midY - side / 2 + 24,
width: side, height: side))
iv.image = CurtainIcon.appIcon(size: side)
iv.imageScaling = .scaleProportionallyUpOrDown
iv.autoresizingMask = [.minXMargin, .maxXMargin, .minYMargin, .maxYMargin]
addSubview(iv)
brandIcon = iv
messageLabel.stringValue = "Locked — press your key to unlock"
default: // "logo", "blur"
glyph.stringValue = "🔒"
messageLabel.stringValue = "Remote Session Active"
}
// Live clock (centered, below the tagline). Hidden until updateClock runs.
clockLabel.frame = NSRect(x: 0, y: bounds.midY - 96, width: bounds.width, height: 30)
configureLabel(clockLabel, size: 18, weight: .regular, color: NSColor(white: 0.55, alpha: 1))
clockLabel.autoresizingMask = [.width, .minYMargin, .maxYMargin]
clockLabel.isHidden = true
addSubview(clockLabel)
// Accessibility warning banner (top, hidden until setWarningVisible(true)).
warning.frame = NSRect(x: 0, y: bounds.height - 64, width: bounds.width, height: 26)
configureLabel(warning, size: 14, weight: .semibold, color: NSColor(red: 1, green: 0.78, blue: 0.35, alpha: 1))
warning.stringValue = "Desk input not blocked — grant Accessibility in System Settings"
warning.autoresizingMask = [.width, .minYMargin]
warning.isHidden = true
addSubview(warning)
}
private func configureLabel(_ t: NSTextField, size: CGFloat, weight: NSFont.Weight, color: NSColor) {
t.alignment = .center
t.font = .systemFont(ofSize: size, weight: weight)
t.textColor = color
t.backgroundColor = .clear
t.isBezeled = false
t.isEditable = false
}
func updateClock(_ stamp: String?) {
guard let stamp else { clockLabel.isHidden = true; return }
clockLabel.stringValue = stamp
clockLabel.isHidden = false
}
func setWarningVisible(_ visible: Bool) { warning.isHidden = !visible }
private func color(fromHex hex: String) -> NSColor? {
var s = hex.trimmingCharacters(in: .whitespaces)
if s.hasPrefix("#") { s.removeFirst() }
guard s.count == 6, let v = UInt32(s, radix: 16) else { return nil }
return NSColor(red: CGFloat((v >> 16) & 0xFF) / 255,
green: CGFloat((v >> 8) & 0xFF) / 255,
blue: CGFloat(v & 0xFF) / 255, alpha: 1)
}
// MARK: - Aerial layer attachment
/// Attach an AVPlayerLayer to the shared aerial player. The layer is inserted as
/// the backmost sublayer so every label, clock, banner, and password box sit above
/// it. The opaque base color (set in build()) ensures a black or loading frame
/// never exposes the desktop the solid background is always visible under the
/// video layer. Uses aspect-fill so the video covers the full display regardless
/// of the video's native aspect ratio.
private func installAerialLayer(player: AVQueuePlayer) {
let layer = AVPlayerLayer(player: player)
layer.videoGravity = .resizeAspectFill
layer.frame = bounds
wantsLayer = true
if let host = self.layer {
layer.autoresizingMask = [.layerWidthSizable, .layerHeightSizable]
host.insertSublayer(layer, at: 0)
}
self.playerLayer = layer
}
}

View file

@ -1,150 +1,4 @@
import Cocoa
/// Purpose: The visible cover + password box on the host's physical monitors.
/// How: one borderless, max-level, opaque window per display. Native displays use
/// sharingType=.none (invisible to the remote operator, who sees the real
/// desktop); DisplayLink displays use .readOnly. Windows are click-through
/// (ignoresMouseEvents) and never key, so they never interfere with the
/// remote cursor physical input is blocked by InputFilter, not the window.
/// SPORT: MASTER-CURTAIN
final class CurtainController {
private var windows: [NSWindow] = []
private var box: PasswordBox?
var onUnlock: (() -> Void)?
var isShown: Bool { !windows.isEmpty }
func show() {
guard windows.isEmpty else { return }
for (i, screen) in NSScreen.screens.enumerated() {
let w = makeWindow(screen: screen, primary: i == 0)
windows.append(w)
}
}
func hide() {
windows.forEach { $0.orderOut(nil) }
windows.removeAll()
box = nil
}
/// Feed a physical key into the password box (from InputFilter).
func physicalKey(_ keycode: Int, _ chars: String?) {
box?.key(keycode: keycode, chars: chars)
}
/// Called once per second to auto-hide the password box after inactivity.
func tick() { box?.tick() }
private func makeWindow(screen: NSScreen, primary: Bool) -> NSWindow {
let w = CoverWindow(contentRect: screen.frame, styleMask: .borderless,
backing: .buffered, defer: false)
w.backgroundColor = .black
w.level = NSWindow.Level(rawValue: Int(CGWindowLevelForKey(.maximumWindow)))
w.collectionBehavior = [.canJoinAllSpaces, .stationary, .ignoresCycle, .fullScreenAuxiliary]
w.isOpaque = true
w.hasShadow = false
w.ignoresMouseEvents = true
w.sharingType = System.isDisplayLink(screen) ? .readOnly : .none
let content = NSView(frame: NSRect(origin: .zero, size: screen.frame.size))
content.wantsLayer = true
content.layer?.backgroundColor = NSColor(red: 0.03, green: 0.03, blue: 0.05, alpha: 1).cgColor
content.autoresizingMask = [.width, .height]
content.addSubview(centeredLabel("🔒", size: 56, y: content.bounds.midY + 12,
color: NSColor(white: 0.30, alpha: 1), width: content.bounds.width))
content.addSubview(centeredLabel("Remote Session Active", size: 20, y: content.bounds.midY - 40,
color: NSColor(white: 0.50, alpha: 1), width: content.bounds.width))
if primary {
let b = PasswordBox(frame: content.bounds)
b.isHidden = true
b.autoresizingMask = [.width, .height]
b.onSuccess = { [weak self] in self?.onUnlock?() }
content.addSubview(b)
box = b
}
w.contentView = content
w.orderFrontRegardless()
return w
}
private func centeredLabel(_ s: String, size: CGFloat, y: CGFloat, color: NSColor, width: CGFloat) -> NSTextField {
let t = NSTextField(labelWithString: s)
t.frame = NSRect(x: 0, y: y, width: width, height: size + 16)
t.alignment = .center; t.font = .systemFont(ofSize: size, weight: .thin)
t.textColor = color; t.backgroundColor = .clear; t.isBezeled = false; t.isEditable = false
t.autoresizingMask = [.width, .minYMargin, .maxYMargin]
return t
}
}
/// A window that never becomes key, so it can never steal focus from the remote session.
private final class CoverWindow: NSWindow {
override var canBecomeKey: Bool { false }
override var canBecomeMain: Bool { false }
}
/// Purpose: the on-curtain unlock box. Keystrokes arrive from InputFilter (physical
/// keyboard), never via normal responder chain, so it works while the
/// curtain stays click-through and non-key.
final class PasswordBox: NSView {
var onSuccess: (() -> Void)?
private let dots = NSTextField(labelWithString: "")
private let err = NSTextField(labelWithString: "")
private var buffer = ""
private var hideAt: TimeInterval = 0
override init(frame: NSRect) { super.init(frame: frame); build() }
required init?(coder: NSCoder) { fatalError() }
private func build() {
let pw = 380.0, ph = 196.0
let box = NSView(frame: NSRect(x: (frame.width - pw) / 2, y: (frame.height - ph) / 2, width: pw, height: ph))
box.wantsLayer = true
box.layer?.backgroundColor = NSColor(white: 0.10, alpha: 0.98).cgColor
box.layer?.cornerRadius = 16
addSubview(box)
func label(_ s: String, _ y: Double, _ sz: Double, _ c: NSColor) -> NSTextField {
let t = NSTextField(labelWithString: s)
t.frame = NSRect(x: 10, y: y, width: pw - 20, height: 34); t.alignment = .center
t.textColor = c; t.backgroundColor = .clear; t.isBezeled = false; t.isEditable = false
t.font = .systemFont(ofSize: sz, weight: .medium); box.addSubview(t); return t
}
_ = label("🔒", 144, 38, .white)
_ = label("Enter password", 120, 14, NSColor(white: 0.85, alpha: 1))
let field = NSView(frame: NSRect(x: 90, y: 82, width: 200, height: 30))
field.wantsLayer = true; field.layer?.backgroundColor = NSColor(white: 0.20, alpha: 1).cgColor
field.layer?.cornerRadius = 6; box.addSubview(field)
dots.frame = NSRect(x: 90, y: 84, width: 200, height: 26); dots.alignment = .center
dots.textColor = .white; dots.backgroundColor = .clear; dots.isBezeled = false; dots.isEditable = false
dots.font = .systemFont(ofSize: 18); box.addSubview(dots)
err.frame = NSRect(x: 10, y: 56, width: pw - 20, height: 18); err.alignment = .center
err.textColor = NSColor(red: 1, green: 0.4, blue: 0.4, alpha: 1); err.backgroundColor = .clear
err.isBezeled = false; err.isEditable = false; err.font = .systemFont(ofSize: 12); err.isHidden = true
box.addSubview(err)
_ = label("Return to unlock · Esc to dismiss", 24, 12, NSColor(white: 0.42, alpha: 1))
}
private func bump() { hideAt = Date().timeIntervalSince1970 + 6 }
func tick() { if !isHidden && Date().timeIntervalSince1970 > hideAt { isHidden = true } }
func key(keycode: Int, chars: String?) {
if isHidden { buffer = ""; dots.stringValue = ""; err.isHidden = true; isHidden = false }
bump()
switch keycode {
case 36, 76: // Return / Enter
if Settings.verify(buffer) { onSuccess?() }
else { buffer = ""; dots.stringValue = ""; err.stringValue = "Wrong password"; err.isHidden = false }
case 53: // Esc
isHidden = true
case 51: // Delete
if !buffer.isEmpty { buffer.removeLast() }
dots.stringValue = String(repeating: "", count: buffer.count); err.isHidden = true
default:
if let c = chars, let ch = c.first, ch.isLetter || ch.isNumber || ch.isPunctuation || ch.isSymbol {
buffer += c
dots.stringValue = String(repeating: "", count: buffer.count); err.isHidden = true
}
}
}
}
// This file has been split into:
// CurtainController.swift CurtainController + CoverWindow (shared aerial player)
// CoverContentView.swift CoverContentView (aerial layer attachment)
// PasswordBox.swift PasswordBox (on-curtain unlock UI)

View file

@ -0,0 +1,479 @@
import Cocoa
import ScreenCaptureKit
import AVFoundation
import CurtainShared
/// Purpose: Owns all cover windows on the host's physical monitors, plus the shared
/// aerial AVQueuePlayer/AVPlayerLooper. One borderless, max-level, opaque
/// window per display is keyed by display UUID so topology changes are
/// reconciled by identity, not array index. Native displays use sharingType
/// .none (invisible to the remote operator); DisplayLink displays use
/// .readOnly. Windows are click-through (ignoresMouseEvents) and never key so
/// they never interfere with the remote cursor. Physical input is blocked by
/// InputFilter, not by this window. Cover scope, appearance, password-box
/// placement, and new-display policy are driven by Settings with a fail-safe
/// bias: when in doubt, cover the display.
/// Inputs: physicalKey (from InputFilter), tick (1 Hz), setInputBlocked (from the
/// coordinator's Accessibility check).
/// Outputs: onUnlock on a correct password.
/// Constraints: AppKit is main-actor-isolated under Swift 6 so the whole class is
/// @MainActor. Every timer and observer is torn down in hide(); closures
/// use [weak self] to avoid retain cycles.
/// SPORT: MASTER-CURTAIN
@MainActor
final class CurtainController {
/// One cover window bound to a physical display, tracked by its stable UUID.
private struct Cover {
let uuid: String
let window: NSWindow
var isPasswordHost: Bool
}
private var covers: [String: Cover] = [:]
private var box: PasswordBox?
private var clockTimer: Timer?
private var screenObserver: NSObjectProtocol?
private var reconcileWork: DispatchWorkItem?
private var inputBlocked = true
// Shared aerial player one instance for the whole curtain session so all cover
// displays loop the same asset in lock-step without each paying the decode cost.
// Each CoverContentView attaches its own AVPlayerLayer to this player. Nilled in
// hide() after per-cover teardown, and kept alive in reconcile() while any aerial
// cover remains.
private var aerialPlayer: AVQueuePlayer?
private var aerialLooper: AVPlayerLooper?
var onUnlock: (() -> Void)?
var isShown: Bool { !covers.isEmpty }
// MARK: - Lifecycle
func show() {
guard covers.isEmpty else { return }
System.preventDisplaySleep()
// Build the shared aerial player once before creating cover windows so
// installAerialLayer() can attach a layer synchronously in makeCover().
let style = Settings.coverStyle
if style == "aerial" {
buildSharedAerialPlayer()
}
for screen in NSScreen.screens {
guard let uuid = uuidKey(for: screen) else { continue }
if shouldCover(uuid: uuid, isNew: false) {
covers[uuid] = makeCover(screen: screen, uuid: uuid)
}
}
ensurePasswordBox()
startClockIfNeeded()
Log.event("cover shown: style=\(Settings.coverStyle) displays=\(covers.count)")
screenObserver = NotificationCenter.default.addObserver(
forName: NSApplication.didChangeScreenParametersNotification,
object: nil, queue: .main
) { [weak self] _ in
Task { @MainActor in self?.scheduleReconcile() }
}
// Best-effort regression check that a .none cover is excluded from capture.
CurtainController.verifyNoneCoverHidden { ok in
if !ok { NSLog("Curtain: SCK self-test — a .none cover was visible in capture (regression)") }
}
}
func hide() {
if let token = screenObserver {
NotificationCenter.default.removeObserver(token)
screenObserver = nil
}
reconcileWork?.cancel()
reconcileWork = nil
clockTimer?.invalidate()
clockTimer = nil
covers.values.forEach {
($0.window.contentView as? CoverContentView)?.teardownAerialLayer()
$0.window.orderOut(nil)
}
covers.removeAll()
// Release the shared player only after all layers have been removed so no
// AVPlayerLayer outlives the player that backs it.
aerialLooper = nil
aerialPlayer = nil
box = nil
System.allowDisplaySleep()
}
/// Feed a physical key into the password box (from InputFilter). Gating: while the
/// box is hidden, only reveal it on any key (when configured) or on the user's
/// reveal combo. Once the box is visible, every key passes through so the user can
/// type the password without re-hitting the combo for each character.
func physicalKey(_ keycode: Int, _ chars: String?, _ flags: UInt64) {
guard let b = box else { return }
if b.isHidden {
let allow = Settings.revealOnAnyKey
|| RevealCombo.matches(combo: Settings.revealKeyCombo, keycode: keycode, chars: chars, flagsRawValue: flags)
guard allow else { return }
}
b.key(keycode: keycode, chars: chars)
}
/// Called once per second to auto-hide the password box after inactivity.
func tick() { box?.tick() }
/// Coordinator reports whether desk input is actually being blocked. When
/// false, every cover shows a warning banner; when true, banners clear.
func setInputBlocked(_ blocked: Bool) {
if !blocked { Log.event("input-not-blocked banner shown") }
inputBlocked = blocked
for cover in covers.values {
(cover.window.contentView as? CoverContentView)?.setWarningVisible(!blocked)
}
}
// MARK: - Shared aerial player
/// Build the single AVQueuePlayer + AVPlayerLooper used by all aerial covers. The
/// looper drives gapless, seamless repeat so individual cover views only need to
/// attach a layer. If the asset fails the async playability check, all aerial
/// layers are torn down and every cover re-renders at the logo style.
private func buildSharedAerialPlayer() {
guard let url = CurtainController.findAerialVideo() else {
NSLog("Curtain: no aerial video found in any known path; using logo cover")
return
}
let item = AVPlayerItem(url: url)
let queue = AVQueuePlayer()
queue.isMuted = true
queue.actionAtItemEnd = .advance
let looper = AVPlayerLooper(player: queue, templateItem: item)
aerialPlayer = queue
aerialLooper = looper
queue.play()
// Asynchronously verify the asset is actually decodable. If not, tear down
// all aerial layers and fall back to logo so no cover ever shows a black frame.
// The Task is keyed to the player it validated: a topology rebuild can replace
// the shared player while this is in flight, and a stale verdict must never
// tear down the replacement.
let asset = item.asset
Task { @MainActor [weak self] in
let playable = (try? await asset.load(.isPlayable)) ?? false
guard let self, self.aerialPlayer === queue else { return }
if !playable {
NSLog("Curtain: aerial asset not playable (\(url.lastPathComponent)); switching to logo cover")
self.teardownAerialAndSwitchToLogo()
}
}
}
/// Called when the async playability check fails. Removes aerial layers from every
/// cover and rebuilds them at the logo style, then releases the shared player.
private func teardownAerialAndSwitchToLogo() {
for cover in covers.values {
(cover.window.contentView as? CoverContentView)?.teardownAerialLayer()
(cover.window.contentView as? CoverContentView)?.applyLogoFallback()
}
aerialLooper = nil
aerialPlayer = nil
}
/// Provide the shared player to a cover view that is being built. Returns nil when
/// no aerial player is active (style is not aerial, or asset failed to load).
func sharedAerialPlayer() -> AVQueuePlayer? { aerialPlayer }
// MARK: - Topology reconcile
private func scheduleReconcile() {
reconcileWork?.cancel()
let work = DispatchWorkItem { [weak self] in
Task { @MainActor in self?.reconcile() }
}
reconcileWork = work
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3, execute: work)
}
private func reconcile() {
guard !covers.isEmpty else { return }
let beforeCount = covers.count
var liveByUUID: [String: NSScreen] = [:]
for screen in NSScreen.screens {
if let uuid = uuidKey(for: screen) { liveByUUID[uuid] = screen }
}
// Drop covers for displays that vanished.
for (uuid, cover) in covers where liveByUUID[uuid] == nil {
(cover.window.contentView as? CoverContentView)?.teardownAerialLayer()
cover.window.orderOut(nil)
covers.removeValue(forKey: uuid)
}
// Release the shared aerial player if no aerial covers remain after drops.
let anyAerialRemains = covers.values.contains {
($0.window.contentView as? CoverContentView)?.hasAerialLayer == true
}
if !anyAerialRemains {
aerialLooper = nil
aerialPlayer = nil
}
// Update survivors and add covers for newly-attached displays.
for (uuid, screen) in liveByUUID {
if covers[uuid] != nil {
let window = covers[uuid]!.window
window.setFrame(screen.frame, display: true)
window.sharingType = System.isDisplayLink(screen) ? .readOnly : .none
} else if shouldCover(uuid: uuid, isNew: true) {
// Re-build the shared player if aerial style was active before and
// we still have other aerial covers running.
if Settings.coverStyle == "aerial" && aerialPlayer == nil {
buildSharedAerialPlayer()
}
covers[uuid] = makeCover(screen: screen, uuid: uuid, forceNewDisplay: true)
}
}
ensurePasswordBox()
startClockIfNeeded()
Log.event("reconcile: displays now \(covers.count) (was \(beforeCount))")
// Reapply any active warning banner to freshly-built covers.
if !inputBlocked { setInputBlocked(false) }
}
// MARK: - Cover-scope decision
/// Decide whether a given display should be covered, honoring scope, the
/// per-display disable list, and (for mid-session arrivals) the new-display
/// policy. Two modes: "all" (default, fail-safe) and "perDisplay" (honor the
/// per-display Cover toggle ON = covered, OFF = uncovered). Legacy values
/// "onlyMarked"/"allExceptMarked" map to "perDisplay" semantics so the toggle
/// means what it says. Unknown or missing scope values default to "all" because
/// an exposed desk is the failure mode we must never reach.
private func shouldCover(uuid: String, isNew: Bool) -> Bool {
if isNew {
switch Settings.newDisplayPolicy {
case "leaveUncovered": return false
case "treatAsDisplayLink": return true // covered as .readOnly
default: return true // "cover"
}
}
let disabled = Settings.perDisplayCoverDisabled.contains(uuid)
switch Settings.coverScope {
case "perDisplay",
"onlyMarked",
"allExceptMarked":
return !disabled // ON = covered; per-display toggle drives this
default:
return true // "all" every display covered regardless of toggle
}
}
// MARK: - Window construction
private func makeCover(screen: NSScreen, uuid: String, forceNewDisplay: Bool = false) -> Cover {
let w = CoverWindow(contentRect: screen.frame, styleMask: .borderless,
backing: .buffered, defer: false)
w.backgroundColor = .black
w.level = NSWindow.Level(rawValue: Int(CGWindowLevelForKey(.maximumWindow)))
w.collectionBehavior = [.canJoinAllSpaces, .stationary, .ignoresCycle, .fullScreenAuxiliary]
w.isOpaque = true
w.hasShadow = false
w.ignoresMouseEvents = true
// A new display under "treatAsDisplayLink" is forced to .readOnly so we
// never assume a fresh, unrecognized panel is hardware-private.
let treatAsLink = forceNewDisplay && Settings.newDisplayPolicy == "treatAsDisplayLink"
w.sharingType = (System.isDisplayLink(screen) || treatAsLink) ? .readOnly : .none
let content = CoverContentView(frame: NSRect(origin: .zero, size: screen.frame.size),
aerialPlayer: aerialPlayer)
w.contentView = content
w.orderFrontRegardless()
return Cover(uuid: uuid, window: w, isPasswordHost: false)
}
// MARK: - Password box placement
/// Guarantee exactly one reachable password box, placed per the configured
/// policy. Recreates/moves the box if its host display vanished on reconcile.
private func ensurePasswordBox() {
guard !covers.isEmpty else { box = nil; return }
let targetUUID = passwordHostUUID()
// If the box already lives on the right host, keep it.
if let host = covers.first(where: { $0.value.isPasswordHost }), host.key == targetUUID {
return
}
// Clear the old host flag + remove the old box.
if let oldKey = covers.first(where: { $0.value.isPasswordHost })?.key {
covers[oldKey]?.isPasswordHost = false
}
box?.removeFromSuperview()
box = nil
guard let cover = covers[targetUUID],
let content = cover.window.contentView as? CoverContentView else { return }
let b = PasswordBox(frame: content.bounds)
b.isHidden = true
b.autoresizingMask = [.width, .height]
b.onSuccess = { [weak self] in self?.onUnlock?() }
content.addSubview(b)
box = b
covers[targetUUID]?.isPasswordHost = true
}
/// Resolve which display hosts the password box for the current placement
/// mode, always falling back to a display that actually has a cover.
private func passwordHostUUID() -> String {
let fallback = primaryCoveredUUID() ?? covers.keys.first ?? ""
switch Settings.passwordBoxPlacement {
case "all":
return fallback // "all" still anchors one interactive box; banners cover the rest
case "specific":
let wanted = Settings.passwordBoxSpecificUUID
return covers[wanted] != nil ? wanted : fallback
case "primary":
return primaryCoveredUUID() ?? fallback
default: // "followActive"
return activeCoveredUUID() ?? fallback
}
}
private func primaryCoveredUUID() -> String? {
if let main = NSScreen.screens.first, let uuid = uuidKey(for: main), covers[uuid] != nil {
return uuid
}
return covers.keys.first
}
/// The display under the mouse, else the focused screen whichever is covered.
private func activeCoveredUUID() -> String? {
let mouse = NSEvent.mouseLocation
if let hit = NSScreen.screens.first(where: { $0.frame.contains(mouse) }),
let uuid = uuidKey(for: hit), covers[uuid] != nil {
return uuid
}
if let main = NSScreen.main, let uuid = uuidKey(for: main), covers[uuid] != nil {
return uuid
}
return primaryCoveredUUID()
}
// MARK: - Live clock
private func startClockIfNeeded() {
let want = Settings.coverShowClock
if !want {
clockTimer?.invalidate(); clockTimer = nil
covers.values.forEach { ($0.window.contentView as? CoverContentView)?.updateClock(nil) }
return
}
guard clockTimer == nil else { tickClock(); return }
let t = Timer(timeInterval: 1.0, repeats: true) { [weak self] _ in
Task { @MainActor in self?.tickClock() }
}
RunLoop.main.add(t, forMode: .common)
clockTimer = t
tickClock()
}
private func tickClock() {
let f = DateFormatter()
f.dateFormat = "EEEE, MMMM d h:mm a"
let stamp = f.string(from: Date())
covers.values.forEach { ($0.window.contentView as? CoverContentView)?.updateClock(stamp) }
}
// MARK: - UUID helper
private func uuidKey(for screen: NSScreen) -> String? { System.uuid(of: screen) }
// MARK: - Aerial asset search
/// Locate a readable aerial `.mov`, first readable wins. Searches the current
/// wallpaper aerials directory, then the 4K SDR idle-asset catalog, then a shallow
/// scan of every Customer subdirectory. Returns nil when nothing readable exists.
/// Emits an NSLog when all candidate directories are exhausted so the caller can
/// decide to fall back to the logo style.
static func findAerialVideo() -> URL? {
let fm = FileManager.default
let home = NSHomeDirectory()
// 1) Current wallpaper aerial videos.
let wallpaperDir = "\(home)/Library/Application Support/com.apple.wallpaper/aerials/videos"
if let url = firstMov(in: wallpaperDir, fm: fm) { return url }
// 2) The standard 4K SDR 240fps idle-asset catalog.
let sdrDir = "/Library/Application Support/com.apple.idleassetsd/Customer/4KSDR240FPS"
if let url = firstMov(in: sdrDir, fm: fm) { return url }
// 3) Shallow scan of every Customer subdirectory for any readable .mov.
let customerDir = "/Library/Application Support/com.apple.idleassetsd/Customer"
if let subdirs = try? fm.contentsOfDirectory(atPath: customerDir) {
for sub in subdirs.sorted() {
if let url = firstMov(in: "\(customerDir)/\(sub)", fm: fm) { return url }
}
}
// All candidate paths exhausted caller uses logo fallback.
NSLog("Curtain: no aerial video found in any known path; using logo cover")
return nil
}
/// Return the first readable `.mov` directly inside a directory, sorted for a
/// stable choice across launches. nil if the directory is missing or has none.
private static func firstMov(in dir: String, fm: FileManager) -> URL? {
guard let entries = try? fm.contentsOfDirectory(atPath: dir) else { return nil }
for name in entries.sorted() where name.hasSuffix(".mov") {
let path = "\(dir)/\(name)"
if fm.isReadableFile(atPath: path) { return URL(fileURLWithPath: path) }
}
return nil
}
// MARK: - SCK self-test
/// Capture the main display via ScreenCaptureKit and confirm a `.none` cover
/// is excluded from the shareable content. SCK omits `.none`-shared windows,
/// so the heuristic is: if any of our windows are still reported on-screen in
/// the shareable window list, that's a regression. Best-effort and off the
/// main thread; falls back to a logged stub if SCK content is unavailable.
static func verifyNoneCoverHidden(completion: @escaping @Sendable (Bool) -> Void) {
if #available(macOS 12.3, *) {
SCShareableContent.getWithCompletionHandler { content, error in
guard let content, error == nil else {
NSLog("Curtain: SCK self-test not run (\(error?.localizedDescription ?? "no content"))")
completion(true)
return
}
let pid = ProcessInfo.processInfo.processIdentifier
// A correctly-hidden .none cover is absent from the shareable
// window list. If any of our windows still show up, warn.
let ourVisible = content.windows.contains {
$0.owningApplication?.processID == pid && $0.isOnScreen
}
completion(!ourVisible)
}
} else {
NSLog("Curtain: SCK self-test not run (requires macOS 12.3+)")
completion(true)
}
}
}
/// A window that never becomes key, so it can never steal focus from the remote
/// session. Input is blocked by InputFilter, not by this window grabbing events.
final class CoverWindow: NSWindow {
override var canBecomeKey: Bool { false }
override var canBecomeMain: Bool { false }
}

View file

@ -0,0 +1,353 @@
import Foundation
import AppKit
import ServiceManagement
import Security
import CurtainShared
/// Purpose: App-side controller for the optional privileged disconnect helper, with
/// two install paths so it works on BOTH a notarized build and a local
/// ad-hoc/unsigned build.
/// 1. SMAppService.daemon the future, notarized path (XPC to the daemon).
/// 2. A sudoers helper script the fallback for ad-hoc/local installs,
/// where SMAppService.daemon().register() refuses to run.
/// When the feature is on it installs the `System.disconnectHandler` closure
/// that ends the active remote Screen Sharing session via whichever path is
/// actually available.
/// Inputs: `Settings.disconnectFeatureEnabled`, the toggle from settings/onboarding.
/// Outputs: Daemon registration OR sudoers helper install/removal; a set/cleared
/// `System.disconnectHandler`.
/// Constraints: @MainActor (touches SMAppService + app state). The handler closure
/// runs on a background queue (System invokes it off-main), and the
/// sudo/XPC work happens there too never on the main actor. Install and
/// removal are idempotent per OS-admin-prompt-hygiene: state-check before
/// prompting, never loop a denied prompt.
///
/// Why two paths: SMAppService.daemon requires a Developer ID signature + a registered
/// LaunchDaemon, which an ad-hoc build does not have, so `register()` throws and the
/// disconnect would silently no-op. The sudoers fallback grants the one privileged
/// action we need (killing the root-owned Screen Sharing processes) via a single
/// NOPASSWD rule scoped to the CURRENT USER only not the whole admin group so the
/// blast radius is one fixed script for one account. The sudoers path is for ad-hoc /
/// local installs ONLY; the notarized build uses SMAppService.daemon.
/// SPORT: MASTER-DISCONNECT
@MainActor
final class DisconnectClient {
static let shared = DisconnectClient()
private init() {}
private var daemon: SMAppService {
SMAppService.daemon(plistName: CurtainHelperInfo.daemonPlistName)
}
// Fallback sudoers helper paths (ad-hoc/local installs only).
private static let helperScriptPath = "/usr/local/bin/curtain-endsession"
private static let sudoersFilePath = "/etc/sudoers.d/curtain-endsession"
/// True when disconnect will actually work: either the SMAppService daemon is
/// registered, or the sudoers helper script is installed and executable. The UI
/// reads this so it can tell the user whether enabling did anything.
var isHelperAvailable: Bool {
daemon.status == .enabled || isSudoersHelperInstalled
}
private var isSudoersHelperInstalled: Bool {
FileManager.default.isExecutableFile(atPath: Self.helperScriptPath)
}
// MARK: - Sudoers-is-dev-only rule
//
// The sudoers fallback writes a NOPASSWD rule into /etc/sudoers.d. That is an
// acceptable convenience for a developer running a locally-built, ad-hoc/unsigned
// app on their own machine but it must NEVER ship inside a notarized public
// build, where it would be a privilege-escalation footgun. The hard rule:
//
// - A properly-signed build (real Developer-ID / Team ID, not ad-hoc) uses the
// SMAppService.daemon path ONLY. If that fails it reports the failure and stops.
// - The sudoers fallback is reachable ONLY when isProperlySigned() == false, i.e.
// an ad-hoc / dev / unsigned build that genuinely cannot register a daemon.
//
// isProperlySigned() inspects the running app's own code signature. An ad-hoc
// signature carries no Team ID and sets the adhoc flag; a real Developer-ID
// signature carries a Team ID and clears that flag. We treat "has a Team ID and is
// not ad-hoc" as properly signed.
/// True only for a real (Developer-ID / non-ad-hoc) signature on the running app.
/// Used to gate the sudoers fallback so a notarized public build can never install
/// a sudoers rule. Fails closed (returns true blocks the fallback) only when the
/// signing info is unreadable AND a Team ID is present; an unreadable signature with
/// no Team ID is treated as unsigned/ad-hoc so local dev still works.
static func isProperlySigned() -> Bool {
var codeRef: SecCode?
guard SecCodeCopySelf([], &codeRef) == errSecSuccess, let code = codeRef else {
return false
}
var staticRef: SecStaticCode?
guard SecCodeCopyStaticCode(code, [], &staticRef) == errSecSuccess,
let staticCode = staticRef else {
return false
}
var infoRef: CFDictionary?
guard SecCodeCopySigningInformation(staticCode,
SecCSFlags(rawValue: kSecCSSigningInformation),
&infoRef) == errSecSuccess,
let info = infoRef as? [String: Any] else {
return false
}
// An ad-hoc signature sets the adhoc bit in the code-signing flags.
if let flags = info[kSecCodeInfoFlags as String] as? UInt32 {
let adhocFlag: UInt32 = 0x2 // kSecCodeSignatureAdhoc
if flags & adhocFlag != 0 { return false }
}
// A real Developer-ID signature carries a Team ID; ad-hoc signatures do not.
guard let teamID = info[kSecCodeInfoTeamIdentifier as String] as? String,
!teamID.isEmpty else {
return false
}
return true
}
/// Turn the feature on or off, picking the install path that works on this build.
/// On: try SMAppService first (notarized path); fall back to the sudoers helper if
/// it throws or doesn't end up enabled (ad-hoc path). Off: tear down whichever is
/// present. Idempotent throughout.
func setEnabled(_ on: Bool) {
if on {
var daemonOK = false
do {
if daemon.status != .enabled {
try daemon.register()
NSLog("Curtain: disconnect helper registered via SMAppService")
}
daemonOK = (daemon.status == .enabled)
} catch {
NSLog("Curtain: SMAppService daemon register failed (%@) — using sudoers fallback",
String(describing: error))
}
if !daemonOK {
// Sudoers fallback is dev-only: reachable solely on an ad-hoc/unsigned
// build. A properly-signed build that still couldn't register the daemon
// must NOT touch sudoers report instead.
if Self.isProperlySigned() {
NSLog("Curtain: SMAppService daemon unavailable on a signed build — not installing sudoers fallback")
Notifier.post(title: "Curtain",
body: "The disconnect helper could not be installed. Try reinstalling Curtain or check System Settings > Login Items.",
throttleKey: "disconnect-helper",
throttleSeconds: 60)
} else {
installSudoersHelper()
}
}
} else {
do {
if daemon.status == .enabled {
try daemon.unregister()
NSLog("Curtain: disconnect helper unregistered")
}
} catch {
NSLog("Curtain: SMAppService daemon unregister failed: %@", String(describing: error))
}
removeSudoersHelper()
System.disconnectHandler = nil
}
syncWithSettings()
}
/// Reconcile the live `System.disconnectHandler` with the persisted setting and the
/// path that is actually available: privileged XPC if the daemon is enabled, else
/// the sudoers helper if installed, else nothing.
func syncWithSettings() {
guard Settings.disconnectFeatureEnabled else {
System.disconnectHandler = nil
return
}
if daemon.status == .enabled {
System.disconnectHandler = { Self.callHelper() }
} else if isSudoersHelperInstalled {
System.disconnectHandler = { Self.runSudoEndSession() }
} else {
// Helper not available: never leave the handler nil, or disconnect
// requests vanish with no feedback. Tell the user how to fix it instead.
System.disconnectHandler = { Self.notifyHelperNeeded() }
}
}
/// Disconnect was requested but no privileged helper is installed. Surface a
/// throttled user notification (at most once per ~60s, handled by Notifier) so the
/// user knows the disconnect did nothing and where to enable it. Called off-main
/// via the handler.
static func notifyHelperNeeded() {
Log.event("disconnect requested but helper not enabled")
let body = "To disconnect the remote session, turn on the disconnect helper in Settings > Disconnect."
Notifier.post(title: "Curtain",
body: body,
throttleKey: "disconnect-helper",
throttleSeconds: 60)
NSLog("Curtain: disconnect requested but helper not enabled — %@", body)
}
// MARK: - SMAppService (notarized) path
/// Open a one-shot privileged XPC connection, ask the helper to disconnect, and
/// tear the connection down. Runs on the background queue System dispatches to.
///
/// Handlers are installed BEFORE resume() so no race can deliver an interruption
/// or invalidation event to an unregistered handler. A once-flag guards against
/// the semaphore being over-signaled if both handlers fire on a broken connection
/// (over-signaling DispatchSemaphore is safe; this is defensive hygiene).
private static func callHelper() {
Log.event("disconnect via XPC daemon")
let conn = NSXPCConnection(machServiceName: CurtainHelperInfo.machServiceName,
options: .privileged)
conn.remoteObjectInterface = NSXPCInterface(with: CurtainDisconnectXPC.self)
let done = DispatchSemaphore(value: 0)
// nonisolated(unsafe) is not needed here the once flag is only accessed
// from the XPC queue that serialises these two callbacks.
var signaled = false
let signalOnce = {
if !signaled { signaled = true; done.signal() }
}
// Interruption means the helper crashed or the connection dropped mid-call.
conn.interruptionHandler = {
NSLog("Curtain: disconnect XPC connection interrupted")
signalOnce()
}
// Invalidation fires on any terminal failure (bad service name, rejected, etc.).
conn.invalidationHandler = {
NSLog("Curtain: disconnect XPC connection invalidated")
signalOnce()
}
conn.resume()
// Guard the cast: if the proxy is nil or doesn't conform, there is nothing
// to call and the timeout would burn 5 s for no reason.
guard let proxy = conn.remoteObjectProxyWithErrorHandler({ error in
NSLog("Curtain: disconnect XPC error: %@", String(describing: error))
signalOnce()
}) as? CurtainDisconnectXPC else {
NSLog("Curtain: disconnect XPC proxy cast failed — invalidating")
conn.invalidate()
return
}
proxy.endScreenSharingSession { ok in
Log.event("disconnect result: \(ok ? "matched" : "no match")")
NSLog("Curtain: helper ended session: %@", ok ? "matched" : "no match")
signalOnce()
}
_ = done.wait(timeout: .now() + 5)
conn.invalidate()
}
// MARK: - Sudoers (ad-hoc/local) fallback path
/// Install the privileged helper script + a NOPASSWD sudoers rule scoped to the
/// current user, via a SINGLE admin prompt. Idempotent: if both files already
/// exist and the sudoers entry validates, skip the prompt entirely.
private func installSudoersHelper() {
if isSudoersHelperInstalled
&& FileManager.default.fileExists(atPath: Self.sudoersFilePath) {
NSLog("Curtain: sudoers disconnect helper already installed — skipping prompt")
syncWithSettings()
return
}
let user = NSUserName()
// launchd respawns the listener, so Screen Sharing stays available afterward.
let script = """
#!/bin/bash
pkill -f ScreenSharingSubscriber
pkill -x screensharingd
pkill -f "RemoteManagement.*[Ss]creen"
exit 0
"""
let sudoersLine = "\(user) ALL=(root) NOPASSWD: \(Self.helperScriptPath)"
// base64 the script so quoting/newlines survive the osascript shell hop intact.
let scriptB64 = Data(script.utf8).base64EncodedString()
let sudoersB64 = Data(sudoersLine.utf8).base64EncodedString()
let install = """
/bin/mkdir -p /usr/local/bin && \
printf '%s' '\(scriptB64)' | /usr/bin/base64 -D -o '\(Self.helperScriptPath)' && \
/bin/chmod 755 '\(Self.helperScriptPath)' && \
printf '%s' '\(sudoersB64)' | /usr/bin/base64 -D -o '\(Self.sudoersFilePath)' && \
/bin/chmod 440 '\(Self.sudoersFilePath)' && \
/usr/sbin/visudo -cf '\(Self.sudoersFilePath)'
"""
if runAdminShell(install) {
NSLog("Curtain: sudoers disconnect helper installed for user %@", user)
} else {
NSLog("Curtain: sudoers disconnect helper install failed")
}
syncWithSettings()
}
/// Remove both helper files via one admin prompt only when at least one exists.
private func removeSudoersHelper() {
let fm = FileManager.default
guard fm.fileExists(atPath: Self.helperScriptPath)
|| fm.fileExists(atPath: Self.sudoersFilePath) else { return }
let remove = "/bin/rm -f '\(Self.helperScriptPath)' '\(Self.sudoersFilePath)'"
if runAdminShell(remove) {
NSLog("Curtain: sudoers disconnect helper removed")
} else {
NSLog("Curtain: sudoers disconnect helper removal failed")
}
}
/// Run a shell command once with administrator privileges (one OS password prompt).
/// Returns true on exit status 0. Never loops on a denied prompt.
private func runAdminShell(_ command: String) -> Bool {
let escaped = command
.replacingOccurrences(of: "\\", with: "\\\\")
.replacingOccurrences(of: "\"", with: "\\\"")
let osa = "do shell script \"\(escaped)\" with administrator privileges"
let task = Process()
task.executableURL = URL(fileURLWithPath: "/usr/bin/osascript")
task.arguments = ["-e", osa]
do {
try task.run()
task.waitUntilExit()
return task.terminationStatus == 0
} catch {
NSLog("Curtain: admin shell launch failed: %@", String(describing: error))
return false
}
}
/// Invoke the installed sudoers helper with `sudo -n` (no prompt the NOPASSWD
/// rule covers it) on a background queue, with a timeout, never blocking main.
/// Called off-main via `System.disconnectHandler`.
static func runSudoEndSession() {
Log.event("disconnect via sudo helper")
DispatchQueue.global(qos: .userInitiated).async {
let task = Process()
task.executableURL = URL(fileURLWithPath: "/usr/bin/sudo")
task.arguments = ["-n", helperScriptPath]
do {
try task.run()
// Bounded wait: poll for exit so a hung process can't pin the queue.
let waitDeadline = Date().addingTimeInterval(5)
while task.isRunning && Date() < waitDeadline {
usleep(50_000)
}
if task.isRunning {
task.terminate()
NSLog("Curtain: sudo end-session timed out — terminated")
} else {
NSLog("Curtain: sudo end-session exited %d", task.terminationStatus)
}
} catch {
NSLog("Curtain: sudo end-session launch failed: %@", String(describing: error))
}
}
}
}

View file

@ -0,0 +1,127 @@
import Cocoa
import Carbon.HIToolbox
import os.log
/// Purpose: A last-resort, always-available "take the cover down" hotkey that works
/// even when Accessibility is NOT granted. Wraps Carbon `RegisterEventHotKey`,
/// which (unlike a CGEventTap or NSEvent global monitor) needs no Accessibility
/// permission, so it remains the guaranteed escape if anything ever traps the
/// screen at the desk.
/// Inputs: register(_:) takes a () -> Void handler invoked on the main actor when the
/// fixed combo Control+Option+Command+U is pressed.
/// Outputs: none directly it drives the stored handler.
/// Constraints: Carbon hotkey APIs are C. The event handler MUST be a top-level C
/// function (no Swift context capture), so the Swift handler is held in a
/// static registry keyed by hotkey id and the C callback hops to the main
/// actor via DispatchQueue.main.async before calling it. Single instance is
/// assumed; the static registry keys by signature+id to stay correct anyway.
/// SPORT: MASTER-EMERGENCYHOTKEY
@MainActor
final class EmergencyHotkey {
/// Fixed combo: Control+Option+Command+U. U keycode = 32 (kVK_ANSI_U).
private static let keyCode: UInt32 = 32
private static let modifiers: UInt32 = UInt32(controlKey | optionKey | cmdKey)
private static let signature: OSType = 0x4355_5254 // 'CURT'
private static let hotKeyID: UInt32 = 1
/// Bridge store: Carbon C callbacks can't capture Swift context, so the handler
/// lives here keyed by hotkey id and the C trampoline looks it up.
private static var handlers: [UInt32: () -> Void] = [:]
private var hotKeyRef: EventHotKeyRef?
private var eventHandlerRef: EventHandlerRef?
init() {}
/// Install the global hotkey and store the handler. Idempotent: a second call
/// unregisters the prior registration first.
func register(_ handler: @escaping () -> Void) {
unregister()
EmergencyHotkey.handlers[EmergencyHotkey.hotKeyID] = handler
var eventType = EventTypeSpec(
eventClass: OSType(kEventClassKeyboard),
eventKind: UInt32(kEventHotKeyPressed)
)
let status = InstallEventHandler(
GetApplicationEventTarget(),
emergencyHotkeyHandler,
1,
&eventType,
nil,
&eventHandlerRef
)
guard status == noErr else {
NSLog("Curtain: emergency hotkey handler install failed (\(status))")
return
}
let id = EventHotKeyID(signature: EmergencyHotkey.signature, id: EmergencyHotkey.hotKeyID)
let regStatus = RegisterEventHotKey(
EmergencyHotkey.keyCode,
EmergencyHotkey.modifiers,
id,
GetApplicationEventTarget(),
0,
&hotKeyRef
)
if regStatus == noErr {
os_log("Curtain: emergency hotkey registered (Control+Option+Command+U)")
} else {
NSLog("Curtain: emergency hotkey registration failed (\(regStatus))")
}
}
/// Tear down the hotkey and clear the stored handler.
func unregister() {
if let ref = hotKeyRef {
UnregisterEventHotKey(ref)
hotKeyRef = nil
}
if let ref = eventHandlerRef {
RemoveEventHandler(ref)
eventHandlerRef = nil
}
EmergencyHotkey.handlers[EmergencyHotkey.hotKeyID] = nil
}
deinit {
if let ref = hotKeyRef { UnregisterEventHotKey(ref) }
if let ref = eventHandlerRef { RemoveEventHandler(ref) }
}
/// Called by the C trampoline (in a nonisolated context) when the combo fires.
/// Hops to the main actor, looks up the stored handler by id, and runs it. The
/// `handlers` read happens inside the main-actor hop, so it's isolation-safe.
nonisolated fileprivate static func fire(id: UInt32) {
DispatchQueue.main.async {
MainActor.assumeIsolated {
handlers[id]?()
}
}
}
}
/// Top-level C event handler. Carbon hands us the hotkey id; we forward it to the
/// Swift bridge, which hops to the main actor. No Swift context is captured here.
private func emergencyHotkeyHandler(
_ nextHandler: EventHandlerCallRef?,
_ event: EventRef?,
_ userData: UnsafeMutableRawPointer?
) -> OSStatus {
guard let event else { return OSStatus(eventNotHandledErr) }
var hkID = EventHotKeyID()
let status = GetEventParameter(
event,
EventParamName(kEventParamDirectObject),
EventParamType(typeEventHotKeyID),
nil,
MemoryLayout<EventHotKeyID>.size,
nil,
&hkID
)
guard status == noErr else { return status }
EmergencyHotkey.fire(id: hkID.id)
return noErr
}

View file

@ -5,61 +5,173 @@ import Cocoa
/// remote operator controls normally.
/// How: a CGEventTap inspects each event's source-state. Physical hardware events
/// report sourceStateID == 1 (kCGEventSourceStateHIDSystemState); Screen
/// Sharing injects synthetic events with a large per-session state ID (!= 1).
/// Verified empirically (see Lessons). Block ==1, pass everything else.
/// Constraints: requires Accessibility permission. Physical key-downs are routed
/// to `onPhysicalKey` to drive the password box.
/// Sharing injects synthetic events with a per-session state ID (!= 1).
/// Block ==1 (and route physical key-downs to the password box), pass the rest.
/// Honesty: this is a CONVENIENCE filter, not a security boundary. Any local process
/// can post events with an arbitrary sourceStateID, so injected input can be made
/// to look "remote" and slip past. It keeps a person at the desk from interfering;
/// it does not stop hostile local code. Some HID paths may also bypass a session
/// tap entirely (documented residual).
/// Inputs: `onPhysicalKey(keycode, characters, flags)` fires on the main thread for
/// each physical key-down, where flags is the CGEvent modifier mask raw value.
/// `start()`/`stop()`/`ensureActive()`/retry helpers drive it.
/// Outputs: returns false from start() when the tap can't be created (no Accessibility);
/// `isTapInstalled` lets the coordinator surface that to the user.
/// Constraints: requires Accessibility permission. Tap callback runs on a dedicated tap
/// thread; all Cocoa work is hopped to main. Pinned to the main run loop.
/// SPORT: MASTER-INPUTFILTER
@MainActor
final class InputFilter {
private var tap: CFMachPort?
private var runLoopSource: CFRunLoopSource?
var onPhysicalKey: ((Int, String?) -> Void)?
private var watchdog: Timer?
private var retryTimer: Timer?
/// Returns false if the tap could not be created (missing Accessibility).
/// Fired on the main thread for every physical key-down: (keycode, characters,
/// modifier-flags raw value). The flags let the controller match a reveal combo.
var onPhysicalKey: ((Int, String?, UInt64) -> Void)?
/// True once a tap is live. Coordinator shows an "Accessibility needed" warning
/// when this stays false after start().
var isTapInstalled: Bool { tap != nil }
// physicalStateID lives in the nonisolated extension below so the C-callback
// can reach it without an actor hop. See InputFilter extension at end of file.
/// Install the tap on the main run loop. Returns false if the tap could not be
/// created (missing Accessibility), so the caller can prompt or retry.
@discardableResult
func start() -> Bool {
assert(Thread.isMainThread, "InputFilter.start() must run on the main thread")
if tap != nil { return true }
let types: [CGEventType] = [.keyDown, .keyUp, .flagsChanged, .mouseMoved,
.leftMouseDown, .leftMouseUp, .rightMouseDown, .rightMouseUp,
.otherMouseDown, .otherMouseUp, .leftMouseDragged, .rightMouseDragged, .scrollWheel]
let mask: CGEventMask = types.reduce(CGEventMask(0)) { $0 | (CGEventMask(1) << $1.rawValue) }
.otherMouseDown, .otherMouseUp, .leftMouseDragged, .rightMouseDragged,
.scrollWheel]
// CGEventType has no .systemDefined case, but the underlying tap accepts its raw
// value (14 == NX_SYSDEFINED). Including it blocks physical media / brightness /
// volume / Mission-Control keys too. Some lower-level HID paths can still bypass a
// session tap; that residual is accepted.
let systemDefinedMask: CGEventMask = CGEventMask(1) << 14
let mask: CGEventMask = types.reduce(systemDefinedMask) { $0 | (CGEventMask(1) << $1.rawValue) }
let me = Unmanaged.passUnretained(self).toOpaque()
guard let t = CGEvent.tapCreate(tap: .cgSessionEventTap, place: .headInsertEventTap,
options: .defaultTap, eventsOfInterest: mask, callback: callback, userInfo: me) else {
Log.event("event tap NOT installed (Accessibility?)")
return false
}
tap = t
let src = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, t, 0)
runLoopSource = src
CFRunLoopAddSource(CFRunLoopGetCurrent(), src, .commonModes)
// Pin to the main run loop so the tap fires no matter which thread called start().
CFRunLoopAddSource(CFRunLoopGetMain(), src, .commonModes)
CGEvent.tapEnable(tap: t, enable: true)
startWatchdog()
Log.event("event tap installed")
return true
}
func stop() {
cancelRetry()
watchdog?.invalidate(); watchdog = nil
if let t = tap { CGEvent.tapEnable(tap: t, enable: false) }
if let s = runLoopSource { CFRunLoopRemoveSource(CFRunLoopGetCurrent(), s, .commonModes) }
if let s = runLoopSource { CFRunLoopRemoveSource(CFRunLoopGetMain(), s, .commonModes) }
tap = nil; runLoopSource = nil
}
fileprivate func handlePhysicalKeyDown(_ event: CGEvent) {
let kc = Int(event.getIntegerValueField(.keyboardEventKeycode))
let chars = NSEvent(cgEvent: event)?.charactersIgnoringModifiers
DispatchQueue.main.async { self.onPhysicalKey?(kc, chars) }
/// Re-enable the tap if the system disabled it (timeout / heavy input). Safe to call
/// repeatedly; backs the watchdog and any coordinator-driven retry.
func ensureActive() {
guard let t = tap else { return }
if !CGEvent.tapIsEnabled(tap: t) { CGEvent.tapEnable(tap: t, enable: true) }
}
fileprivate func reenable() { if let t = tap { CGEvent.tapEnable(tap: t, enable: true) } }
/// While the tap is NOT installed, poll Accessibility trust ~every 2s and retry
/// start() once it flips true. `onSuccess` fires exactly once after install.
func retryUntilTrusted(onSuccess: @escaping () -> Void) {
if isTapInstalled { onSuccess(); return }
cancelRetry()
retryTimer = Timer.scheduledTimer(withTimeInterval: 2.0, repeats: true) { [weak self] timer in
Task { @MainActor in
guard let self else { timer.invalidate(); return }
if self.isTapInstalled { self.cancelRetry(); onSuccess(); return }
if AXIsProcessTrusted(), self.start() {
Log.event("event tap installed after Accessibility grant")
self.cancelRetry(); onSuccess()
}
}
}
}
/// Stop the trust-polling loop started by retryUntilTrusted.
func cancelRetry() {
retryTimer?.invalidate(); retryTimer = nil
}
// Nudge a disabled tap back to life without caller intervention.
private func startWatchdog() {
watchdog?.invalidate()
watchdog = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
Task { @MainActor in self?.ensureActive() }
}
}
// Builds the Cocoa key string on the main thread, off the tap thread.
// Guard tap != nil: both this method and stop() are @MainActor, so the check
// is race-correct. If stop() ran before this async hop landed, discard the key
// rather than delivering it to an already-torn-down handler.
fileprivate func deliverPhysicalKey(keyCode: Int, characters: String?, flags: UInt64) {
guard tap != nil else { return }
onPhysicalKey?(keyCode, characters, flags)
}
fileprivate func reenableFromCallback() {
ensureActive()
}
}
// Top-level C callback. A CGEventTapCallBack is a bare C function pointer: it can't
// capture context, so the InputFilter is recovered from `refcon`. Only cheap field
// reads happen here; any Cocoa work is hopped to main.
private let callback: CGEventTapCallBack = { _, type, event, refcon in
let filter = Unmanaged<InputFilter>.fromOpaque(refcon!).takeUnretainedValue()
guard let refcon else { return Unmanaged.passUnretained(event) }
let unmanaged = Unmanaged<InputFilter>.fromOpaque(refcon)
// The system disables the tap on timeout or under input load. Re-enable and let the
// notification event pass through untouched (do not return nil for these types).
if type == .tapDisabledByTimeout || type == .tapDisabledByUserInput {
filter.reenable()
DispatchQueue.main.async { unmanaged.takeUnretainedValue().reenableFromCallback() }
return Unmanaged.passUnretained(event)
}
let physical = (event.getIntegerValueField(.eventSourceStateID) == 1)
if physical {
if type == .keyDown { filter.handlePhysicalKeyDown(event) }
return nil // block hardware input from apps
// Cheap read only. Physical hardware reports sourceStateID == 1.
let physical = event.getIntegerValueField(.eventSourceStateID) == InputFilter.physicalStateIDValue
guard physical else {
return Unmanaged.passUnretained(event) // remote / injected input: pass through
}
return Unmanaged.passUnretained(event) // pass remote (synthetic) input
// Physical input: block it from apps. Route key-downs to the password box, resolving
// the unicode cheaply on the tap thread and hopping value types to main.
if type == .keyDown {
let keyCode = Int(event.getIntegerValueField(.keyboardEventKeycode))
var length = 0
var buffer = [UniChar](repeating: 0, count: 4)
event.keyboardGetUnicodeString(maxStringLength: 4, actualStringLength: &length, unicodeString: &buffer)
let chars = length > 0 ? String(utf16CodeUnits: buffer, count: length) : nil
// Cheap modifier read on the tap thread; a plain UInt64 crosses to main safely.
let flags = event.flags.rawValue
DispatchQueue.main.async {
unmanaged.takeUnretainedValue().deliverPhysicalKey(keyCode: keyCode, characters: chars, flags: flags)
}
}
return nil
}
extension InputFilter {
// Both constants live here (nonisolated context) so the C-callback can read them
// without crossing actor isolation. physicalStateID is the single source of truth;
// physicalStateIDValue is the name exposed at the call site in the callback.
fileprivate static let physicalStateID: Int64 = 1
fileprivate nonisolated static var physicalStateIDValue: Int64 { physicalStateID }
}

27
Sources/Curtain/Log.swift Normal file
View file

@ -0,0 +1,27 @@
import os
/// Purpose: One-line diagnostic logging for live testing, gated by the
/// diagnostics-logging setting so it stays silent in normal use.
/// Inputs: a short, greppable message string. Never pass secrets or passwords.
/// Outputs: a `.public` os.Logger line under subsystem io.acamarata.curtain so it
/// shows in `log stream` / Console without redaction. No-op when disabled.
/// Constraints: only log state transitions, never per-keystroke or per-tick events,
/// to avoid spam. os.Logger is Sendable, so the static instance is safe
/// under Swift 6 strict concurrency.
/// SPORT: MASTER-LOG
enum Log {
private static let logger = Logger(subsystem: "io.acamarata.curtain", category: "curtain")
static func event(_ message: String) {
guard Settings.diagnosticsLoggingEnabled else { return }
logger.log("CURTAIN \(message, privacy: .public)") // .public so it shows in log stream
}
/// Unconditional error logging NOT gated by the diagnostics setting. Reserved
/// for failures that would otherwise be silent (e.g. a detection probe that can
/// no longer launch its helper tool). Errors are rare by definition, so this
/// never spams; callers must still rate-limit anything that can repeat.
static func error(_ message: String) {
logger.error("CURTAIN ERROR \(message, privacy: .public)")
}
}

View file

@ -1,12 +1,15 @@
import Cocoa
/// Purpose: Optional menu-bar presence (the curtains glyph) with quick actions.
/// Can be shown/hidden per the "Show in menu bar" setting.
/// Reflects active + armed state and routes actions to the coordinator.
/// SPORT: MASTER-MENUBAR
@MainActor
final class MenuBarController: NSObject {
private var statusItem: NSStatusItem?
private var armedItem: NSMenuItem?
private weak var coordinator: SessionCoordinator?
var onOpenSettings: (() -> Void)?
var onOpenSetup: (() -> Void)?
var onQuit: (() -> Void)?
init(coordinator: SessionCoordinator) { self.coordinator = coordinator; super.init() }
@ -17,7 +20,9 @@ final class MenuBarController: NSObject {
item.button?.image = CurtainIcon.menuBarImage()
let menu = NSMenu()
add(menu, "Open Curtain Settings…", #selector(openSettings))
add(menu, "Setup…", #selector(openSetup))
menu.addItem(.separator())
armedItem = add(menu, "Armed", #selector(toggleArmed))
add(menu, "Activate Now", #selector(activate))
add(menu, "Deactivate", #selector(deactivate))
add(menu, "Test Curtain (10s)", #selector(test))
@ -26,11 +31,13 @@ final class MenuBarController: NSObject {
item.menu = menu
statusItem = item
reflect(active: coordinator?.isActive ?? false)
reflect(armed: coordinator?.isArmed ?? false)
}
func hide() {
if let i = statusItem { NSStatusBar.system.removeStatusItem(i) }
statusItem = nil
armedItem = nil
}
/// Update the icon to reflect active/idle. Active = highlighted (non-template).
@ -42,15 +49,31 @@ final class MenuBarController: NSObject {
button.contentTintColor = active ? NSColor.systemRed : nil
}
/// Update the Armed menu item state and the icon tooltip.
func reflect(armed: Bool) {
armedItem?.state = armed ? .on : .off
statusItem?.button?.toolTip = armed ? "Armed" : "Disarmed"
}
// MARK: - Actions
private func add(_ menu: NSMenu, _ title: String, _ sel: Selector, key: String = "") {
@discardableResult
private func add(_ menu: NSMenu, _ title: String, _ sel: Selector, key: String = "") -> NSMenuItem {
let item = NSMenuItem(title: title, action: sel, keyEquivalent: key)
item.target = self; menu.addItem(item)
item.target = self; menu.addItem(item); return item
}
@objc private func openSettings() { onOpenSettings?() }
@objc private func openSetup() { onOpenSetup?() }
@objc private func toggleArmed() {
guard let coordinator else { return }
coordinator.setArmed(!coordinator.isArmed)
reflect(armed: coordinator.isArmed)
}
@objc private func activate() { coordinator?.activateNow() }
@objc private func deactivate() { coordinator?.deactivateNow() }
@objc private func test() { coordinator?.testCurtain() }
@objc private func deactivate() {
// If gated, the coordinator already presented the password box; nothing else to do.
_ = coordinator?.requestDeactivateFromMenu()
}
@objc private func test() { coordinator?.testCurtain(seconds: 10) }
@objc private func quit() { onQuit?() }
}

View file

@ -0,0 +1,53 @@
import Foundation
import UserNotifications
/// Purpose: Thin wrapper over UNUserNotificationCenter for the app's banners. Replaces
/// the deprecated NSUserNotification path with the modern UserNotifications API.
/// Inputs: title/body strings; optional throttleKey + window to suppress repeats.
/// Outputs: an immediate (nil-trigger) user notification, or nothing when throttled.
/// Constraints: @MainActor touches shared throttle state and the notification center
/// from a single context to satisfy Swift 6 strict concurrency. Every call is
/// defensive: a missing bundle id or unavailable center must never crash the
/// agent, so failures are logged and swallowed. Authorization is best-effort.
/// SPORT: MASTER-NOTIFIER
@MainActor
enum Notifier {
/// Last post time per throttle key, used to suppress rapid repeats.
private static var lastPost: [String: Date] = [:]
/// Ask once (at launch) for permission to show banners and play sounds. The result
/// is ignored: if the user declines, post(...) simply becomes a no-op silently.
static func requestAuthorization() {
guard Bundle.main.bundleIdentifier != nil else {
NSLog("Curtain: skipping notification authorization — no bundle identifier")
return
}
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound]) { _, error in
if let error { NSLog("Curtain: notification authorization failed: \(error.localizedDescription)") }
}
}
/// Post an immediate banner. When `throttleKey` is set and `throttleSeconds > 0`,
/// repeats inside the window are dropped. Safe to call from anywhere; hops to main.
static func post(title: String, body: String, throttleKey: String? = nil, throttleSeconds: TimeInterval = 0) {
Task { @MainActor in
guard Bundle.main.bundleIdentifier != nil else { return }
if let key = throttleKey, throttleSeconds > 0 {
let now = Date()
if let last = lastPost[key], now.timeIntervalSince(last) < throttleSeconds { return }
lastPost[key] = now
}
let content = UNMutableNotificationContent()
content.title = title
content.body = body
let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil)
UNUserNotificationCenter.current().add(request) { error in
if let error { NSLog("Curtain: failed to post notification: \(error.localizedDescription)") }
}
}
}
}

View file

@ -0,0 +1,270 @@
import AppKit
import SwiftUI
/// Purpose: First-run onboarding flow that walks a brand-new user from download
/// to a working setup with no documentation: a five-step SwiftUI window
/// (welcome, accessibility grant, optional disconnect helper, optional
/// password, finish).
/// Inputs: a SessionCoordinator (for the disconnect-helper install) at init.
/// Outputs: side effects only. On finish it sets Settings.hasOnboarded = true,
/// applies LoginItem.set(Settings.launchAtLogin), and closes the window.
/// Constraints: AppKit + SwiftUI are @MainActor. The Accessibility step polls
/// AXIsProcessTrusted() on a ~1s timer; that timer is invalidated
/// when the user leaves step 2 or the window closes, so it never
/// leaks. The window is reused if show() is called again and is not
/// released on close so the controller can re-present it.
/// SPORT: MASTER-ONBOARDING
@MainActor
final class OnboardingWindowController {
private let coordinator: SessionCoordinator
private var window: NSWindow?
init(coordinator: SessionCoordinator) {
self.coordinator = coordinator
}
/// Present the onboarding window, building it on first use and reusing it after.
func show() {
if let window {
window.makeKeyAndOrderFront(nil)
NSApp.activate(ignoringOtherApps: true)
return
}
let view = OnboardingView(
enableDisconnectHelper: { [coordinator] on in
coordinator.enableDisconnectHelper(on)
},
finish: { [weak self] in
self?.completeOnboarding()
}
)
let hosting = NSHostingController(rootView: view)
let win = NSWindow(contentViewController: hosting)
win.title = "Welcome to Curtain"
win.styleMask = [.titled, .closable]
win.isReleasedWhenClosed = false
win.setContentSize(NSSize(width: 460, height: 460))
win.center()
self.window = win
win.makeKeyAndOrderFront(nil)
NSApp.activate(ignoringOtherApps: true)
}
private func completeOnboarding() {
Settings.hasOnboarded = true
LoginItem.set(Settings.launchAtLogin)
window?.close()
}
}
// MARK: - View
/// The multi-step onboarding content. Holds the current step and the live
/// accessibility-trust flag; the parent controller supplies the two closures
/// that reach into the coordinator and finish the flow.
private struct OnboardingView: View {
let enableDisconnectHelper: (Bool) -> Void
let finish: () -> Void
@State private var step: Step = .welcome
@State private var axTrusted: Bool = AXIsProcessTrusted()
@State private var axTimer: Timer?
@State private var disconnectOn = false
@State private var password = ""
@State private var passwordSaved = false
private enum Step: Int { case welcome, accessibility, disconnect, password, finish }
var body: some View {
VStack(spacing: 0) {
header
Divider()
content
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding(24)
}
.frame(width: 460, height: 460)
.onDisappear { stopAXPoll() }
}
private var header: some View {
HStack(spacing: 12) {
Image(nsImage: CurtainIcon.appIcon(size: 40))
.resizable()
.frame(width: 40, height: 40)
Text("Curtain")
.font(.title2.weight(.semibold))
Spacer()
}
.padding(.horizontal, 24)
.padding(.vertical, 16)
}
@ViewBuilder
private var content: some View {
switch step {
case .welcome: welcomeStep
case .accessibility: accessibilityStep
case .disconnect: disconnectStep
case .password: passwordStep
case .finish: finishStep
}
}
// MARK: Step 1 Welcome
private var welcomeStep: some View {
VStack(alignment: .leading, spacing: 20) {
Text("Hide your screen while you work remotely")
.font(.title3.weight(.semibold))
Text("Curtain covers your screen and blocks the keyboard and mouse at your desk while you remote in. It locks or sleeps the Mac when the session goes idle or disconnects.")
.foregroundStyle(.secondary)
Spacer()
HStack {
Spacer()
Button("Continue") { step = .accessibility }
.keyboardShortcut(.defaultAction)
}
}
}
// MARK: Step 2 Accessibility (required)
private var accessibilityStep: some View {
VStack(alignment: .leading, spacing: 16) {
Text("Allow Curtain to block desk input")
.font(.title3.weight(.semibold))
Text("Curtain needs Accessibility permission so it can capture the keyboard and mouse at your desk. Without it, your screen can be covered but input is not blocked.")
.foregroundStyle(.secondary)
HStack(spacing: 8) {
Circle()
.fill(axTrusted ? Color.green : Color.red)
.frame(width: 10, height: 10)
Text(axTrusted ? "Permission granted" : "Permission not granted yet")
.font(.callout)
}
Button("Open Accessibility Settings") { openAXSettings() }
Spacer()
HStack {
Button("Skip for now") {
stopAXPoll()
step = .disconnect
}
.help("Curtain will not be able to block keyboard and mouse input.")
Spacer()
Button("Continue") {
stopAXPoll()
step = .disconnect
}
.keyboardShortcut(.defaultAction)
.disabled(!axTrusted)
}
}
.onAppear { startAXPoll() }
}
// MARK: Step 3 Disconnect helper (optional, off)
private var disconnectStep: some View {
VStack(alignment: .leading, spacing: 16) {
Text("Disconnect the remote session (optional)")
.font(.title3.weight(.semibold))
Toggle(isOn: $disconnectOn) {
Text("Also disconnect the remote session on idle or end")
}
Text("This needs a one-time admin approval to install a small helper. Most people do not need it. Curtain still locks or sleeps the Mac without it.")
.foregroundStyle(.secondary)
.font(.callout)
Spacer()
HStack {
Spacer()
Button("Continue") {
if disconnectOn { enableDisconnectHelper(true) }
step = .password
}
.keyboardShortcut(.defaultAction)
}
}
}
// MARK: Step 4 Password (optional)
private var passwordStep: some View {
VStack(alignment: .leading, spacing: 16) {
Text("Set an unlock password (optional)")
.font(.title3.weight(.semibold))
Text("This password unlocks the screen at your desk. If you skip it, the default is \"curtain\" and you can change it later in Settings.")
.foregroundStyle(.secondary)
.font(.callout)
HStack {
SecureField("Password", text: $password)
.textFieldStyle(.roundedBorder)
Button("Set") {
Settings.setPassword(password)
passwordSaved = true
}
.disabled(password.isEmpty)
}
if passwordSaved {
Text("Password set.")
.font(.callout)
.foregroundStyle(.green)
}
Spacer()
HStack {
Button("Skip") { step = .finish }
Spacer()
Button("Continue") { step = .finish }
.keyboardShortcut(.defaultAction)
}
}
}
// MARK: Step 5 Finish
private var finishStep: some View {
VStack(alignment: .leading, spacing: 16) {
Text("You're all set")
.font(.title3.weight(.semibold))
Text("Curtain runs in the menu bar and starts at login. Open the menu bar icon any time to arm it or change settings.")
.foregroundStyle(.secondary)
Spacer()
HStack {
Spacer()
Button("Finish") { finish() }
.keyboardShortcut(.defaultAction)
}
}
}
// MARK: AX polling
private func startAXPoll() {
axTrusted = AXIsProcessTrusted()
axTimer?.invalidate()
axTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in
Task { @MainActor in axTrusted = AXIsProcessTrusted() }
}
}
private func stopAXPoll() {
axTimer?.invalidate()
axTimer = nil
}
private func openAXSettings() {
// macOS 13+ Privacy pane URL; the legacy ?Privacy_Accessibility query-string form
// stopped reliably opening the Accessibility row on Ventura+ and is dropped here.
let url = URL(string: "x-apple.systempreferences:com.apple.Privacy-Accessibility-Settings")!
NSWorkspace.shared.open(url)
}
}

View file

@ -0,0 +1,113 @@
import Cocoa
/// Purpose: The on-curtain unlock box. Keystrokes arrive from InputFilter
/// (physical keyboard), never via the normal responder chain, so it
/// works while the curtain stays click-through and non-key. Respects the
/// shared Settings lockout backoff and auto-hides after inactivity.
/// Inputs: key() from CurtainController.physicalKey(), tick() at 1 Hz.
/// Outputs: onSuccess closure when the correct password is entered.
/// Constraints: @MainActor. Buffer is zeroed on every state transition (success,
/// failure, lockout, Esc, initial reveal) so the plaintext credential
/// never lingers in memory longer than necessary.
/// SPORT: MASTER-CURTAIN
@MainActor
final class PasswordBox: NSView {
var onSuccess: (() -> Void)?
private let dots = NSTextField(labelWithString: "")
private let err = NSTextField(labelWithString: "")
private let prompt = NSTextField(labelWithString: "Enter password")
private var buffer = ""
private var hideAt: TimeInterval = 0
override init(frame: NSRect) { super.init(frame: frame); build() }
required init?(coder: NSCoder) { fatalError() }
private func build() {
let pw = 380.0, ph = 196.0
let box = NSView(frame: NSRect(x: (frame.width - pw) / 2, y: (frame.height - ph) / 2, width: pw, height: ph))
box.wantsLayer = true
box.layer?.backgroundColor = NSColor(white: 0.10, alpha: 0.98).cgColor
box.layer?.cornerRadius = 16
box.autoresizingMask = [.minXMargin, .maxXMargin, .minYMargin, .maxYMargin]
addSubview(box)
func label(_ s: String, _ y: Double, _ sz: Double, _ c: NSColor) -> NSTextField {
let t = NSTextField(labelWithString: s)
t.frame = NSRect(x: 10, y: y, width: pw - 20, height: 34); t.alignment = .center
t.textColor = c; t.backgroundColor = .clear; t.isBezeled = false; t.isEditable = false
t.font = .systemFont(ofSize: sz, weight: .medium); box.addSubview(t); return t
}
_ = label("🔒", 144, 38, .white)
prompt.frame = NSRect(x: 10, y: 120, width: pw - 20, height: 34); prompt.alignment = .center
prompt.textColor = NSColor(white: 0.85, alpha: 1); prompt.backgroundColor = .clear
prompt.isBezeled = false; prompt.isEditable = false; prompt.font = .systemFont(ofSize: 14, weight: .medium)
box.addSubview(prompt)
let field = NSView(frame: NSRect(x: 90, y: 82, width: 200, height: 30))
field.wantsLayer = true; field.layer?.backgroundColor = NSColor(white: 0.20, alpha: 1).cgColor
field.layer?.cornerRadius = 6; box.addSubview(field)
dots.frame = NSRect(x: 90, y: 84, width: 200, height: 26); dots.alignment = .center
dots.textColor = .white; dots.backgroundColor = .clear; dots.isBezeled = false; dots.isEditable = false
dots.font = .systemFont(ofSize: 18); box.addSubview(dots)
err.frame = NSRect(x: 10, y: 56, width: pw - 20, height: 18); err.alignment = .center
err.textColor = NSColor(red: 1, green: 0.4, blue: 0.4, alpha: 1); err.backgroundColor = .clear
err.isBezeled = false; err.isEditable = false; err.font = .systemFont(ofSize: 12); err.isHidden = true
box.addSubview(err)
_ = label("Return to unlock · Esc to dismiss", 24, 12, NSColor(white: 0.42, alpha: 1))
}
private func bump() { hideAt = Date().timeIntervalSince1970 + Double(Settings.passwordBoxTimeoutSeconds) }
func tick() {
guard !isHidden else { return }
// While locked out, keep the box up and count the backoff down.
if Settings.isLockedOut { showLockout(); return }
if Date().timeIntervalSince1970 > hideAt { isHidden = true }
}
func key(keycode: Int, chars: String?) {
if isHidden { buffer = ""; dots.stringValue = ""; err.isHidden = true; isHidden = false }
bump()
if Settings.isLockedOut { showLockout(); return }
switch keycode {
case 36, 76: // Return / Enter
if Settings.verify(buffer) {
Settings.resetFailedAttempts()
// Zero the buffer and dot display before calling back so the
// plaintext credential doesn't linger while the curtain comes down.
buffer = ""
dots.stringValue = ""
onSuccess?()
} else {
Settings.registerFailedAttempt()
buffer = ""; dots.stringValue = ""
if Settings.isLockedOut { showLockout() }
else { err.stringValue = "Wrong password"; err.isHidden = false }
}
case 53: // Esc
// Clear the buffer and dots before hiding so a partial password attempt
// is not left in memory or on-screen if the box is re-revealed quickly.
buffer = ""
dots.stringValue = ""
isHidden = true
case 51: // Delete
if !buffer.isEmpty { buffer.removeLast() }
dots.stringValue = String(repeating: "", count: buffer.count); err.isHidden = true
default:
if let c = chars, let ch = c.first, ch.isLetter || ch.isNumber || ch.isPunctuation || ch.isSymbol {
buffer += c
dots.stringValue = String(repeating: "", count: buffer.count); err.isHidden = true
}
}
}
private func showLockout() {
let secs = Int(ceil(Settings.backoffRemaining))
buffer = ""; dots.stringValue = ""
err.stringValue = "Try again in \(secs)s"
err.isHidden = false
}
}

View file

@ -0,0 +1,55 @@
import SwiftUI
/// Purpose: Advanced tab diagnostics toggle, re-open onboarding, settings export /
/// import / reset, and version footer.
/// Extracted from PreferencesView to keep every tab file under 500 lines.
/// Inputs: @AppStorage for diagnostics; injected closures for the three settings-file
/// actions and the onboarding re-open; a Binding for hasPassword so Reset can
/// refresh it without coupling to the parent's @State directly.
/// Outputs: writes to UserDefaults (via the closures); no direct coordinator calls.
/// Constraints: @MainActor (SwiftUI). Reset/Export/Import are defined in the parent
/// PreferencesView and passed in as closures so the exportableKeys list stays
/// in one place.
/// SPORT: MASTER-PREFS
struct PrefAdvancedTab: View {
@AppStorage(Settings.Key.diagnosticsLoggingEnabled) private var diagnosticsLogging = false
let openOnboarding: () -> Void
let exportSettings: () -> Void
let importSettings: () -> Void
let resetToDefaults: () -> Void
var body: some View {
Form {
Section("Diagnostics") {
Toggle("Enable diagnostics logging", isOn: $diagnosticsLogging)
}
Section("Setup") {
Button("Open Setup…", action: openOnboarding)
}
Section("Settings file") {
HStack {
Button("Export…", action: exportSettings)
Button("Import…", action: importSettings)
}
Button("Reset to Defaults", role: .destructive, action: resetToDefaults)
}
Section {
HStack(spacing: 10) {
Image(nsImage: CurtainIcon.appIcon(size: 28))
.resizable().frame(width: 28, height: 28)
VStack(alignment: .leading, spacing: 1) {
Text("Curtain \(appVersion)").font(.callout).bold()
Text("Privacy for macOS Screen Sharing")
.font(.caption).foregroundStyle(.secondary)
}
}
}
}
.formStyle(.grouped)
}
private var appVersion: String {
Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "1.0.0"
}
}

View file

@ -0,0 +1,81 @@
import SwiftUI
import AppKit
import CurtainShared
/// Purpose: Appearance tab cover style, color, message, clock, and reveal trigger.
/// Extracted from PreferencesView to keep every tab file under 500 lines.
/// Inputs: @AppStorage bindings for cover and reveal prefs.
/// Outputs: writes to UserDefaults; no side-effectful closures needed.
/// Constraints: @MainActor (SwiftUI). Color is persisted as "#rrggbb" hex (shared with
/// the headless cover renderer) so a small Color<->hex bridge is included here.
/// SPORT: MASTER-PREFS
struct PrefAppearanceTab: View {
// FIX-5: default literal aligned to registerDefaults (was "solidColor", now "logo")
@AppStorage(Settings.Key.coverStyle) private var coverStyle = "logo"
@AppStorage(Settings.Key.coverColor) private var coverColorHex = "#000000"
@AppStorage(Settings.Key.coverMessage) private var coverMessage = ""
@AppStorage(Settings.Key.coverShowClock) private var coverShowClock = false
@AppStorage(Settings.Key.revealTrigger) private var revealTrigger = "anyKey"
@AppStorage(Settings.Key.revealKeyCombo) private var revealKeyCombo = ""
var body: some View {
Form {
Section("Cover") {
Picker("Cover style", selection: $coverStyle) {
Text("Solid color").tag("solidColor")
Text("Message").tag("message")
Text("Blur").tag("blur")
Text("Lock logo").tag("logo")
Text("Curtain logo").tag("curtainLogo")
Text("Aerial video").tag("aerial")
}
if coverStyle == "aerial" {
Text("Plays a system aerial video on the covered screens (muted, looping). A keypress still brings up the password. Falls back to the logo if no aerial video is installed. Uses more power than a static cover.")
.font(.caption).foregroundStyle(.secondary)
}
ColorPicker("Cover color", selection: colorBinding, supportsOpacity: false)
if coverStyle == "message" {
TextField("Cover message", text: $coverMessage)
}
Toggle("Show a clock on the cover", isOn: $coverShowClock)
}
Section {
Picker("Reveal trigger", selection: $revealTrigger) {
Text("Any key").tag("anyKey")
Text("Key combo").tag("keyCombo")
}
if revealTrigger == "keyCombo" {
TextField("Reveal key combo", text: $revealKeyCombo)
}
} header: {
Text("Reveal")
} footer: {
if revealTrigger == "keyCombo" {
Text("Format: modifiers plus a key, joined by \"+\". For example \"cmd+shift+l\".")
.font(.caption).foregroundStyle(.secondary)
}
}
}
.formStyle(.grouped)
}
// MARK: - Color <-> hex bridge
/// Bridge the stored "#rrggbb" hex string to a SwiftUI Color for the ColorPicker.
private var colorBinding: Binding<Color> {
Binding<Color>(
get: { Self.color(fromHex: coverColorHex) },
set: { coverColorHex = Self.hex(from: $0) }
)
}
private static func color(fromHex hex: String) -> Color {
guard let rgb = HexColor.toRGB(hex) else { return .black }
return Color(red: rgb.r, green: rgb.g, blue: rgb.b)
}
private static func hex(from color: Color) -> String {
let ns = NSColor(color).usingColorSpace(.sRGB) ?? .black
return HexColor.fromRGB(Double(ns.redComponent), Double(ns.greenComponent), Double(ns.blueComponent))
}
}

View file

@ -0,0 +1,33 @@
import SwiftUI
/// Purpose: Disconnect tab privileged-helper toggle and explanatory caption.
/// Extracted from PreferencesView to keep every tab file under 500 lines.
/// Inputs: @AppStorage binding for the feature-enabled flag; injected closure that
/// tells the coordinator to register/unregister the SMAppService daemon.
/// Outputs: writes to UserDefaults; calls enableDisconnectHelper when the toggle changes.
/// Constraints: @MainActor (SwiftUI). The helper is off by default; enabling it triggers
/// a one-time admin authorization prompt via SMAppService.
/// SPORT: MASTER-PREFS
struct PrefDisconnectTab: View {
@AppStorage(Settings.Key.disconnectFeatureEnabled) private var disconnectFeatureEnabled = false
let enableDisconnectHelper: (Bool) -> Void
var body: some View {
Form {
Section {
Toggle("Enable disconnect-remote-on-end (needs a one-time admin approval)", isOn: $disconnectFeatureEnabled)
.onChange(of: disconnectFeatureEnabled) { enableDisconnectHelper($0) }
} header: {
Text("Disconnect helper")
} footer: {
VStack(alignment: .leading, spacing: 4) {
Text("When off, a requested disconnect is logged and skipped rather than calling the privileged helper.")
.font(.caption).foregroundStyle(.secondary)
Text("The disconnect action used by unlock, idle, and on-end only fires when this helper is enabled.")
.font(.caption).foregroundStyle(.secondary)
}
}
}
.formStyle(.grouped)
}
}

View file

@ -0,0 +1,151 @@
import SwiftUI
import AppKit
/// Purpose: Displays tab per-display Cover/DisplayLink toggles, cover-scope picker,
/// password-box placement picker (with specific-display sub-picker), new-display
/// policy, and display-tool buttons.
/// Extracted from PreferencesView to keep every tab file under 500 lines.
/// Inputs: @AppStorage bindings for all display-related prefs; injected closures for
/// the Identify and Mark-as-DisplayLink buttons; displayRefresh trigger from the
/// parent so the list re-reads after a toggle.
/// Outputs: writes to UserDefaults; calls identifyDisplays / markDisplayLink closures.
/// Constraints: @MainActor (SwiftUI). Cover scope uses two values only: "all" (fail-safe
/// default) and "perDisplay" (delegate to per-display Cover toggles). The legacy
/// "onlyMarked"/"allExceptMarked" values are migrated by Settings.registerDefaults.
/// SPORT: MASTER-PREFS
struct PrefDisplaysTab: View {
// Scope: two-mode model "all" is the fail-safe, "perDisplay" delegates to toggles.
@AppStorage(Settings.Key.coverScope) private var coverScope = "all"
@AppStorage(Settings.Key.passwordBoxPlacement) private var passwordBoxPlacement = "followActive"
// Stores the UUID of the display chosen for the specific-display password box.
@AppStorage(Settings.Key.passwordBoxSpecificUUID) private var passwordBoxSpecificUUID = ""
@AppStorage(Settings.Key.newDisplayPolicy) private var newDisplayPolicy = "cover"
/// Bumped by the parent to force the dynamic display list to re-read after a toggle.
let displayRefresh: Int
let identifyDisplays: () -> Void
let markDisplayLink: () -> Void
let onMarkDisplayLink: () -> Void // called after markDisplayLink so parent can bump refresh
var body: some View {
Form {
Section {
ForEach(Array(NSScreen.screens.enumerated()), id: \.offset) { idx, screen in
displayRow(index: idx, screen: screen)
}
.id(displayRefresh)
} header: {
Text("Connected displays")
} footer: {
Text("DisplayLink monitors can't be hidden invisibly; mark them so the curtain covers them too.")
.font(.caption).foregroundStyle(.secondary)
}
Section {
// Cover-scope: two options All displays (fail-safe) or Per-display toggles.
Picker("Cover scope", selection: $coverScope) {
Text("All displays").tag("all")
Text("Per-display Cover toggles").tag("perDisplay")
}
Text("In per-display mode each display's Cover toggle decides; new displays follow the new-display policy below. All displays is the fail-safe default.")
.font(.caption).foregroundStyle(.secondary)
Picker("Password box placement", selection: $passwordBoxPlacement) {
Text("Primary display").tag("primary")
Text("Follow active display").tag("followActive")
Text("All displays").tag("all")
Text("A specific display").tag("specific")
}
// Shown only when "specific" is chosen lets the user pin the password
// box to one display by UUID. Shows a "(disconnected)" row if the stored
// UUID is no longer connected, so the selection is never silently lost.
if passwordBoxPlacement == "specific" {
specificDisplayPicker
}
Picker("When a new display connects", selection: $newDisplayPolicy) {
Text("Cover it").tag("cover")
Text("Leave it uncovered").tag("leaveUncovered")
Text("Treat it as DisplayLink").tag("treatAsDisplayLink")
}
} header: {
Text("Behavior")
}
Section("Tools") {
HStack {
Button("Identify Displays", action: identifyDisplays)
Button("Mark Externals as DisplayLink") {
markDisplayLink()
onMarkDisplayLink()
}
}
}
}
.formStyle(.grouped)
}
// MARK: - Per-display row
private func displayRow(index: Int, screen: NSScreen) -> some View {
let uuid = System.uuid(of: screen)
let shortID = uuid.map { String($0.prefix(8)) } ?? "unknown"
let res = "\(Int(screen.frame.width))×\(Int(screen.frame.height))"
let cover = Binding<Bool>(
get: { uuid.map { !Settings.perDisplayCoverDisabled.contains($0) } ?? true },
set: { newValue in
guard let u = uuid else { return }
var list = Settings.perDisplayCoverDisabled
if newValue { list.removeAll { $0 == u } } else if !list.contains(u) { list.append(u) }
Settings.perDisplayCoverDisabled = list
}
)
let displayLink = Binding<Bool>(
get: { uuid.map { Settings.displayLinkUUIDs.contains($0) } ?? false },
set: { newValue in
guard let u = uuid else { return }
var list = Settings.displayLinkUUIDs
if newValue { if !list.contains(u) { list.append(u) } } else { list.removeAll { $0 == u } }
Settings.displayLinkUUIDs = list
}
)
return VStack(alignment: .leading, spacing: 4) {
Text("Display \(index) · \(res) · \(shortID)").font(.caption).bold()
HStack {
Toggle("Cover", isOn: cover)
Toggle("DisplayLink", isOn: displayLink)
}
}
}
// MARK: - Specific-display picker for password box
/// A picker over currently connected displays, using the display's full UUID as the
/// tag value. If the stored UUID is no longer connected, an extra "(disconnected)"
/// row preserves the selection so the user can see what was chosen.
private var specificDisplayPicker: some View {
let screens = NSScreen.screens
let connectedUUIDs: [(uuid: String, label: String)] = screens.enumerated().compactMap { idx, s in
guard let u = System.uuid(of: s) else { return nil }
let res = "\(Int(s.frame.width))×\(Int(s.frame.height))"
let short = String(u.prefix(8))
return (uuid: u, label: "Display \(idx) · \(res) · \(short)")
}
let storedIsOrphan = !passwordBoxSpecificUUID.isEmpty
&& !connectedUUIDs.contains(where: { $0.uuid == passwordBoxSpecificUUID })
return Picker("Specific display", selection: $passwordBoxSpecificUUID) {
ForEach(connectedUUIDs, id: \.uuid) { item in
Text(item.label).tag(item.uuid)
}
// Keep an orphaned UUID visible so the user knows what was stored and can
// change it deliberately rather than having it silently reset to empty.
if storedIsOrphan {
Text("\(String(passwordBoxSpecificUUID.prefix(8)))… (disconnected)")
.tag(passwordBoxSpecificUUID)
.foregroundStyle(.secondary)
}
}
}
}

View file

@ -0,0 +1,54 @@
import SwiftUI
/// Purpose: General tab master switch, login-item, menu-bar toggle, activation
/// timing, and manual test actions. Extracted from PreferencesView to keep
/// every tab file under 500 lines.
/// Inputs: @AppStorage bindings shared with the headless coordinator, plus injected
/// action closures for side-effectful buttons.
/// Outputs: writes to UserDefaults; calls LoginItem.set and onMenuBarToggle.
/// Constraints: @MainActor (SwiftUI); changes are live because AppStorage shares the
/// same UserDefaults domain as the coordinator.
/// SPORT: MASTER-PREFS
struct PrefGeneralTab: View {
@AppStorage(Settings.Key.armed) private var armed = true
@AppStorage(Settings.Key.launchAtLogin) private var launchAtLogin = true
@AppStorage(Settings.Key.showInMenuBar) private var showInMenuBar = true
@AppStorage(Settings.Key.onStartActivate) private var onStartActivate = true
@AppStorage(Settings.Key.connectGraceSeconds) private var connectGraceSeconds = 2
@AppStorage(Settings.Key.notifyOnActivate) private var notifyOnActivate = true
@AppStorage(Settings.Key.playSoundOnActivate) private var playSoundOnActivate = false
let activateNow: () -> Void
let testNow: () -> Void
let onMenuBarToggle: (Bool) -> Void
var body: some View {
Form {
Section("Status") {
Toggle("Armed (master switch)", isOn: $armed)
Toggle("Open at login", isOn: $launchAtLogin)
.onChange(of: launchAtLogin) { LoginItem.set($0) }
Toggle("Show in menu bar", isOn: $showInMenuBar)
.onChange(of: showInMenuBar) { onMenuBarToggle($0) }
}
Section("Activation") {
Toggle("Activate curtain when a remote session begins", isOn: $onStartActivate)
Stepper("Connect grace: \(connectGraceSeconds)s", value: $connectGraceSeconds, in: 0...30)
Toggle("Log a note when the curtain activates", isOn: $notifyOnActivate)
Toggle("Play a sound when the curtain activates", isOn: $playSoundOnActivate)
}
Section {
HStack {
Button("Activate Now", action: activateNow)
Button("Test (10s)", action: testNow)
}
} header: {
Text("Manual")
} footer: {
Text("Test runs a 10 second curtain so you can check appearance and reveal.")
.font(.caption).foregroundStyle(.secondary)
}
}
.formStyle(.grouped)
}
}

View file

@ -0,0 +1,79 @@
import SwiftUI
/// Purpose: Idle & End tab idle-timeout block and on-disconnect block.
/// Extracted from PreferencesView to keep every tab file under 500 lines.
/// Inputs: @AppStorage bindings for idle and end prefs.
/// Outputs: writes to UserDefaults; no side-effectful closures needed.
/// Constraints: @MainActor (SwiftUI). Idle source labels updated to be self-explanatory
/// about what "session input" vs "HID" means for the remote operator.
/// SPORT: MASTER-PREFS
struct PrefIdleEndTab: View {
@AppStorage(Settings.Key.idleEnabled) private var idleEnabled = true
@AppStorage(Settings.Key.idleMinutes) private var idleMinutes = 30
// FIX-5: default literal aligned to registerDefaults (was "hidIdle", now "sessionInput")
@AppStorage(Settings.Key.idleSource) private var idleSource = "sessionInput"
@AppStorage(Settings.Key.onIdleDisconnect) private var idleDisconnect = true
@AppStorage(Settings.Key.onIdleLock) private var idleLock = true
@AppStorage(Settings.Key.onIdleScreenOff) private var idleScreenOff = true
@AppStorage(Settings.Key.onIdleDeactivate) private var idleDeactivate = true
@AppStorage(Settings.Key.onEndLock) private var endLock = true
@AppStorage(Settings.Key.onEndScreenOff) private var endScreenOff = true
@AppStorage(Settings.Key.onEndDeactivate) private var endDeactivate = true
var body: some View {
Form {
Section {
Toggle("Act after the session is idle", isOn: $idleEnabled)
if idleEnabled {
Stepper("After \(idleMinutes) minutes of inactivity:", value: $idleMinutes, in: 1...240)
Picker("Inactivity is measured by", selection: $idleSource) {
// Tag values are unchanged; only the human-readable labels are updated
// to make it obvious that "session input" tracks the remote operator.
Text("Remote session activity (recommended)").tag("sessionInput")
Text("This Mac's physical input only").tag("hidIdle")
}
Toggle("Disconnect the remote session", isOn: $idleDisconnect)
Toggle("Lock the Mac", isOn: $idleLock)
Toggle("Turn off the displays", isOn: $idleScreenOff)
Toggle("Deactivate the curtain", isOn: $idleDeactivate)
}
} header: {
Text("On idle")
} footer: {
if idleEnabled {
VStack(alignment: .leading, spacing: 4) {
if idleMinutes <= 2 && idleDisconnect {
warn("A very short idle timeout with disconnect-on-idle can cut a session during a brief pause.")
}
if !idleDeactivate && idleScreenOff {
warn("Screens go dark on idle but the curtain stays up. The desk shows nothing until you deactivate.")
}
}
}
}
Section {
Toggle("Lock the Mac", isOn: $endLock)
Toggle("Turn off the displays", isOn: $endScreenOff)
Toggle("Deactivate the curtain", isOn: $endDeactivate)
} header: {
Text("When the remote session disconnects")
} footer: {
if !endDeactivate && !endLock {
warn("On disconnect the Mac is neither locked nor uncovered. It is left covered but unlocked (\"dead but unlocked\").")
}
}
}
.formStyle(.grouped)
}
/// Small inline warning row: orange triangle + caption. Used in section footers
/// to flag dangerous setting combinations without alarming the layout.
private func warn(_ text: String) -> some View {
HStack(alignment: .top, spacing: 6) {
Image(systemName: "exclamationmark.triangle")
Text(text)
}
.font(.caption)
.foregroundStyle(.orange)
}
}

View file

@ -0,0 +1,85 @@
import SwiftUI
/// Purpose: Security tab unlock action, password-box timeout, require-password
/// toggle, Accessibility behavior, and password change form.
/// Extracted from PreferencesView to keep every tab file under 500 lines.
/// Inputs: @AppStorage bindings for security prefs, plus a @Binding for the live
/// hasPassword state so the parent can refresh it after a reset.
/// Outputs: writes to UserDefaults; calls Settings.setPassword on "Set" button press.
/// Constraints: @MainActor (SwiftUI). The fallback password "curtain" always works so
/// the user can never be locked out the UI calls this out in the footer.
/// SPORT: MASTER-PREFS
struct PrefSecurityTab: View {
// FIX-5: default literal aligned to registerDefaults ("keepSession" -> "disconnect")
@AppStorage(Settings.Key.onUnlockAction) private var onUnlockAction = "disconnect"
@AppStorage(Settings.Key.passwordBoxTimeoutSeconds) private var passwordBoxTimeout = 15
@AppStorage(Settings.Key.requirePasswordToDeactivateFromMenu) private var requirePasswordToDeactivate = false
@AppStorage(Settings.Key.accessibilityMissingBehavior) private var accessibilityMissing = "warn"
@State private var newPassword = ""
@Binding var hasPassword: Bool
var body: some View {
Form {
Section {
Picker("On Curtain Unlock", selection: $onUnlockAction) {
Text("Keep the remote session active").tag("keepSession")
Text("Disconnect the remote session").tag("disconnect")
}
Stepper("Password box timeout: \(passwordBoxTimeout)s", value: $passwordBoxTimeout, in: 5...60)
Toggle("Require the password to deactivate from the menu", isOn: $requirePasswordToDeactivate)
} header: {
Text("Unlock")
} footer: {
VStack(alignment: .leading, spacing: 4) {
if onUnlockAction == "disconnect" {
Text("Disconnecting on unlock needs the disconnect helper enabled (see the Disconnect tab). Under an ad-hoc local build, enabling it installs a small privileged helper with one admin prompt.")
.font(.caption).foregroundStyle(.secondary)
}
if requirePasswordToDeactivate {
warn("The fallback password \"curtain\" always works, so you can never be locked out of your own Mac.")
}
}
}
Section {
Picker("If Accessibility is missing", selection: $accessibilityMissing) {
Text("Warn and arm anyway").tag("warn")
Text("Refuse to arm").tag("refuseToArm")
}
} header: {
Text("Accessibility")
} footer: {
if accessibilityMissing == "refuseToArm" {
warn("Curtain will not arm without Accessibility. Grant it in System Settings, or the curtain never engages.")
}
}
Section {
HStack {
SecureField("New unlock password", text: $newPassword)
Button("Set") {
if !newPassword.isEmpty {
Settings.setPassword(newPassword)
newPassword = ""
hasPassword = Settings.hasPassword
}
}
}
} header: {
Text("Password")
} footer: {
Text(hasPassword ? "A password is set." : "No password set (default: \"curtain\").")
.font(.caption).foregroundStyle(.secondary)
}
}
.formStyle(.grouped)
}
private func warn(_ text: String) -> some View {
HStack(alignment: .top, spacing: 6) {
Image(systemName: "exclamationmark.triangle")
Text(text)
}
.font(.caption)
.foregroundStyle(.orange)
}
}

View file

@ -1,49 +1,69 @@
import SwiftUI
import AppKit
import CurtainShared
/// Purpose: The single settings window (Caffeine-style). Binds to the same
/// UserDefaults keys the coordinator reads, so changes take effect live.
/// Purpose: The single settings window. Every Curtain preference is a control here,
/// grouped by concern and bound to the same UserDefaults keys the headless
/// coordinator reads, so a change in the UI takes effect live.
/// Inputs: a SessionCoordinator (for the manual actions) plus a handful of injected
/// closures the AppDelegate wires (onboarding, menu-bar toggle).
/// Outputs: writes to UserDefaults via @AppStorage + Settings helpers; invokes the
/// coordinator and injected closures for side effects.
/// Constraints: SwiftUI + AppKit run on the main actor; the controller is @MainActor.
/// The coordinator is held weakly inside the escaping closures so the window
/// never extends the coordinator's lifetime. Bindings use Settings.Key.*
/// constants so keys match the headless side byte-for-byte.
/// SPORT: MASTER-PREFS
@MainActor
final class PreferencesWindowController {
private var window: NSWindow?
private weak var coordinator: SessionCoordinator?
var onMenuBarToggle: ((Bool) -> Void)?
/// Wired by the AppDelegate to reopen the first-run setup flow.
var openOnboarding: (() -> Void)?
init(coordinator: SessionCoordinator) { self.coordinator = coordinator }
func show() {
if let w = window { w.makeKeyAndOrderFront(nil); NSApp.activate(ignoringOtherApps: true); return }
let view = PreferencesView(
activateNow: { [weak self] in self?.coordinator?.activateNow() },
testNow: { [weak self] in self?.coordinator?.testCurtain() },
activateNow: { [weak coordinator] in coordinator?.activateNow() },
testNow: { [weak coordinator] in coordinator?.testCurtain(seconds: 10) },
markDisplayLink: { Self.markExternalsAsDisplayLink() },
identifyDisplays: { Self.identifyDisplays() },
enableDisconnectHelper: { [weak coordinator] on in coordinator?.enableDisconnectHelper(on) },
openOnboarding: { [weak self] in self?.openOnboarding?() },
onMenuBarToggle: { [weak self] on in self?.onMenuBarToggle?(on) }
)
let w = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 460, height: 560),
styleMask: [.titled, .closable, .miniaturizable],
let w = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 620, height: 560),
styleMask: [.titled, .closable, .miniaturizable, .resizable],
backing: .buffered, defer: false)
w.title = "Curtain"
w.contentViewController = NSHostingController(rootView: view)
w.setContentSize(NSSize(width: 620, height: 560))
w.contentMinSize = NSSize(width: 600, height: 520)
w.center(); w.isReleasedWhenClosed = false
window = w
w.makeKeyAndOrderFront(nil); NSApp.activate(ignoringOtherApps: true)
}
// MARK: - Display helpers (shared with menu)
// MARK: - Display helpers (shared with the menu)
/// Mark every external (non-builtin) display as a DisplayLink, keyed by the
/// stable per-display UUID so the mapping survives reboots and port changes.
static func markExternalsAsDisplayLink() {
var serials: [UInt32] = []
var uuids: [String] = []
for s in NSScreen.screens {
let id = s.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as! CGDirectDisplayID
if CGDisplayIsBuiltin(id) == 0 { serials.append(System.serial(of: s)) }
guard let id = s.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? CGDirectDisplayID else { continue }
if CGDisplayIsBuiltin(id) == 0, let u = System.uuid(of: s) { uuids.append(u) }
}
Settings.displayLinkSerials = serials
Settings.displayLinkUUIDs = uuids
let a = NSAlert(); a.messageText = "Curtain"
a.informativeText = "Marked \(serials.count) external display(s) as DisplayLink."
a.informativeText = "Marked \(uuids.count) external display(s) as DisplayLink."
NSApp.activate(ignoringOtherApps: true); a.runModal()
}
/// Flash a big index + short UUID on each screen so the user can tell them apart.
static func identifyDisplays() {
var wins: [NSWindow] = []
for (i, screen) in NSScreen.screens.enumerated() {
@ -51,8 +71,9 @@ final class PreferencesWindowController {
w.level = NSWindow.Level(rawValue: Int(CGWindowLevelForKey(.maximumWindow)))
w.backgroundColor = NSColor.black.withAlphaComponent(0.85)
w.collectionBehavior = [.canJoinAllSpaces, .stationary]
let lbl = NSTextField(labelWithString: "\(i)\nserial \(System.serial(of: screen))")
lbl.frame = NSRect(x: 0, y: screen.frame.height/2 - 130, width: screen.frame.width, height: 260)
let shortID = System.uuid(of: screen).map { String($0.prefix(8)) } ?? "unknown"
let lbl = NSTextField(labelWithString: "\(i)\n\(shortID)")
lbl.frame = NSRect(x: 0, y: screen.frame.height / 2 - 130, width: screen.frame.width, height: 260)
lbl.alignment = .center; lbl.font = .systemFont(ofSize: 110, weight: .bold)
lbl.textColor = .white; lbl.backgroundColor = .clear; lbl.isBezeled = false
lbl.isEditable = false; lbl.maximumNumberOfLines = 2
@ -62,110 +83,214 @@ final class PreferencesWindowController {
}
}
/// The SwiftUI settings form.
// MARK: - Settings import/export shape
/// A flat, versioned snapshot of every persisted preference, used for Export/Import.
struct SettingsSnapshot: Codable {
var version = 1
var values: [String: AnyCodable] = [:]
}
/// Minimal AnyCodable so a heterogeneous defaults dictionary survives JSON round-trips.
struct AnyCodable: Codable {
let value: Any
init(_ value: Any) { self.value = value }
init(from decoder: Decoder) throws {
let c = try decoder.singleValueContainer()
if let b = try? c.decode(Bool.self) { value = b }
else if let i = try? c.decode(Int.self) { value = i }
else if let d = try? c.decode(Double.self) { value = d }
else if let s = try? c.decode(String.self) { value = s }
else if let a = try? c.decode([AnyCodable].self) { value = a.map(\.value) }
else { value = "" }
}
func encode(to encoder: Encoder) throws {
var c = encoder.singleValueContainer()
switch value {
case let b as Bool: try c.encode(b)
case let i as Int: try c.encode(i)
case let d as Double: try c.encode(d)
case let s as String: try c.encode(s)
case let a as [String]: try c.encode(a.map(AnyCodable.init))
default: try c.encode("")
}
}
}
// MARK: - The SwiftUI settings form
private struct PreferencesView: View {
// App
@AppStorage(Settings.Key.launchAtLogin) private var launchAtLogin = true
@AppStorage(Settings.Key.showInMenuBar) private var showInMenuBar = true
// Start
@AppStorage(Settings.Key.onStartActivate) private var onStartActivate = true
// Idle
@AppStorage(Settings.Key.idleEnabled) private var idleEnabled = true
@AppStorage(Settings.Key.idleMinutes) private var idleMinutes = 30
@AppStorage(Settings.Key.onIdleDisconnect) private var idleDisconnect = true
@AppStorage(Settings.Key.onIdleLock) private var idleLock = true
@AppStorage(Settings.Key.onIdleScreenOff) private var idleScreenOff = true
@AppStorage(Settings.Key.onIdleDeactivate) private var idleDeactivate = true
// End
@AppStorage(Settings.Key.onEndLock) private var endLock = true
@AppStorage(Settings.Key.onEndScreenOff) private var endScreenOff = true
@AppStorage(Settings.Key.onEndDeactivate) private var endDeactivate = true
// Password
@AppStorage(Settings.Key.onPasswordDisconnect) private var passwordDisconnect = true
@State private var newPassword = ""
// State shared across tabs that need to refresh or toggle together.
@State private var hasPassword = Settings.hasPassword
/// Bumped to force the dynamic display list to re-read after a Cover/DisplayLink toggle.
@State private var displayRefresh = 0
let activateNow: () -> Void
let testNow: () -> Void
let markDisplayLink: () -> Void
let identifyDisplays: () -> Void
let enableDisconnectHelper: (Bool) -> Void
let openOnboarding: () -> Void
let onMenuBarToggle: (Bool) -> Void
/// The settings sections, shown as a System Settings style sidebar. Each case
/// carries its sidebar title and SF Symbol; the order here is the sidebar order.
private enum PrefSection: String, Identifiable, Hashable, CaseIterable {
case general, appearance, idleEnd, security, disconnect, displays, advanced
var id: String { rawValue }
var title: String {
switch self {
case .general: return "General"
case .appearance: return "Appearance"
case .idleEnd: return "Idle & End"
case .security: return "Security"
case .disconnect: return "Disconnect"
case .displays: return "Displays"
case .advanced: return "Advanced"
}
}
var symbol: String {
switch self {
case .general: return "gearshape"
case .appearance: return "paintbrush"
case .idleEnd: return "moon.zzz"
case .security: return "lock.shield"
case .disconnect: return "network.slash"
case .displays: return "display"
case .advanced: return "slider.horizontal.3"
}
}
}
@State private var selection: PrefSection = .general
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 18) {
header
group("Application") {
Toggle("Open at login", isOn: $launchAtLogin)
.onChange(of: launchAtLogin) { LoginItem.set($0) }
Toggle("Show in menu bar", isOn: $showInMenuBar)
.onChange(of: showInMenuBar) { onMenuBarToggle($0) }
HStack {
Button("Activate Now", action: activateNow)
Button("Test (10s)", action: testNow)
}
}
group("On session start") {
Toggle("Activate curtain when a remote session begins", isOn: $onStartActivate)
}
group("On session idle") {
Toggle("Act after the session is idle", isOn: $idleEnabled)
if idleEnabled {
Stepper("Idle timeout: \(idleMinutes) min", value: $idleMinutes, in: 1...240)
Toggle("Disconnect the remote session", isOn: $idleDisconnect)
Toggle("Lock the Mac", isOn: $idleLock)
Toggle("Turn off the displays", isOn: $idleScreenOff)
Toggle("Deactivate the curtain", isOn: $idleDeactivate)
}
}
group("On session end (disconnect)") {
Toggle("Lock the Mac", isOn: $endLock)
Toggle("Turn off the displays", isOn: $endScreenOff)
Toggle("Deactivate the curtain", isOn: $endDeactivate)
}
group("Security") {
Toggle("Disconnect remote when password is entered at the desk", isOn: $passwordDisconnect)
HStack {
SecureField("New unlock password", text: $newPassword)
Button("Set") { if !newPassword.isEmpty { Settings.setPassword(newPassword); newPassword = "" } }
}
Text(Settings.hasPassword ? "A password is set." : "No password set (default: “curtain”).")
.font(.caption).foregroundStyle(.secondary)
}
group("Displays") {
Text("DisplayLink monitors can't be hidden invisibly; mark them so the curtain covers them too.")
.font(.caption).foregroundStyle(.secondary)
HStack {
Button("Identify Displays", action: identifyDisplays)
Button("Mark Externals as DisplayLink", action: markDisplayLink)
}
}
}
.padding(22)
HStack(spacing: 0) {
sidebar
.frame(width: 190)
Divider()
detail
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
.frame(width: 460, height: 560)
.frame(minWidth: 620, idealWidth: 620, maxWidth: .infinity,
minHeight: 560, idealHeight: 560, maxHeight: .infinity)
}
private var header: some View {
HStack(spacing: 12) {
Image(nsImage: CurtainIcon.appIcon(size: 48))
.resizable().frame(width: 48, height: 48)
VStack(alignment: .leading) {
Text("Curtain").font(.title2).bold()
Text("Privacy for macOS Screen Sharing").font(.caption).foregroundStyle(.secondary)
}
/// An always-visible source-list sidebar. A plain List inside the HStack keeps the
/// translucent System Settings look without the split view that kept collapsing.
/// Adapts the non-optional selection to the optional binding List wants for single
/// selection. A nil set (clicking empty space) is ignored so a section is always shown.
private var sidebarSelection: Binding<PrefSection?> {
Binding<PrefSection?>(
get: { selection },
set: { if let new = $0 { selection = new } }
)
}
private var sidebar: some View {
List(PrefSection.allCases, selection: sidebarSelection) { section in
Label(section.title, systemImage: section.symbol)
.tag(section)
}
.listStyle(.sidebar)
}
/// The right-hand content for the selected sidebar section.
@ViewBuilder
private var detail: some View {
switch selection {
case .general:
PrefGeneralTab(
activateNow: activateNow,
testNow: testNow,
onMenuBarToggle: onMenuBarToggle
)
case .appearance:
PrefAppearanceTab()
case .idleEnd:
PrefIdleEndTab()
case .security:
PrefSecurityTab(hasPassword: $hasPassword)
case .disconnect:
PrefDisconnectTab(enableDisconnectHelper: enableDisconnectHelper)
case .displays:
PrefDisplaysTab(
displayRefresh: displayRefresh,
identifyDisplays: identifyDisplays,
markDisplayLink: markDisplayLink,
onMarkDisplayLink: { displayRefresh += 1 }
)
case .advanced:
PrefAdvancedTab(
openOnboarding: openOnboarding,
exportSettings: exportSettings,
importSettings: importSettings,
resetToDefaults: resetToDefaults
)
}
}
@ViewBuilder private func group<Content: View>(_ title: String, @ViewBuilder _ content: () -> Content) -> some View {
VStack(alignment: .leading, spacing: 8) {
Text(title.uppercased()).font(.caption2).bold().foregroundStyle(.secondary)
content()
}
// MARK: - Reset / Export / Import
private func resetToDefaults() {
let d = UserDefaults.standard
for key in Self.exportableKeys { d.removeObject(forKey: key) }
Settings.registerDefaults()
LoginItem.set(d.bool(forKey: Settings.Key.launchAtLogin))
hasPassword = Settings.hasPassword
displayRefresh += 1
}
private func exportSettings() {
let panel = NSSavePanel()
panel.allowedContentTypes = [.json]
panel.nameFieldStringValue = "Curtain-Settings.json"
guard panel.runModal() == .OK, let url = panel.url else { return }
let d = UserDefaults.standard
var snapshot = SettingsSnapshot()
for key in Self.exportableKeys {
if let obj = d.object(forKey: key) { snapshot.values[key] = AnyCodable(obj) }
}
let encoder = JSONEncoder(); encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
if let data = try? encoder.encode(snapshot) { try? data.write(to: url) }
}
private func importSettings() {
let panel = NSOpenPanel()
panel.allowedContentTypes = [.json]
panel.allowsMultipleSelection = false
guard panel.runModal() == .OK, let url = panel.url,
let data = try? Data(contentsOf: url),
let snapshot = try? JSONDecoder().decode(SettingsSnapshot.self, from: data) else { return }
let d = UserDefaults.standard
for (key, wrapped) in snapshot.values where Self.exportableKeys.contains(key) {
d.set(wrapped.value, forKey: key)
}
LoginItem.set(d.bool(forKey: Settings.Key.launchAtLogin))
hasPassword = Settings.hasPassword
displayRefresh += 1
}
/// Every non-secret preference key, used by Reset/Export/Import. Password hash,
/// salt, and algo are deliberately excluded so a snapshot never leaks the secret.
/// armDisarmHotkey removed EmergencyHotkey is intentionally hardcoded and there
/// is no runtime reader for that key.
static let exportableKeys: [String] = [
Settings.Key.armed, Settings.Key.launchAtLogin, Settings.Key.showInMenuBar,
Settings.Key.onStartActivate, Settings.Key.connectGraceSeconds, Settings.Key.notifyOnActivate, Settings.Key.playSoundOnActivate,
Settings.Key.coverStyle, Settings.Key.coverColor, Settings.Key.coverMessage, Settings.Key.coverShowClock,
Settings.Key.revealTrigger, Settings.Key.revealKeyCombo,
Settings.Key.idleEnabled, Settings.Key.idleMinutes, Settings.Key.idleSource,
Settings.Key.onIdleDisconnect, Settings.Key.onIdleLock, Settings.Key.onIdleScreenOff, Settings.Key.onIdleDeactivate,
Settings.Key.onEndLock, Settings.Key.onEndScreenOff, Settings.Key.onEndDeactivate,
Settings.Key.onUnlockAction, Settings.Key.passwordBoxTimeoutSeconds,
Settings.Key.requirePasswordToDeactivateFromMenu, Settings.Key.accessibilityMissingBehavior,
Settings.Key.disconnectFeatureEnabled,
Settings.Key.displayLinkUUIDs, Settings.Key.perDisplayCoverDisabled,
Settings.Key.coverScope, Settings.Key.passwordBoxPlacement, Settings.Key.passwordBoxSpecificUUID, Settings.Key.newDisplayPolicy,
Settings.Key.diagnosticsLoggingEnabled,
]
}

View file

@ -1,76 +1,290 @@
import Cocoa
import os.log
/// Purpose: The brain. Wires the session monitor, curtain, input filter, and the
/// configurable lifecycle actions together. Owns the connect / idle / end
/// flow described in the README. Holds no UI.
/// configurable lifecycle actions together, and runs the connect / idle /
/// end / password flow as an explicit, idempotent state machine. Holds no
/// UI of its own it owns AppKit objects and publishes state changes.
/// Inputs: none at construction; behavior is driven entirely by Settings + the
/// monitor callbacks.
/// Outputs: onStateChange (curtain active?) and onArmedChange (armed?) for the menu.
/// Constraints: @MainActor it owns AppKit objects and the monitor callbacks
/// already hop to main. Every public transition is idempotent: a
/// double-activate, a deactivate-while-idle, or a reconnect-while-active
/// must never double-run lock/sleep or leave the tap dangling. The
/// password-unlock path disconnects the remote BEFORE revealing the
/// desktop, and never blocks the runloop with a modal (that would freeze
/// the event tap). When disarmed it stays passive and ignores the monitor.
/// SPORT: MASTER-COORDINATOR
@MainActor
final class SessionCoordinator {
/// Curtain active? Drives the menu-bar icon.
var onStateChange: ((Bool) -> Void)?
/// Armed? Drives the menu's arm/disarm item.
var onArmedChange: ((Bool) -> Void)?
let curtain = CurtainController()
let input = InputFilter()
private let monitor = SessionMonitor()
private lazy var runner = ActionRunner(curtain: curtain, input: input)
private var tickTimer: Timer?
/// Called when the curtain's active state changes (for the menu-bar icon).
var onStateChange: ((Bool) -> Void)?
/// Always-available escape hatch deactivates without Accessibility. See start().
private var emergencyHotkey: EmergencyHotkey?
/// Pending connect grace; canceled if the session drops before it elapses.
private var connectGrace: DispatchWorkItem?
/// Pending test-curtain teardown; canceled if a real session connects mid-test.
private var testTeardown: DispatchWorkItem?
/// Explicit lifecycle state. Every transition is guarded so it stays idempotent.
private enum State { case idle, active }
private var state: State = .idle
// MARK: - Setup
func start() {
input.onPhysicalKey = { [weak self] kc, chars in self?.curtain.physicalKey(kc, chars) }
input.onPhysicalKey = { [weak self] kc, chars, flags in self?.curtain.physicalKey(kc, chars, flags) }
curtain.onUnlock = { [weak self] in self?.handlePasswordUnlock() }
monitor.onConnect = { [weak self] in self?.sessionStarted() }
monitor.onConnect = { [weak self] in self?.sessionConnected() }
monitor.onIdleTimeout = { [weak self] in self?.sessionIdled() }
monitor.onDisconnect = { [weak self] in self?.sessionEnded() }
monitor.start()
tickTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
self?.curtain.tick()
Task { @MainActor in self?.curtain.tick() }
}
// Reflect the persisted disconnect-helper setting into the System handler hook.
enableDisconnectHelper(Settings.disconnectFeatureEnabled)
// Always-on emergency escape. Carbon RegisterEventHotKey needs no Accessibility,
// so Control+Option+Command+U force-deactivates even if the tap never installed.
let hotkey = EmergencyHotkey()
hotkey.register { [weak self] in
Log.event("emergency hotkey: force deactivate")
self?.deactivateNow()
self?.postNotification(title: "Curtain", body: "Curtain deactivated (emergency hotkey).")
}
emergencyHotkey = hotkey
}
// MARK: - Lifecycle
// MARK: - User notifications
private func sessionStarted() {
/// Throttled (~60s) prompt shown when an activation is refused because Accessibility
/// isn't granted. Without the tap the cover can't be unlocked at the desk.
private func notifyAccessibilityNeeded() {
Notifier.post(
title: "Curtain",
body: "Grant Accessibility to Curtain to use the privacy cover. System Settings > Privacy & Security > Accessibility.",
throttleKey: "accessibility-needed",
throttleSeconds: 60
)
}
private func postNotification(title: String, body: String) {
Notifier.post(title: title, body: body)
}
// MARK: - Monitor events (gated by armed)
private func sessionConnected() {
guard Settings.armed else { return }
// A real session wins over any in-flight test: keep whatever is on screen.
testTeardown?.cancel(); testTeardown = nil
guard Settings.onStartActivate else { return }
runner.activateCover()
onStateChange?(true)
// Grace window: a brief, flaky connection shouldn't flash the cover up.
connectGrace?.cancel()
let work = DispatchWorkItem { [weak self] in
self?.connectGrace = nil
// Re-check armed: the user may have disarmed while the work item was
// already executing (past the point where cancel() could stop it).
guard Settings.armed else { return }
self?.enterActive(notify: true)
}
connectGrace = work
DispatchQueue.main.asyncAfter(deadline: .now() + Double(Settings.connectGraceSeconds), execute: work)
}
private func sessionIdled() {
runner.run(Settings.onIdle)
onStateChange?(curtain.isShown)
guard Settings.armed, state == .active else { return }
Log.event("running idle actions")
applySet(Settings.onIdle)
}
private func sessionEnded() {
runner.run(Settings.onEnd)
guard Settings.armed else { return }
// If the session never made it past grace, just drop the pending activate.
connectGrace?.cancel(); connectGrace = nil
guard state == .active else { return }
Log.event("running end actions")
applySet(Settings.onEnd)
}
// MARK: - State machine
/// Raise the cover and move to .active. No-op if already active.
///
/// `requireAX` gates indefinite activations (real session + "Activate Now") on
/// Accessibility: without the event tap the cover both fails to block desk input
/// AND can't be unlocked at the desk, so it must never be shown. The bounded
/// `testCurtain(seconds:)` path passes `requireAX: false` it auto-deactivates
/// after its timeout, so it's a safe visual test even without AX.
private func enterActive(notify: Bool, requireAX: Bool = true) {
guard state == .idle else { return }
Log.event("activate requested")
if requireAX, !AXIsProcessTrusted() {
Log.event("activation refused: Accessibility not granted")
NSLog("Curtain: refusing to cover — Accessibility not granted (would trap the desk)")
notifyAccessibilityNeeded()
return
}
state = .active
runner.activateCover()
Log.event("cover activated")
onStateChange?(true)
if notify { announceActivation() }
}
/// Take the cover down and move to .idle. No-op if already idle.
private func enterIdle() {
guard state == .active else { return }
state = .idle
runner.deactivateCover()
onStateChange?(false)
}
/// Run a configured set, then resync our state to whatever the cover ended at.
/// This keeps idle/end actions (which may or may not deactivate) idempotent: if
/// the set tears the cover down we land in .idle; if it leaves it up we stay
/// .active so a later disconnect re-arms cleanly without double-running actions.
private func applySet(_ set: ActionSet) {
runner.run(set)
state = curtain.isShown ? .active : .idle
onStateChange?(curtain.isShown)
}
/// Host typed the correct password at the desk.
// MARK: - Password unlock (desk reveal)
/// Host typed the correct password at the desk. Order is load-bearing: if the
/// remote should be cut, sever it FIRST so the operator never sees the desktop,
/// and only then drop the cover. No modal that would freeze the runloop the
/// event tap rides on.
private func handlePasswordUnlock() {
runner.deactivateCover()
onStateChange?(false)
if Settings.onPasswordDisconnect {
let alert = NSAlert()
alert.messageText = "Unlocked at this Mac"
alert.informativeText = "Disconnect the active remote session?"
alert.addButton(withTitle: "Disconnect Remote")
alert.addButton(withTitle: "Keep Connected")
NSApp.activate(ignoringOtherApps: true)
if alert.runModal() == .alertFirstButtonReturn { System.endScreenShareSession() }
Log.event("password accepted; unlockDisconnect=\(Settings.unlockDisconnect)")
if Settings.unlockDisconnect {
System.endScreenShareSession()
}
enterIdle()
}
// MARK: - Manual controls (menu bar / settings)
// MARK: - Manual controls
func activateNow() { runner.activateCover(); onStateChange?(true) }
func deactivateNow() { runner.deactivateCover(); onStateChange?(false) }
var isActive: Bool { curtain.isShown }
func activateNow() {
connectGrace?.cancel(); connectGrace = nil
enterActive(notify: false)
}
func testCurtain(seconds: TimeInterval = 10) {
runner.activateCover(); onStateChange?(true)
DispatchQueue.main.asyncAfter(deadline: .now() + seconds) { [weak self] in
self?.runner.deactivateCover(); self?.onStateChange?(false)
/// Force the cover down with no password gate. Used internally and on quit.
func deactivateNow() {
connectGrace?.cancel(); connectGrace = nil
testTeardown?.cancel(); testTeardown = nil
enterIdle()
}
/// Menu-driven deactivate. If the setting requires a password and the cover is
/// up, refuse to deactivate, surface the on-curtain password box, and report
/// false. Otherwise deactivate and report true.
@discardableResult
func requestDeactivateFromMenu() -> Bool {
if Settings.requirePasswordToDeactivateFromMenu, state == .active, input.isTapInstalled {
// Keep the cover up: the tap is live, so a physical keypress raises the
// on-curtain password box that is the intended unlock path. If the tap
// is NOT installed (transient tap-create failure after the AX check), the
// box can never receive keys, so refusing the menu here would strand the
// desk with only the emergency hotkey; fall through and deactivate instead.
return false
}
deactivateNow()
return true
}
var isActive: Bool { state == .active }
var isArmed: Bool { Settings.armed }
/// Persist the armed flag. Disarming forces the cover down immediately so the
/// Mac is never left covered by a system the user just turned off. When the
/// user chose "Refuse to arm" for missing Accessibility, arming is rejected
/// outright (with a notification) rather than arming a system that could only
/// warn at connect time the cover would refuse to rise anyway.
func setArmed(_ on: Bool) {
if on, Settings.accessibilityRefuseToArm, !AXIsProcessTrusted() {
Log.event("arming refused: Accessibility not granted (refuseToArm)")
Notifier.post(title: "Curtain",
body: "Not armed: grant Accessibility first (Settings > Security).")
onArmedChange?(false)
return
}
Log.event("armed=\(on)")
Settings.armed = on
onArmedChange?(on)
if !on { deactivateNow() }
}
/// Briefly show the cover for a visual check. Cancelable, and a real connect
/// during the window cancels the teardown so we don't tear down a live session.
func testCurtain(seconds: TimeInterval) {
// Refuse to schedule a bounded test teardown while a REAL session has the
// cover up that would drop a live session's cover after the test delay.
guard state == .idle else { return }
testTeardown?.cancel()
// Bounded + auto-deactivating, so it's safe to show even without Accessibility.
enterActive(notify: false, requireAX: false)
let work = DispatchWorkItem { [weak self] in
self?.testTeardown = nil
self?.enterIdle()
}
testTeardown = work
DispatchQueue.main.asyncAfter(deadline: .now() + seconds, execute: work)
}
/// Persist the disconnect-helper toggle and reconcile the privileged daemon.
/// Registering/unregistering the LaunchDaemon and (re)installing the disconnect
/// handler is delegated to DisconnectClient, which is idempotent and logs errors.
func enableDisconnectHelper(_ on: Bool) {
// Settings exposes this flag read-only; persist via the shared defaults key.
UserDefaults.standard.set(on, forKey: Settings.Key.disconnectFeatureEnabled)
DisconnectClient.shared.setEnabled(on)
DisconnectClient.shared.syncWithSettings()
}
/// Cleanup path for app termination: drop the cover and release the assertion.
/// runner.deactivateCover() already releases the IOKit display-sleep assertion,
/// so a second direct call to System.allowDisplaySleep() here is redundant and
/// was removed to keep the release path in exactly one place.
func deactivateNowForQuit() {
connectGrace?.cancel(); connectGrace = nil
testTeardown?.cancel(); testTeardown = nil
runner.deactivateCover()
state = .idle
}
// MARK: - Activation feedback
private func announceActivation() {
if Settings.notifyOnActivate {
os_log("Curtain active: desk covered, physical input blocked")
// Surface a real banner via UNUserNotificationCenter so the user
// (or a test harness) can observe the activation event in Notification
// Center, not just the system log.
Notifier.post(title: "Curtain", body: "Privacy curtain activated.")
}
if Settings.playSoundOnActivate {
NSSound.beep()
}
}
}

View file

@ -1,11 +1,27 @@
import CoreGraphics
import Foundation
/// Purpose: Detect Screen Sharing connect / disconnect / idle and fire callbacks.
/// How: polls `netstat` for an ESTABLISHED connection on the VNC port (5900).
/// lsof does NOT work here the screensharing sockets are owned by _rmd/root
/// and are invisible to a user-context lsof (verified). Disconnect is debounced
/// (N consecutive misses) so a transient blip never kills a live session.
/// How: polls every 2s. The connect signal is `CaptureProbe.signals()`: the CGSession
/// capture key is authoritative; the fallback activators are an ESTABLISHED
/// inbound TCP session on port 5900 (classic VNC) and a peered UDP socket on
/// 5900-5902 (High-Performance transport). This replaces the old netstat-only
/// detection, which silently failed under macOS Sequoia/26 High-Performance
/// Screen Sharing (UDP, no ESTABLISHED state) and on remapped ports, and it
/// drops the old process / LISTEN-socket activators that lingered with no
/// session and kept the curtain up overnight. Disconnect is debounced
/// (3 consecutive misses, ~6s) so a transient blip never kills a live session.
/// Constraints: the class lives on the main thread (the Timer runs on the main
/// runloop and all state vars are main-thread-only). Probes block on shell
/// calls, so each tick runs the probes on a background queue and hops back to
/// the main thread before touching state or firing callbacks. A `probing` flag
/// coalesces ticks so a slow probe never overlaps the next one.
///
/// Console-vs-virtual stand-down is inherent: `combinedCaptureActive()` is
/// driven by the capture key, which reads false for a different-user virtual
/// session, so we simply never report a connect in that case.
/// SPORT: MASTER-SESSIONMONITOR
@MainActor
final class SessionMonitor {
var onConnect: (() -> Void)?
var onDisconnect: (() -> Void)?
@ -14,65 +30,118 @@ final class SessionMonitor {
private var timer: Timer?
private var connected = false
private var missCount = 0
private var probing = false
/// Last raw signal snapshot, kept only to log per-signal changes (which signal
/// actually saw the session) without spamming a line per tick.
private var lastSignals: CaptureProbe.CaptureSignals?
private var idleFired = false
private let missLimit = 3 // ~6s at 2s poll
/// Idle only counts once we have seen a sub-threshold reading. This prevents
/// firing on connect-time idle: if the user was already idle when the session
/// began, we wait for one reading below the threshold before arming.
private var idleArmed = false
private let missLimit = 3 // ~6s at the 2s poll interval
private let pollInterval: TimeInterval = 2
private let probeQueue = DispatchQueue(label: "com.curtain.session-monitor.probe", qos: .userInitiated)
func start() {
connected = isVNCEstablished()
if connected { onConnect?() }
timer = Timer.scheduledTimer(withTimeInterval: pollInterval, repeats: true) { [weak self] _ in
self?.tick()
Task { @MainActor in self?.tick() }
}
// Evaluate once immediately so a session already in progress is caught at start.
tick()
}
func stop() { timer?.invalidate(); timer = nil }
func stop() {
timer?.invalidate()
timer = nil
}
// MARK: - Poll
private func tick() {
if isVNCEstablished() {
missCount = 0
if !connected { connected = true; idleFired = false; onConnect?() }
if Settings.idleEnabled {
if !idleFired, idleSeconds() >= Settings.idleMinutes * 60 {
idleFired = true
onIdleTimeout?()
} else if idleSeconds() < Settings.idleMinutes * 60 {
idleFired = false
}
guard !probing else { return }
probing = true
probeQueue.async { [weak self] in
let signals = CaptureProbe.signals()
let idle = SessionMonitor.idleSeconds()
Task { @MainActor in
self?.apply(signals: signals, idle: idle)
}
}
}
private func apply(signals: CaptureProbe.CaptureSignals, idle: Int) {
defer { probing = false }
// Log raw signal changes (transitions only) so a live test shows exactly
// which signal saw the session and which one missed it.
if signals != lastSignals {
Log.event("signals: captured=\(signals.captured) tcpEstab=\(signals.tcpEstablished) udpPeered=\(signals.udpPeered)")
lastSignals = signals
}
if signals.any {
missCount = 0
if !connected {
connected = true
idleFired = false
idleArmed = false
Log.event("session detected: captured=\(signals.captured) tcpEstab=\(signals.tcpEstablished) udpPeered=\(signals.udpPeered) idle=\(idle)")
onConnect?()
}
evaluateIdle(idle)
} else if connected {
missCount += 1
if missCount >= missLimit { connected = false; missCount = 0; onDisconnect?() }
}
}
// MARK: - Probes
private func isVNCEstablished() -> Bool {
let out = shell("/usr/bin/netstat", ["-an"])
for line in out.split(separator: "\n") {
if line.contains(".5900 ") && line.contains("ESTABLISHED") { return true }
}
return false
}
/// Seconds since the last HID (human) input event.
private func idleSeconds() -> Int {
let out = shell("/usr/sbin/ioreg", ["-c", "IOHIDSystem"])
for line in out.split(separator: "\n") where line.contains("HIDIdleTime") {
if let ns = line.split(separator: "=").last.flatMap({ Int($0.trimmingCharacters(in: .whitespaces)) }) {
return ns / 1_000_000_000
if missCount >= missLimit {
connected = false
missCount = 0
Log.event("session ended (debounced)")
onDisconnect?()
}
}
return 0
}
private func shell(_ path: String, _ args: [String]) -> String {
let p = Process(); p.launchPath = path; p.arguments = args
let pipe = Pipe(); p.standardOutput = pipe; p.standardError = Pipe()
do { try p.run() } catch { return "" }
let data = pipe.fileHandleForReading.readDataToEndOfFile()
p.waitUntilExit()
return String(data: data, encoding: .utf8) ?? ""
private func evaluateIdle(_ idle: Int) {
guard Settings.idleEnabled else { return }
let threshold = Settings.idleMinutes * 60
if idle < threshold {
// Below threshold: arm the latch and clear any prior fire.
idleArmed = true
idleFired = false
return
}
// At or above threshold: only fire if we have armed since connect and
// have not already fired this idle stretch.
if idleArmed, !idleFired {
idleFired = true
Log.event("idle timeout fired")
onIdleTimeout?()
}
}
// MARK: - Idle source
/// Seconds since the last qualifying input event, from the event system rather
/// than ioreg.
///
/// Source selection (Settings.idleSourceIsHID):
/// - `false` (default "sessionInput"): `.combinedSessionState` counts activity
/// from ALL sources in the session, including the remote operator. This is the
/// product default because idle-on-session should respond to operator inactivity,
/// not just physical desk input.
/// - `true` ("hidIdle"): `.hidSystemState` counts only physical HID events at
/// the desk. Remote operator activity is invisible to this source; the idle
/// clock ticks even while the operator types remotely.
nonisolated private static func idleSeconds() -> Int {
let source: CGEventSourceStateID = Settings.idleSourceIsHID ? .hidSystemState : .combinedSessionState
let seconds = CGEventSource.secondsSinceLastEventType(source, eventType: .null)
guard seconds.isFinite, seconds > 0 else { return 0 }
return Int(seconds)
}
}

View file

@ -1,48 +1,101 @@
import Foundation
import CryptoKit
import CommonCrypto
/// Purpose: Single source of truth for every Curtain preference, backed by
/// UserDefaults so the SwiftUI settings view (@AppStorage) and the
/// headless coordinator read/write the exact same keys.
/// Inputs: none (reads/writes the standard user defaults under the keys below).
/// Outputs: typed get/set accessors + password helpers.
/// Constraints: password is stored as a salted SHA256 hash, never plaintext.
/// Outputs: typed get/set accessors, password helpers, lockout backoff state.
/// Constraints: passwords are stored as a PBKDF2-HMAC-SHA256 derived key, never
/// plaintext. Every default is chosen so the user can never be locked
/// out: the fallback password "curtain" always works when no hash is
/// set, and the disconnect feature ships off. Settings is a plain
/// enum of static funcs over the thread-safe UserDefaults.standard,
/// so it is safe to call from any thread under Swift 6 concurrency.
/// SPORT: MASTER-SETTINGS
enum Settings {
/// Defaults key strings. The SwiftUI view binds to these same strings via @AppStorage.
enum Key {
static let launchAtLogin = "launchAtLogin"
static let showInMenuBar = "showInMenuBar"
// On session start
static let onStartActivate = "onStart.activateCurtain"
// On idle
static let idleEnabled = "idle.enabled"
static let idleMinutes = "idle.minutes"
static let onIdleDisconnect = "onIdle.disconnect"
static let onIdleLock = "onIdle.lock"
static let onIdleScreenOff = "onIdle.screenOff"
static let onIdleDeactivate = "onIdle.deactivate"
// On session end (disconnect)
static let onEndLock = "onEnd.lock"
static let onEndScreenOff = "onEnd.screenOff"
static let onEndDeactivate = "onEnd.deactivate"
// On password entered at the desk
static let onPasswordDisconnect = "onPassword.disconnect"
// Security + displays
static let passwordHash = "password.hash"
static let passwordSalt = "password.salt"
static let displayLinkSerials = "displayLinkSerials"
// General
static let armed = "armed"
static let launchAtLogin = "launchAtLogin"
static let showInMenuBar = "showInMenuBar"
// Activation
static let onStartActivate = "onStart.activateCurtain"
static let connectGraceSeconds = "connect.graceSeconds"
static let notifyOnActivate = "notifyOnActivate"
static let playSoundOnActivate = "playSoundOnActivate"
// Reveal trigger (how the desk user pops the password box)
static let revealTrigger = "reveal.trigger" // "anyKey" | "keyCombo"
static let revealKeyCombo = "reveal.keyCombo" // e.g. "cmd+shift+L"; empty when anyKey
// Appearance
// cover.style accepts: "solidColor" | "message" | "blur" | "logo" | "curtainLogo" | "aerial"
// ("screensaver" is legacy and is treated as "logo".)
static let coverStyle = "cover.style"
static let coverColor = "cover.color"
static let coverMessage = "cover.message"
static let coverShowClock = "cover.showClock"
// Idle
static let idleEnabled = "idle.enabled"
static let idleMinutes = "idle.minutes"
static let idleSource = "idle.source"
static let onIdleDisconnect = "onIdle.disconnect"
static let onIdleLock = "onIdle.lock"
static let onIdleScreenOff = "onIdle.screenOff"
static let onIdleDeactivate = "onIdle.deactivate"
// End (disconnect)
static let onEndLock = "onEnd.lock"
static let onEndScreenOff = "onEnd.screenOff"
static let onEndDeactivate = "onEnd.deactivate"
// Security
static let onPasswordDisconnect = "onPassword.disconnect" // legacy bool, kept readable
static let onUnlockAction = "onUnlock.action" // "keepSession" | "disconnect"
static let migratedOnUnlock = "migrated.onUnlock" // one-time migration guard
static let migratedCoverScope = "migrated.coverScope" // one-time migration guard
static let passwordBoxTimeoutSeconds = "password.boxTimeoutSeconds"
static let requirePasswordToDeactivateFromMenu = "requirePasswordToDeactivateFromMenu"
static let accessibilityMissingBehavior = "accessibility.missingBehavior"
// Disconnect feature
static let disconnectFeatureEnabled = "disconnect.featureEnabled"
// Displays
static let displayLinkUUIDs = "displayLinkUUIDs"
static let displayLinkSerials = "displayLinkSerials" // legacy [Int], read-only fallback
static let perDisplayCoverDisabled = "perDisplayCoverDisabled"
static let coverScope = "cover.scope"
static let passwordBoxPlacement = "passwordBox.placement"
static let passwordBoxSpecificUUID = "passwordBox.specificUUID"
static let newDisplayPolicy = "newDisplay.policy"
// Advanced
static let diagnosticsLoggingEnabled = "diagnostics.loggingEnabled"
static let hasOnboarded = "hasOnboarded"
// Password (PBKDF2)
static let passwordAlgo = "password.algo"
static let passwordSalt = "password.salt"
static let passwordIterations = "password.iterations"
static let passwordHash = "password.hash"
}
/// Register sensible defaults once at launch.
/// Register sensible, safe defaults once at launch.
static func registerDefaults() {
UserDefaults.standard.register(defaults: [
Key.armed: true,
Key.launchAtLogin: true,
Key.showInMenuBar: true,
Key.revealTrigger: "anyKey",
Key.revealKeyCombo: "",
Key.onUnlockAction: "disconnect",
Key.onStartActivate: true,
Key.connectGraceSeconds: 2,
Key.notifyOnActivate: true,
Key.playSoundOnActivate: false,
Key.coverStyle: "logo",
Key.coverColor: "#000000",
Key.coverMessage: "",
Key.coverShowClock: false,
Key.idleEnabled: true,
Key.idleMinutes: 30,
Key.idleSource: "sessionInput",
Key.onIdleDisconnect: true,
Key.onIdleLock: true,
Key.onIdleScreenOff: true,
@ -51,17 +104,77 @@ enum Settings {
Key.onEndScreenOff: true,
Key.onEndDeactivate: true,
Key.onPasswordDisconnect: true,
Key.passwordBoxTimeoutSeconds: 15,
Key.requirePasswordToDeactivateFromMenu: false,
Key.accessibilityMissingBehavior: "warn",
Key.disconnectFeatureEnabled: false,
Key.coverScope: "all",
Key.passwordBoxPlacement: "followActive",
Key.passwordBoxSpecificUUID: "",
Key.newDisplayPolicy: "cover",
Key.diagnosticsLoggingEnabled: false,
Key.hasOnboarded: false,
Key.passwordIterations: 200_000,
])
migrateOnUnlockAction()
migrateCoverScope()
}
/// One-time migration: the old boolean `onPassword.disconnect` is replaced by the
/// explicit `onUnlock.action` choice. If the user had disconnect turned on and never
/// set the new key, carry that intent forward. Guarded so it runs at most once.
private static func migrateOnUnlockAction() {
guard !d.bool(forKey: Key.migratedOnUnlock) else { return }
let legacyOn = d.object(forKey: Key.onPasswordDisconnect) != nil
&& d.bool(forKey: Key.onPasswordDisconnect)
let newSet = d.object(forKey: Key.onUnlockAction) != nil
if legacyOn && !newSet {
d.set("disconnect", forKey: Key.onUnlockAction)
}
d.set(true, forKey: Key.migratedOnUnlock)
}
/// One-time migration: the old "onlyMarked" / "allExceptMarked" cover-scope values are
/// collapsed into "perDisplay", which uses the per-display Cover toggle as the authority.
/// Guarded so it runs at most once.
private static func migrateCoverScope() {
guard !d.bool(forKey: Key.migratedCoverScope) else { return }
let legacy = d.string(forKey: Key.coverScope) ?? ""
if legacy == "onlyMarked" || legacy == "allExceptMarked" {
d.set("perDisplay", forKey: Key.coverScope)
}
d.set(true, forKey: Key.migratedCoverScope)
}
// MARK: - Typed accessors (headless side)
private static let d = UserDefaults.standard
// MARK: - General
static var armed: Bool { get { d.bool(forKey: Key.armed) } set { d.set(newValue, forKey: Key.armed) } }
static var launchAtLogin: Bool { get { d.bool(forKey: Key.launchAtLogin) } set { d.set(newValue, forKey: Key.launchAtLogin) } }
static var showInMenuBar: Bool { get { d.bool(forKey: Key.showInMenuBar) } set { d.set(newValue, forKey: Key.showInMenuBar) } }
// MARK: - Reveal trigger
static var revealTrigger: String { get { d.string(forKey: Key.revealTrigger) ?? "anyKey" } set { d.set(newValue, forKey: Key.revealTrigger) } }
static var revealKeyCombo: String { get { d.string(forKey: Key.revealKeyCombo) ?? "" } set { d.set(newValue, forKey: Key.revealKeyCombo) } }
/// True when any keypress should pop the password box; false only when a specific combo is required.
static var revealOnAnyKey: Bool { revealTrigger != "keyCombo" }
// MARK: - Activation
static var onStartActivate: Bool { d.bool(forKey: Key.onStartActivate) }
static var connectGraceSeconds: Int { clamp(d.integer(forKey: Key.connectGraceSeconds), 0, 30) }
static var notifyOnActivate: Bool { d.bool(forKey: Key.notifyOnActivate) }
static var playSoundOnActivate: Bool { d.bool(forKey: Key.playSoundOnActivate) }
// MARK: - Appearance
static var coverStyle: String { d.string(forKey: Key.coverStyle) ?? "logo" }
static var coverColorHex: String { d.string(forKey: Key.coverColor) ?? "#000000" }
static var coverMessage: String { d.string(forKey: Key.coverMessage) ?? "" }
static var coverShowClock: Bool { d.bool(forKey: Key.coverShowClock) }
// MARK: - Idle
static var idleEnabled: Bool { d.bool(forKey: Key.idleEnabled) }
static var idleMinutes: Int { max(1, d.integer(forKey: Key.idleMinutes)) }
static var onPasswordDisconnect: Bool { d.bool(forKey: Key.onPasswordDisconnect) }
static var idleMinutes: Int { clamp(d.integer(forKey: Key.idleMinutes), 1, 240) }
static var idleSourceIsHID: Bool { (d.string(forKey: Key.idleSource) ?? "hidIdle") == "hidIdle" }
static var onIdle: ActionSet {
ActionSet(disconnect: d.bool(forKey: Key.onIdleDisconnect),
@ -76,30 +189,176 @@ enum Settings {
deactivateCurtain: d.bool(forKey: Key.onEndDeactivate))
}
static var displayLinkSerials: [UInt32] {
get { (d.array(forKey: Key.displayLinkSerials) as? [Int])?.map { UInt32(truncatingIfNeeded: $0) } ?? [] }
set { d.set(newValue.map { Int($0) }, forKey: Key.displayLinkSerials) }
// MARK: - Security
static var onPasswordDisconnect: Bool { d.bool(forKey: Key.onPasswordDisconnect) }
/// On curtain unlock: drop the remote session, or keep it running. Replaces onPasswordDisconnect.
static var unlockDisconnect: Bool { d.string(forKey: Key.onUnlockAction) == "disconnect" }
static var passwordBoxTimeoutSeconds: Int { clamp(d.integer(forKey: Key.passwordBoxTimeoutSeconds), 5, 60) }
static var requirePasswordToDeactivateFromMenu: Bool { d.bool(forKey: Key.requirePasswordToDeactivateFromMenu) }
static var accessibilityRefuseToArm: Bool { (d.string(forKey: Key.accessibilityMissingBehavior) ?? "warn") == "refuseToArm" }
// MARK: - Disconnect feature
static var disconnectFeatureEnabled: Bool { d.bool(forKey: Key.disconnectFeatureEnabled) }
// MARK: - Displays
static var displayLinkUUIDs: [String] {
get { (d.array(forKey: Key.displayLinkUUIDs) as? [String]) ?? [] }
set { d.set(newValue, forKey: Key.displayLinkUUIDs) }
}
/// Legacy serials kept for read-only `isDisplayLink` fallback during migration.
static var legacyDisplayLinkSerials: [UInt32] {
(d.array(forKey: Key.displayLinkSerials) as? [Int])?.map { UInt32(truncatingIfNeeded: $0) } ?? []
}
static var perDisplayCoverDisabled: [String] {
get { (d.array(forKey: Key.perDisplayCoverDisabled) as? [String]) ?? [] }
set { d.set(newValue, forKey: Key.perDisplayCoverDisabled) }
}
static var coverScope: String { d.string(forKey: Key.coverScope) ?? "all" }
static var passwordBoxPlacement: String { d.string(forKey: Key.passwordBoxPlacement) ?? "followActive" }
static var passwordBoxSpecificUUID: String { d.string(forKey: Key.passwordBoxSpecificUUID) ?? "" }
static var newDisplayPolicy: String { d.string(forKey: Key.newDisplayPolicy) ?? "cover" }
/// One-time migration: if a display has no UUID record yet but its serial is
/// in the legacy list, the caller can use this to treat it as a DisplayLink.
static func isLegacyDisplayLink(serial: UInt32) -> Bool {
legacyDisplayLinkSerials.contains(serial)
}
// MARK: - Password
// MARK: - Advanced
static var diagnosticsLoggingEnabled: Bool { d.bool(forKey: Key.diagnosticsLoggingEnabled) }
static var hasOnboarded: Bool { get { d.bool(forKey: Key.hasOnboarded) } set { d.set(newValue, forKey: Key.hasOnboarded) } }
// MARK: - Password (PBKDF2-HMAC-SHA256)
/// Derive and store a new password. Generates a fresh 16-byte salt each time.
static func setPassword(_ plain: String) {
var salt = d.string(forKey: Key.passwordSalt) ?? ""
if salt.isEmpty { salt = randomSalt(); d.set(salt, forKey: Key.passwordSalt) }
d.set(hash(plain, salt: salt), forKey: Key.passwordHash)
let salt = randomBytes(16)
let iterations = currentIterations()
let derived = pbkdf2(password: plain, salt: salt, iterations: iterations)
d.set("pbkdf2", forKey: Key.passwordAlgo)
d.set(hex(salt), forKey: Key.passwordSalt)
d.set(iterations, forKey: Key.passwordIterations)
d.set(hex(derived), forKey: Key.passwordHash)
}
/// Verify a candidate. If no password is set, the built-in "curtain" is accepted
/// so the Mac is never unrecoverable.
/// Verify a candidate. When no password is set, the built-in "curtain" is
/// accepted so the Mac is never unrecoverable. A legacy salted-SHA256 hash is
/// verified once and, on success, transparently upgraded to PBKDF2.
static func verify(_ candidate: String) -> Bool {
let stored = d.string(forKey: Key.passwordHash) ?? ""
if stored.isEmpty { return candidate == "curtain" }
return hash(candidate, salt: d.string(forKey: Key.passwordSalt) ?? "") == stored
let storedHash = d.string(forKey: Key.passwordHash) ?? ""
if storedHash.isEmpty { return candidate == "curtain" }
let algo = d.string(forKey: Key.passwordAlgo) ?? ""
if algo == "pbkdf2" {
guard let saltBytes = bytes(fromHex: d.string(forKey: Key.passwordSalt) ?? "") else { return false }
let iterations = currentIterations()
let derived = pbkdf2(password: candidate, salt: saltBytes, iterations: iterations)
return constantTimeEquals(hex(derived), storedHash)
}
// Legacy salted-SHA256: verify against the old scheme, then upgrade on success.
let legacySalt = d.string(forKey: Key.passwordSalt) ?? ""
if constantTimeEquals(legacySHA256(candidate, salt: legacySalt), storedHash) {
setPassword(candidate)
return true
}
return false
}
static var hasPassword: Bool { !(d.string(forKey: Key.passwordHash) ?? "").isEmpty }
private static func randomSalt() -> String {
(0..<16).map { _ in String(format: "%02x", UInt8.random(in: 0...255)) }.joined()
private static func currentIterations() -> Int {
max(100_000, d.integer(forKey: Key.passwordIterations))
}
private static func hash(_ s: String, salt: String) -> String {
SHA256.hash(data: Data((salt + s).utf8)).map { String(format: "%02x", $0) }.joined()
// MARK: - Attempt backoff (in-memory, self-contained)
nonisolated(unsafe) private static var failureCount = 0
nonisolated(unsafe) private static var lockoutUntil: Date?
static func registerFailedAttempt() {
failureCount += 1
// Exponential: 1s, 2s, 4s, ... capped at 30s, starting after 3 misses.
guard failureCount >= 3 else { return }
let delay = min(30.0, pow(2.0, Double(failureCount - 3)))
lockoutUntil = Date().addingTimeInterval(delay)
}
static func resetFailedAttempts() {
failureCount = 0
lockoutUntil = nil
}
static var isLockedOut: Bool { backoffRemaining > 0 }
static var backoffRemaining: TimeInterval {
guard let until = lockoutUntil else { return 0 }
return max(0, until.timeIntervalSinceNow)
}
// MARK: - Crypto helpers
private static func pbkdf2(password: String, salt: [UInt8], iterations: Int) -> [UInt8] {
let pw = Array(password.utf8)
var out = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH))
_ = pw.withUnsafeBufferPointer { pwPtr in
salt.withUnsafeBufferPointer { saltPtr in
CCKeyDerivationPBKDF(
CCPBKDFAlgorithm(kCCPBKDF2),
pwPtr.baseAddress, pwPtr.count,
saltPtr.baseAddress, saltPtr.count,
CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA256),
UInt32(iterations),
&out, out.count)
}
}
return out
}
private static func legacySHA256(_ s: String, salt: String) -> String {
var out = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH))
let data = Array((salt + s).utf8)
data.withUnsafeBufferPointer { CC_SHA256($0.baseAddress, CC_LONG($0.count), &out) }
return hex(out)
}
private static func randomBytes(_ count: Int) -> [UInt8] {
var bytes = [UInt8](repeating: 0, count: count)
if SecRandomCopyBytes(kSecRandomDefault, count, &bytes) != errSecSuccess {
for i in 0..<count { bytes[i] = UInt8.random(in: 0...255) }
}
return bytes
}
private static func hex(_ bytes: [UInt8]) -> String {
bytes.map { String(format: "%02x", $0) }.joined()
}
private static func bytes(fromHex s: String) -> [UInt8]? {
guard s.count % 2 == 0 else { return nil }
var out = [UInt8](); out.reserveCapacity(s.count / 2)
var idx = s.startIndex
while idx < s.endIndex {
let next = s.index(idx, offsetBy: 2)
guard let byte = UInt8(s[idx..<next], radix: 16) else { return nil }
out.append(byte)
idx = next
}
return out
}
/// Length-aware constant-time string compare to avoid timing leaks on the hash.
private static func constantTimeEquals(_ a: String, _ b: String) -> Bool {
let av = Array(a.utf8), bv = Array(b.utf8)
var diff = av.count ^ bv.count
let n = max(av.count, bv.count)
for i in 0..<n {
let x = i < av.count ? av[i] : 0
let y = i < bv.count ? bv[i] : 0
diff |= Int(x ^ y)
}
return diff == 0
}
private static func clamp(_ v: Int, _ lo: Int, _ hi: Int) -> Int { min(max(v, lo), hi) }
}

View file

@ -12,35 +12,60 @@ enum System {
// Accessibility and is unreliable from a launchd agent. SACLockScreenImmediate
// (private login.framework symbol) locks immediately with no extra permission.
static func lockScreen() {
let paths = [
"/System/Library/PrivateFrameworks/login.framework/Versions/Current/login",
"/System/Library/PrivateFrameworks/login.framework/login"
]
private static let loginPaths = [
"/System/Library/PrivateFrameworks/login.framework/Versions/Current/login",
"/System/Library/PrivateFrameworks/login.framework/login"
]
/// Resolve the private lock symbol without calling it. Returns the function
/// pointer if found, nil otherwise. Both callers use this so probing and
/// locking stay in sync.
private static func resolveLockFn() -> (@convention(c) () -> Int32)? {
typealias LockFn = @convention(c) () -> Int32
for p in paths {
for p in loginPaths {
if let h = dlopen(p, RTLD_LAZY), let sym = dlsym(h, "SACLockScreenImmediate") {
_ = unsafeBitCast(sym, to: LockFn.self)()
return
return unsafeBitCast(sym, to: LockFn.self)
}
}
return nil
}
/// Call once at launch to surface a clear warning if the fast lock path is
/// missing on this OS build, before the user ever relies on it.
static func startupLockProbe() {
if resolveLockFn() == nil {
NSLog("Curtain: SACLockScreenImmediate unavailable — lock will fall back to osascript")
}
}
static func lockScreen() {
if let lock = resolveLockFn() {
_ = lock()
return
}
NSLog("Curtain: SACLockScreenImmediate could not be resolved on either login.framework path — falling back to osascript")
// Fallback (needs Accessibility): the lock-screen shortcut.
let t = Process()
t.launchPath = "/usr/bin/osascript"
t.executableURL = URL(fileURLWithPath: "/usr/bin/osascript")
t.arguments = ["-e", "tell application \"System Events\" to keystroke \"q\" using {command down, control down}"]
try? t.run()
}
/// Put all displays to sleep (after a lock = a dark, locked Mac).
/// Runs off the main thread so a slow exec never stalls the UI.
static func sleepDisplays() {
let t = Process(); t.launchPath = "/usr/bin/pmset"; t.arguments = ["displaysleepnow"]
try? t.run()
DispatchQueue.global(qos: .userInitiated).async {
let t = Process()
t.executableURL = URL(fileURLWithPath: "/usr/bin/pmset")
t.arguments = ["displaysleepnow"]
try? t.run()
}
}
// MARK: - Prevent display sleep during a session
private static var assertionID: IOPMAssertionID = 0
private static var assertionActive = false
nonisolated(unsafe) private static var assertionID: IOPMAssertionID = 0
nonisolated(unsafe) private static var assertionActive = false
static func preventDisplaySleep() {
guard !assertionActive else { return }
@ -58,15 +83,21 @@ enum System {
// MARK: - End the active Screen Sharing session
//
// Killing the connection processes needs root, so install.sh drops a tiny
// helper at /usr/local/bin/curtain-endsession with a NOPASSWD sudoers rule.
// launchd respawns the listener, so Screen Sharing stays available afterward.
// Killing the connection processes needs root. The disconnect feature is an
// optional privileged daemon installed separately (SMAppService.daemon). The
// daemon client sets disconnectHandler at launch; if nothing sets it, the
// disconnect is simply a no-op with a logged note. No sudo, no blocking.
/// Set by the daemon client when the privileged disconnect helper is enabled.
/// Invoked on a background queue so it never touches the main thread.
nonisolated(unsafe) static var disconnectHandler: (() -> Void)?
static func endScreenShareSession() {
let helper = "/usr/local/bin/curtain-endsession"
guard FileManager.default.isExecutableFile(atPath: helper) else { return }
let t = Process(); t.launchPath = "/usr/bin/sudo"; t.arguments = ["-n", helper]
try? t.run(); t.waitUntilExit()
if let handler = disconnectHandler {
DispatchQueue.global(qos: .userInitiated).async { handler() }
return
}
NSLog("Curtain: disconnect requested but the remote-disconnect helper is not enabled")
}
// MARK: - Displays
@ -76,11 +107,27 @@ enum System {
return CGDisplaySerialNumber(id)
}
/// Stable per-display UUID. Survives reboots and port changes better than the
/// serial, and unlike the serial it is unique even when EDID passthrough makes
/// vendor IDs identical. Returns nil if the display can't be resolved.
static func uuid(of screen: NSScreen) -> String? {
guard let num = screen.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? CGDirectDisplayID else {
return nil
}
guard let cfUUID = CGDisplayCreateUUIDFromDisplayID(num)?.takeRetainedValue() else {
return nil
}
return CFUUIDCreateString(nil, cfUUID) as String
}
/// A native display can be hidden invisibly (sharingType .none). A DisplayLink
/// display only exists via screen capture, so .none hides it from the capture
/// too it must use .readOnly (visible in the remote view). We identify them
/// by serial because EDID passthrough makes vendor IDs identical.
/// too it must use .readOnly (visible in the remote view). We match by UUID
/// now, falling back to the legacy serial list so older configs keep working.
static func isDisplayLink(_ screen: NSScreen) -> Bool {
Settings.displayLinkSerials.contains(serial(of: screen))
if let id = uuid(of: screen) {
return Settings.displayLinkUUIDs.contains(id)
}
return Settings.legacyDisplayLinkSerials.contains(serial(of: screen))
}
}

View file

@ -13,8 +13,26 @@ if CommandLine.arguments.contains("--render-icon"),
exit(0)
}
let app = NSApplication.shared
let delegate = AppDelegate()
app.delegate = delegate
app.setActivationPolicy(.accessory) // background agent; settings window still shows
app.run()
// The AppKit bootstrap is main-actor work; top-level code is nonisolated under Swift 6,
// so run it inside an assumeIsolated block (process start is already on the main thread).
// app.run() blocks here and never returns, so `delegate` and the signal source stay
// retained on this stack. NSApplication.delegate is weak, so `delegate` must be held.
MainActor.assumeIsolated {
let app = NSApplication.shared
let delegate = AppDelegate()
app.delegate = delegate
app.setActivationPolicy(.accessory) // background agent; settings window still shows
// SIGTERM (launchd stop / `kill`): a C signal handler can't safely touch AppKit, so
// ignore the default action and route the signal through a DispatchSource on the main
// queue, where it can run cleanup before exiting.
signal(SIGTERM, SIG_IGN)
let sigtermSource = DispatchSource.makeSignalSource(signal: SIGTERM, queue: .main)
sigtermSource.setEventHandler {
MainActor.assumeIsolated { delegate.cleanup() }
exit(0)
}
sigtermSource.resume()
app.run()
}

View file

@ -0,0 +1,169 @@
import Foundation
import Security
import CurtainShared
/// Purpose: The privileged LaunchDaemon. Runs as root (installed via
/// SMAppService.daemon), vends the CurtainDisconnectXPC interface over a
/// mach service, and ends the active remote Screen Sharing session by
/// signalling the connection processes that a user process cannot touch.
/// Inputs: XPC connections from the Curtain app on the well-known mach service.
/// Outputs: Replies true/false over XPC; sends SIGTERM to screensharingd and the
/// subscriber processes (launchd respawns the idle listener).
/// Constraints: Separate process with its own entry point. Daemon never returns
/// from main it runs the run loop forever. The listener delegate is
/// nonisolated (XPC callbacks arrive off the main actor). Every
/// incoming connection is validated against a Developer-ID code
/// requirement before it is accepted.
/// SPORT: MASTER-DISCONNECT
/// Implements the XPC contract. One instance is exported per accepted connection.
final class DisconnectService: NSObject, CurtainDisconnectXPC, @unchecked Sendable {
func endScreenSharingSession(reply: @escaping (Bool) -> Void) {
// launchd owns the screensharingd listener and respawns it, so terminating
// these processes drops the live session without killing the service itself.
let targets: [[String]] = [
["pkill", "-f", "ScreenSharingSubscriber"],
["pkill", "-x", "screensharingd"],
["pkill", "-f", "RemoteManagement.*[Ss]creen"],
]
var matched = false
for argv in targets where runMatched(argv) { matched = true }
reply(matched)
}
/// Run a pkill-style command. Returns true when pkill exits 0 (a process matched).
private func runMatched(_ argv: [String]) -> Bool {
guard let tool = argv.first else { return false }
let p = Process()
p.executableURL = URL(fileURLWithPath: "/usr/bin/" + tool)
p.arguments = Array(argv.dropFirst())
do {
try p.run()
p.waitUntilExit()
return p.terminationStatus == 0
} catch {
NSLog("CurtainHelper: failed to run %@: %@", tool, String(describing: error))
return false
}
}
}
/// Accepts XPC connections only from a copy of the Curtain app signed by the same
/// Developer-ID identity. Rejects everything else.
final class ListenerDelegate: NSObject, NSXPCListenerDelegate {
nonisolated func listener(_ listener: NSXPCListener,
shouldAcceptNewConnection conn: NSXPCConnection) -> Bool {
guard isTrustedCaller(conn) else {
NSLog("CurtainHelper: rejected connection — caller failed code-signature check")
return false
}
conn.exportedInterface = NSXPCInterface(with: CurtainDisconnectXPC.self)
conn.exportedObject = DisconnectService()
conn.resume()
return true
}
/// Validate the connecting client by PID: the caller must be a valid code signed
/// with our bundle identifier and an Apple-issued anchor, AND when both sides
/// expose a Team ID the Team IDs must match. This means only a properly-signed
/// copy of Curtain (our identity) can drive the privileged helper. (NSXPCConnection
/// does not surface its audit token to Swift, so we resolve the guest code from the
/// connecting PID, which the kernel reports for the live XPC peer.)
///
/// SECURITY NOTE PID-reuse TOCTOU race: SecCodeCopyGuestWithAttributes keyed on
/// a PID is inherently subject to a time-of-check/time-of-use race. Between the
/// moment the kernel delivers the PID to our listener and the moment we call
/// SecCodeCopyGuestWithAttributes, the original process could exit and a different
/// (potentially hostile) process could occupy the same PID. The gold-standard fix is
/// to key on the XPC audit token instead, but NSXPCConnection does not expose its
/// audit token via a public Swift API in the current SDK. Migrating to an
/// audit-token-keyed check (via a private/ObjC bridging shim or a future SDK
/// addition) is queued for the notarized public build at that point the Helper will
/// also carry a Developer-ID signature, making the Team-ID pinning the primary
/// guard and the audit token the defense-in-depth layer. Until then, the PID check
/// is the best available option and the risk is accepted under the assumption that
/// only trusted admins install the daemon.
///
/// Relaxation path for local dev ONLY: an ad-hoc / unsigned build has no Team ID.
/// When neither this helper nor the caller has a Team ID, we fall back to the
/// identifier+anchor check alone so a locally-built ad-hoc app can still be tested.
/// That relaxation is LOGGED loudly. In the signed case (this helper has a Team ID)
/// a Team-ID mismatch or a missing caller Team ID is always rejected.
private func isTrustedCaller(_ conn: NSXPCConnection) -> Bool {
let attrs: [String: Any] = [
kSecGuestAttributePid as String: NSNumber(value: conn.processIdentifier)
]
var code: SecCode?
guard SecCodeCopyGuestWithAttributes(nil, attrs as CFDictionary, [], &code) == errSecSuccess,
let guestCode = code else {
return false
}
let ourTeamID = Self.ownTeamID()
let callerTeamID = Self.teamID(of: guestCode)
// Build the code-signing requirement. When a Team ID is available on both
// sides, pin it so only our signed identity is accepted.
var reqString = "identifier \"io.acamarata.curtain\" and anchor apple generic"
if let ourTeamID, !ourTeamID.isEmpty {
// Signed helper: require the caller to carry a matching Team ID.
guard let callerTeamID, callerTeamID == ourTeamID else {
NSLog("CurtainHelper: rejected connection — caller Team ID mismatch (ours=%@, caller=%@)",
ourTeamID, callerTeamID ?? "<none>")
return false
}
reqString += " and certificate leaf[subject.OU] = \"\(ourTeamID)\""
} else {
// Ad-hoc / unsigned local dev: no Team ID on this helper. Accept the
// identifier+anchor check alone, and log the relaxation loudly.
NSLog("CurtainHelper: WARNING — accepting caller without Team-ID pinning (ad-hoc/local dev build)")
}
var requirement: SecRequirement?
guard SecRequirementCreateWithString(reqString as CFString, [], &requirement) == errSecSuccess,
let req = requirement else {
return false
}
return SecCodeCheckValidity(guestCode, [], req) == errSecSuccess
}
/// Team ID of this running helper, or nil for an ad-hoc/unsigned build.
private static func ownTeamID() -> String? {
var codeRef: SecCode?
guard SecCodeCopySelf([], &codeRef) == errSecSuccess, let code = codeRef else { return nil }
var staticRef: SecStaticCode?
guard SecCodeCopyStaticCode(code, [], &staticRef) == errSecSuccess,
let staticCode = staticRef else { return nil }
return teamID(ofStatic: staticCode)
}
/// Team ID of a connecting guest code, or nil if it carries none (ad-hoc/unsigned).
private static func teamID(of code: SecCode) -> String? {
var staticRef: SecStaticCode?
guard SecCodeCopyStaticCode(code, [], &staticRef) == errSecSuccess,
let staticCode = staticRef else { return nil }
return teamID(ofStatic: staticCode)
}
private static func teamID(ofStatic staticCode: SecStaticCode) -> String? {
var infoRef: CFDictionary?
guard SecCodeCopySigningInformation(staticCode,
SecCSFlags(rawValue: kSecCSSigningInformation),
&infoRef) == errSecSuccess,
let info = infoRef as? [String: Any],
let teamID = info[kSecCodeInfoTeamIdentifier as String] as? String,
!teamID.isEmpty else {
return nil
}
return teamID
}
}
let delegate = ListenerDelegate()
let listener = NSXPCListener(machServiceName: CurtainHelperInfo.machServiceName)
listener.delegate = delegate
listener.resume()
NSLog("CurtainHelper: listening on %@", CurtainHelperInfo.machServiceName)
// Daemons never return from main; park on the run loop forever.
RunLoop.current.run()

View file

@ -0,0 +1,27 @@
import Foundation
/// Purpose: Shared XPC contract between the Curtain app (client) and the privileged
/// CurtainHelper daemon (service). Both targets depend on CurtainShared so
/// the protocol and identifiers stay in exactly one place.
/// Inputs: None this is a declaration unit, not a runtime component.
/// Outputs: The `CurtainDisconnectXPC` remote-object interface and the well-known
/// mach-service / daemon-plist names.
/// Constraints: The protocol is `@objc` because NSXPCInterface requires an
/// Objective-C-visible protocol. The single method is async-by-reply.
/// SPORT: MASTER-DISCONNECT
/// Remote object interface the helper vends and the app calls.
@objc public protocol CurtainDisconnectXPC {
/// Ask the privileged helper to end the active remote Screen Sharing session.
/// `reply(true)` if at least one connection process was matched and signalled.
func endScreenSharingSession(reply: @escaping (Bool) -> Void)
}
/// Well-known identifiers shared by the app and the helper. The mach service name
/// is what the daemon's `NSXPCListener` registers and what the client connects to;
/// the plist name is what `SMAppService.daemon(plistName:)` looks up under
/// `Contents/Library/LaunchDaemons/` of the app bundle.
public enum CurtainHelperInfo {
public static let machServiceName = "io.acamarata.curtain.helper"
public static let daemonPlistName = "io.acamarata.curtain.helper.plist"
}

View file

@ -0,0 +1,26 @@
import Foundation
/// Purpose: convert between a "#rrggbb" hex string and normalized (0...1) RGB
/// components. Pure Foundation so it can be shared between the headless
/// cover renderer and the preferences ColorPicker bridge without AppKit.
public enum HexColor {
/// Parse a "#rrggbb" (or "rrggbb") string into normalized RGB components.
/// Returns nil for malformed input.
public static func toRGB(_ hex: String) -> (r: Double, g: Double, b: Double)? {
var s = hex
if s.hasPrefix("#") { s.removeFirst() }
guard s.count == 6, let v = UInt32(s, radix: 16) else { return nil }
let r = Double((v >> 16) & 0xFF) / 255.0
let g = Double((v >> 8) & 0xFF) / 255.0
let b = Double(v & 0xFF) / 255.0
return (r, g, b)
}
/// Render normalized RGB components (clamped to 0...1) as a "#rrggbb" string.
public static func fromRGB(_ r: Double, _ g: Double, _ b: Double) -> String {
let ri = Int(round(min(max(r, 0), 1) * 255))
let gi = Int(round(min(max(g, 0), 1) * 255))
let bi = Int(round(min(max(b, 0), 1) * 255))
return String(format: "#%02x%02x%02x", ri, gi, bi)
}
}

View file

@ -0,0 +1,64 @@
import Foundation
/// Purpose: decide, from `netstat -an` output text, whether there is a genuine
/// inbound Screen Sharing connection: an ESTABLISHED TCP session on local
/// port 5900 (classic VNC) or a peered UDP socket on 5900-5902 (the
/// Sequoia/26 High-Performance transport). A LISTEN socket on 5900 is
/// always present whenever Screen Sharing is enabled, and wildcard UDP
/// listeners can linger, so neither alone proves anything; only a row
/// with a real foreign peer (not `*.*`) counts.
public enum NetstatParse {
/// Returns true when the netstat output contains an ESTABLISHED inbound TCP
/// session on local port 5900 with a real peer.
public static func hasEstablishedVNC(_ netstatOutput: String) -> Bool {
for raw in netstatOutput.split(separator: "\n") {
let line = String(raw)
let fields = line.split(separator: " ", omittingEmptySubsequences: true).map(String.init)
// netstat -an TCP columns: proto recv-q send-q local-address foreign-address state
guard fields.count >= 6 else { continue }
guard fields[0].lowercased().hasPrefix("tcp") else { continue }
let local = fields[3]
let foreign = fields[4]
let state = fields[5]
// Local side must be our 5900 listener accepting the inbound connection.
guard local.hasSuffix(".5900") else { continue }
// Must be an established connection with a real peer, not a LISTEN socket.
guard state == "ESTABLISHED" else { continue }
guard isRealPeer(foreign) else { continue }
return true
}
return false
}
/// Returns true when the netstat output contains a UDP socket on local port
/// 5900-5902 that is connected to a real foreign peer. High-Performance Screen
/// Sharing (macOS 14+, Apple silicon) streams over UDP, so there is no
/// ESTABLISHED TCP row to find but an active session shows as a peered UDP
/// socket. A wildcard foreign address (`*.*`) is just a listener and never
/// counts, so this cannot fire at rest.
public static func hasPeeredUDPVNC(_ netstatOutput: String) -> Bool {
for raw in netstatOutput.split(separator: "\n") {
let line = String(raw)
let fields = line.split(separator: " ", omittingEmptySubsequences: true).map(String.init)
// netstat -an UDP columns: proto recv-q send-q local-address foreign-address
guard fields.count >= 5 else { continue }
guard fields[0].lowercased().hasPrefix("udp") else { continue }
let local = fields[3]
let foreign = fields[4]
guard local.hasSuffix(".5900") || local.hasSuffix(".5901") || local.hasSuffix(".5902") else { continue }
guard isRealPeer(foreign) else { continue }
return true
}
return false
}
/// A foreign address is a real peer only when it names an actual remote
/// host+port, not the `*.*` / `host.*` wildcard forms a listener shows.
private static func isRealPeer(_ foreign: String) -> Bool {
foreign != "*.*" && !foreign.hasSuffix(".*")
}
}

View file

@ -0,0 +1,60 @@
import Foundation
/// Purpose: parse and match a reveal hotkey like "cmd+shift+l" against an incoming
/// physical key-down. Forgiving by design: caps-lock and fn are ignored and
/// only the four real modifiers (cmd/ctrl/option/shift) are compared, so a
/// stray device-dependent bit never blocks a legitimate match.
///
/// This is pure Foundation: modifier flags are matched against documented raw
/// CGEventFlags masks so CurtainShared needs no AppKit/CoreGraphics import.
public enum RevealCombo {
// Documented CGEventFlags raw values (AppKit-free).
private static let maskCommand: UInt64 = 0x100000
private static let maskShift: UInt64 = 0x20000
private static let maskControl: UInt64 = 0x40000
private static let maskAlternate: UInt64 = 0x80000
// Compare only the meaningful modifier bits; drop caps-lock, fn, and device bits.
private static let meaningfulMask: UInt64 =
maskCommand | maskControl | maskAlternate | maskShift
/// Non-character keys we accept by name (keycode), since they carry no usable char.
private static let namedKeycodes: [String: Int] = [
"space": 49, "return": 36, "enter": 76, "tab": 48, "escape": 53, "esc": 53,
"delete": 51, "backspace": 51,
]
/// Returns true when the incoming key-down matches the configured combo.
/// - Parameters:
/// - combo: a string like "cmd+shift+l" (separators: `+`, space, or `-`).
/// - keycode: the physical key code of the event.
/// - chars: the typed character(s), if any.
/// - flagsRawValue: the event's modifier flags as a CGEventFlags raw value.
public static func matches(combo: String, keycode: Int, chars: String?, flagsRawValue: UInt64) -> Bool {
let parts = combo.lowercased()
.split(whereSeparator: { $0 == "+" || $0 == " " || $0 == "-" })
.map(String.init)
.filter { !$0.isEmpty }
guard !parts.isEmpty else { return false }
var required: UInt64 = 0
var finalKey: String?
for p in parts {
switch p {
case "cmd", "command", "": required |= maskCommand
case "ctrl", "control", "": required |= maskControl
case "opt", "option", "alt", "": required |= maskAlternate
case "shift", "": required |= maskShift
default: finalKey = p
}
}
guard let key = finalKey else { return false }
// Modifiers must match exactly within the meaningful set.
guard (flagsRawValue & meaningfulMask) == (required & meaningfulMask) else { return false }
// Match a non-character key by keycode, otherwise by the typed character.
if let kc = namedKeycodes[key] { return kc == keycode }
return chars?.lowercased() == key
}
}

View file

@ -0,0 +1,43 @@
import XCTest
@testable import CurtainShared
final class HexColorTests: XCTestCase {
func testRoundTripBlack() {
let rgb = HexColor.toRGB("#000000")
XCTAssertNotNil(rgb)
XCTAssertEqual(HexColor.fromRGB(rgb!.r, rgb!.g, rgb!.b), "#000000")
}
func testRoundTripWhite() {
let rgb = HexColor.toRGB("#ffffff")
XCTAssertNotNil(rgb)
XCTAssertEqual(rgb!.r, 1.0, accuracy: 1e-9)
XCTAssertEqual(HexColor.fromRGB(rgb!.r, rgb!.g, rgb!.b), "#ffffff")
}
func testRoundTripArbitrary() {
let rgb = HexColor.toRGB("#1a2b3c")
XCTAssertNotNil(rgb)
XCTAssertEqual(HexColor.fromRGB(rgb!.r, rgb!.g, rgb!.b), "#1a2b3c")
}
func testToRGBWithoutHashPrefix() {
XCTAssertNotNil(HexColor.toRGB("1a2b3c"))
}
func testRejectsMalformed() {
XCTAssertNil(HexColor.toRGB("#12345")) // too short
XCTAssertNil(HexColor.toRGB("#1234567")) // too long
XCTAssertNil(HexColor.toRGB("#gggggg")) // non-hex
XCTAssertNil(HexColor.toRGB("")) // empty
}
func testFromRGBClamps() {
XCTAssertEqual(HexColor.fromRGB(2.0, -1.0, 0.5), "#ff0080")
}
func testFromRGBKnownValue() {
// 26/255, 43/255, 60/255 == 0x1a, 0x2b, 0x3c
XCTAssertEqual(HexColor.fromRGB(26.0 / 255, 43.0 / 255, 60.0 / 255), "#1a2b3c")
}
}

View file

@ -0,0 +1,128 @@
import XCTest
@testable import CurtainShared
final class NetstatParseTests: XCTestCase {
func testListenOnlyIsFalse() {
let out = """
Active Internet connections (including servers)
Proto Recv-Q Send-Q Local Address Foreign Address (state)
tcp4 0 0 *.5900 *.* LISTEN
"""
XCTAssertFalse(NetstatParse.hasEstablishedVNC(out))
}
func testEstablishedInboundIsTrue() {
let out = """
Proto Recv-Q Send-Q Local Address Foreign Address (state)
tcp4 0 0 192.168.1.20.5900 192.168.1.55.51234 ESTABLISHED
"""
XCTAssertTrue(NetstatParse.hasEstablishedVNC(out))
}
func testEstablishedDifferentPortIsFalse() {
let out = """
Proto Recv-Q Send-Q Local Address Foreign Address (state)
tcp4 0 0 192.168.1.20.22 192.168.1.55.51234 ESTABLISHED
"""
XCTAssertFalse(NetstatParse.hasEstablishedVNC(out))
}
func testForeignWildcardIsFalse() {
let out = """
Proto Recv-Q Send-Q Local Address Foreign Address (state)
tcp4 0 0 192.168.1.20.5900 *.* ESTABLISHED
"""
XCTAssertFalse(NetstatParse.hasEstablishedVNC(out))
}
func testOutboundFiveNineHundredIsFalse() {
// 5900 appears in the FOREIGN column (we are the client) not an inbound VNC session.
let out = """
Proto Recv-Q Send-Q Local Address Foreign Address (state)
tcp4 0 0 192.168.1.20.51234 192.168.1.55.5900 ESTABLISHED
"""
XCTAssertFalse(NetstatParse.hasEstablishedVNC(out))
}
func testEmptyOutputIsFalse() {
XCTAssertFalse(NetstatParse.hasEstablishedVNC(""))
}
func testEstablishedAmongNoiseIsTrue() {
let out = """
Proto Recv-Q Send-Q Local Address Foreign Address (state)
tcp4 0 0 *.5900 *.* LISTEN
tcp4 0 0 192.168.1.20.22 10.0.0.9.4421 ESTABLISHED
tcp4 0 0 192.168.1.20.5900 10.0.0.9.62000 ESTABLISHED
"""
XCTAssertTrue(NetstatParse.hasEstablishedVNC(out))
}
func testRealMacOS26ListenFormatIsFalse() {
// Verbatim from `netstat -an` on macOS 26.5 with Screen Sharing enabled,
// no session: LISTEN rows only must never read as established.
let out = """
Active Internet connections (including servers)
Proto Recv-Q Send-Q Local Address Foreign Address (state)
tcp4 0 0 *.5900 *.* LISTEN
tcp6 0 0 *.5900 *.* LISTEN
"""
XCTAssertFalse(NetstatParse.hasEstablishedVNC(out))
XCTAssertFalse(NetstatParse.hasPeeredUDPVNC(out))
}
// MARK: - Peered UDP (High-Performance transport)
func testPeeredUDPOn5900IsTrue() {
let out = """
Proto Recv-Q Send-Q Local Address Foreign Address
udp4 0 0 192.168.1.20.5900 192.168.1.55.61234
"""
XCTAssertTrue(NetstatParse.hasPeeredUDPVNC(out))
}
func testPeeredUDPOn5901And5902IsTrue() {
let out5901 = """
udp4 0 0 192.168.1.20.5901 192.168.1.55.61234
"""
let out5902 = """
udp6 0 0 fe80::1%en0.5902 fe80::2%en0.61234
"""
XCTAssertTrue(NetstatParse.hasPeeredUDPVNC(out5901))
XCTAssertTrue(NetstatParse.hasPeeredUDPVNC(out5902))
}
func testWildcardUDPListenerIsFalse() {
// An unconnected UDP listener (foreign *.*) is just Screen Sharing being
// enabled it must NEVER activate (the overnight false-positive class).
let out = """
udp4 0 0 *.5900 *.*
udp4 0 0 192.168.1.20.5900 *.*
"""
XCTAssertFalse(NetstatParse.hasPeeredUDPVNC(out))
}
func testPeeredUDPOnOtherPortIsFalse() {
let out = """
udp4 0 0 192.168.1.20.5353 192.168.1.55.5353
udp4 0 0 192.168.1.20.59000 10.0.0.9.443
"""
XCTAssertFalse(NetstatParse.hasPeeredUDPVNC(out))
}
func testPeeredUDPIsNotEstablishedTCP() {
// The two detectors must not bleed into each other.
let udpOnly = """
udp4 0 0 192.168.1.20.5900 192.168.1.55.61234
"""
let tcpOnly = """
tcp4 0 0 192.168.1.20.5900 192.168.1.55.61234 ESTABLISHED
"""
XCTAssertFalse(NetstatParse.hasEstablishedVNC(udpOnly))
XCTAssertFalse(NetstatParse.hasPeeredUDPVNC(tcpOnly))
}
func testEmptyOutputUDPIsFalse() {
XCTAssertFalse(NetstatParse.hasPeeredUDPVNC(""))
}
}

View file

@ -0,0 +1,84 @@
import XCTest
@testable import CurtainShared
final class RevealComboTests: XCTestCase {
// Documented CGEventFlags raw masks.
private let cmd: UInt64 = 0x100000
private let shift: UInt64 = 0x20000
private let control: UInt64 = 0x40000
private let option: UInt64 = 0x80000
private let capsLock: UInt64 = 0x10000
private let fn: UInt64 = 0x800000
// The "L" key: keycode 37, char "l".
private let lKeycode = 37
func testMatchesCmdShiftL() {
XCTAssertTrue(RevealCombo.matches(
combo: "cmd+shift+l", keycode: lKeycode, chars: "l", flagsRawValue: cmd | shift))
}
func testRejectsWrongKey() {
XCTAssertFalse(RevealCombo.matches(
combo: "cmd+shift+l", keycode: 0, chars: "a", flagsRawValue: cmd | shift))
}
func testRejectsMissingModifier() {
// Only cmd held, shift required too.
XCTAssertFalse(RevealCombo.matches(
combo: "cmd+shift+l", keycode: lKeycode, chars: "l", flagsRawValue: cmd))
}
func testRejectsExtraModifier() {
// control is held but not part of the combo: exact-match rejects it.
XCTAssertFalse(RevealCombo.matches(
combo: "cmd+shift+l", keycode: lKeycode, chars: "l", flagsRawValue: cmd | shift | control))
}
func testNamedKeySpace() {
XCTAssertTrue(RevealCombo.matches(
combo: "cmd+space", keycode: 49, chars: " ", flagsRawValue: cmd))
XCTAssertFalse(RevealCombo.matches(
combo: "cmd+space", keycode: 36, chars: nil, flagsRawValue: cmd))
}
func testNamedKeysReturnEscDelete() {
XCTAssertTrue(RevealCombo.matches(
combo: "ctrl+return", keycode: 36, chars: nil, flagsRawValue: control))
XCTAssertTrue(RevealCombo.matches(
combo: "cmd+esc", keycode: 53, chars: nil, flagsRawValue: cmd))
XCTAssertTrue(RevealCombo.matches(
combo: "cmd+delete", keycode: 51, chars: nil, flagsRawValue: cmd))
}
func testIgnoresCapsLockAndFnBits() {
XCTAssertTrue(RevealCombo.matches(
combo: "cmd+shift+l", keycode: lKeycode, chars: "l",
flagsRawValue: cmd | shift | capsLock | fn))
}
func testCaseInsensitiveChar() {
XCTAssertTrue(RevealCombo.matches(
combo: "cmd+shift+l", keycode: lKeycode, chars: "L", flagsRawValue: cmd | shift))
XCTAssertTrue(RevealCombo.matches(
combo: "CMD+SHIFT+L", keycode: lKeycode, chars: "l", flagsRawValue: cmd | shift))
}
func testOptionAndControlNames() {
XCTAssertTrue(RevealCombo.matches(
combo: "opt+a", keycode: 0, chars: "a", flagsRawValue: option))
XCTAssertTrue(RevealCombo.matches(
combo: "alt+a", keycode: 0, chars: "a", flagsRawValue: option))
}
func testEmptyComboIsFalse() {
XCTAssertFalse(RevealCombo.matches(
combo: "", keycode: 0, chars: "a", flagsRawValue: 0))
}
func testModifierOnlyComboIsFalse() {
// No final key part, so nothing to match.
XCTAssertFalse(RevealCombo.matches(
combo: "cmd+shift", keycode: 0, chars: "a", flagsRawValue: cmd | shift))
}
}

1
VERSION Normal file
View file

@ -0,0 +1 @@
1.0.0

14
curtain.entitlements Normal file
View file

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.cs.allow-jit</key>
<false/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<false/>
<key>com.apple.security.cs.disable-executable-page-protection</key>
<false/>
<key>com.apple.security.cs.disable-library-validation</key>
<false/>
</dict>
</plist>