mirror of
https://github.com/acamarata/curtain.git
synced 2026-06-30 18:54:25 +00:00
Add wiki: Home, Installation, How-It-Works, Architecture, Lessons-Learned, Troubleshooting
This commit is contained in:
parent
709669e0cb
commit
fa2efa5d05
6 changed files with 569 additions and 0 deletions
153
.github/wiki/Architecture.md
vendored
Normal file
153
.github/wiki/Architecture.md
vendored
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
# Architecture
|
||||
|
||||
## File and 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. |
|
||||
|
||||
## Key macOS APIs
|
||||
|
||||
### Session detection
|
||||
|
||||
`SessionMonitor` runs a two-second timer and shells out to check for an established VNC connection:
|
||||
|
||||
```
|
||||
netstat -an | grep '.5900 ' | grep ESTABLISHED
|
||||
```
|
||||
|
||||
`lsof` does not work here — Screen Sharing sockets are owned by the `_rmd` system account and are invisible to user-context `lsof`.
|
||||
|
||||
Idle time comes from IOKit:
|
||||
|
||||
```
|
||||
ioreg -c IOHIDSystem => HIDIdleTime (nanoseconds)
|
||||
```
|
||||
|
||||
### Physical vs remote input (`CGEventTap`)
|
||||
|
||||
`InputFilter` creates the tap with:
|
||||
|
||||
```swift
|
||||
CGEvent.tapCreate(
|
||||
tap: .cgSessionEventTap,
|
||||
place: .headInsertEventTap,
|
||||
options: .defaultTap, // active tap — can block events
|
||||
eventsOfInterest: <mask covering keyDown/keyUp/flagsChanged + all mouse + scroll>,
|
||||
callback: ...,
|
||||
userInfo: ...
|
||||
)
|
||||
```
|
||||
|
||||
Inside the callback, each event is classified by source:
|
||||
|
||||
```swift
|
||||
let physical = (event.getIntegerValueField(.eventSourceStateID) == 1)
|
||||
```
|
||||
|
||||
- Source state ID `1` = physical hardware (`kCGEventSourceStateHIDSystemState`). Block it (return `nil`).
|
||||
- Any other ID = Screen Sharing injected. Pass it (return `Unmanaged.passUnretained(event)`).
|
||||
|
||||
The tap handles `.tapDisabledByTimeout` and `.tapDisabledByUserInput` by re-enabling itself.
|
||||
|
||||
### Cover window (`NSWindow.sharingType`)
|
||||
|
||||
The curtain window on each display is borderless, opaque, level `CGWindowLevelForKey(.maximumWindow)`, with:
|
||||
|
||||
- `ignoresMouseEvents = true` — never intercepts remote cursor.
|
||||
- `canBecomeKey = false` — never steals keyboard focus.
|
||||
|
||||
For native displays: `sharingType = .none`. The window is excluded from screen capture, so the remote operator sees the real desktop behind it.
|
||||
|
||||
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`)
|
||||
|
||||
`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`:
|
||||
|
||||
```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.
|
||||
|
||||
### Display sleep prevention (`IOPMAssertion`)
|
||||
|
||||
While a session is active, Curtain holds an IOKit power assertion to keep the displays on:
|
||||
|
||||
```swift
|
||||
IOPMAssertionCreateWithName(kIOPMAssertionTypeNoDisplaySleep, ..., "Curtain active", &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.
|
||||
|
||||
### 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:
|
||||
|
||||
```swift
|
||||
Process() { launchPath = "/usr/bin/sudo"; arguments = ["-n", "/usr/local/bin/curtain-endsession"] }
|
||||
```
|
||||
|
||||
macOS respawns the Screen Sharing listener automatically after the helper kills the session processes.
|
||||
|
||||
## Data flow: connect to end
|
||||
|
||||
```
|
||||
1. SessionMonitor detects ESTABLISHED on :5900
|
||||
|
|
||||
v
|
||||
2. AppDelegate.sessionStarted()
|
||||
- CurtainController.show() => black cover windows appear on all displays
|
||||
- System.preventDisplaySleep() => IOPMAssertion held
|
||||
- InputFilter.start() => CGEventTap installed
|
||||
|
|
||||
v
|
||||
3. Remote operator works normally. Physical input is dropped at the tap.
|
||||
Physical key-downs forwarded to PasswordBox via onPhysicalKey callback.
|
||||
|
|
||||
+-- Password correct
|
||||
| => InputFilter.stop() + CurtainController.hide()
|
||||
| => System.allowDisplaySleep()
|
||||
| => Alert: "Disconnect remote?" => System.endScreenShareSession()
|
||||
|
|
||||
+-- Idle timeout fires (SessionMonitor)
|
||||
| => System.endScreenShareSession()
|
||||
| => sessionEnded(lock: true)
|
||||
|
|
||||
+-- 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)
|
||||
```
|
||||
|
||||
## 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. |
|
||||
35
.github/wiki/Home.md
vendored
Normal file
35
.github/wiki/Home.md
vendored
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
# 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.
|
||||
|
||||
It lives in the menu bar. It does one job.
|
||||
|
||||
## Behavior
|
||||
|
||||
| Event | What happens |
|
||||
|---|---|
|
||||
| **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. |
|
||||
|
||||
## 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 granted to Curtain (required for desk input blocking).
|
||||
|
||||
## 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.
|
||||
|
||||
## A note on DisplayLink monitors
|
||||
|
||||
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.
|
||||
|
||||
See [How It Works](How-It-Works#displaylink) for details.
|
||||
81
.github/wiki/How-It-Works.md
vendored
Normal file
81
.github/wiki/How-It-Works.md
vendored
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
# How It Works
|
||||
|
||||
## The core challenge
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
## 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.
|
||||
|
||||
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.
|
||||
|
||||
This is what lets you type and click freely on your laptop while the desk keyboard and mouse do nothing.
|
||||
|
||||
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.
|
||||
|
||||
## The lifecycle
|
||||
|
||||
```
|
||||
Screen Sharing connects
|
||||
|
|
||||
+-- 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)
|
||||
|
|
||||
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
|
||||
|
|
||||
+-- No HID input for 30 minutes (idle timeout)
|
||||
| -> Remote session is terminated
|
||||
| -> Mac locks, displays sleep
|
||||
|
|
||||
+-- Remote operator disconnects
|
||||
-> Mac locks, displays sleep
|
||||
```
|
||||
|
||||
## 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.
|
||||
|
||||
## 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.
|
||||
79
.github/wiki/Installation.md
vendored
Normal file
79
.github/wiki/Installation.md
vendored
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
# Installation
|
||||
|
||||
## 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).
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
git clone https://github.com/acamarata/curtain.git
|
||||
cd curtain
|
||||
./Scripts/install.sh
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
## 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.
|
||||
|
||||
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`
|
||||
|
||||
If Curtain does not appear in the Accessibility list, run:
|
||||
|
||||
```bash
|
||||
open -a Curtain
|
||||
```
|
||||
|
||||
Then check again.
|
||||
|
||||
## 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.
|
||||
|
||||
If you never set a password, the default is `curtain`.
|
||||
|
||||
Passwords are stored as a salted SHA-256 hash in `~/Library/Application Support/Curtain/config.json`. The plaintext is never saved.
|
||||
|
||||
## Mark DisplayLink monitors (if you use them)
|
||||
|
||||
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.
|
||||
|
||||
To see which display is which first, choose **Identify Displays** — each monitor flashes its index number and serial for six seconds.
|
||||
|
||||
See [How It Works — DisplayLink](How-It-Works#displaylink) for why this matters.
|
||||
|
||||
## Uninstall
|
||||
|
||||
```bash
|
||||
./Scripts/uninstall.sh
|
||||
```
|
||||
|
||||
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
|
||||
```
|
||||
101
.github/wiki/Lessons-Learned.md
vendored
Normal file
101
.github/wiki/Lessons-Learned.md
vendored
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
# Lessons Learned
|
||||
|
||||
This page records what was discovered building Curtain. The goal is to explain the constraints behind several non-obvious design choices.
|
||||
|
||||
## The hard constraint: one shared session
|
||||
|
||||
Standard macOS Screen Sharing shares the console session. The remote operator and the person at the desk are looking at the same desktop and using the same input system.
|
||||
|
||||
This rules out window-level input separation. There is no such thing as a window that blocks the desk keyboard but not the remote keyboard, because both appear identical at the window level. Several approaches broke against this constraint:
|
||||
|
||||
- A click-through cover window (ignores mouse and keyboard): the curtain renders, but desk key presses reach the running apps. Input blocking is not there.
|
||||
- An input-blocking, key window: desk input is blocked, but so is remote input. Remote control stops working.
|
||||
- The screensaver: it draws on the real display output, so the remote operator also sees the screensaver and cannot work. And `open -a ScreenSaverEngine` returns 0 on recent macOS but does not reliably run the screensaver from a script context.
|
||||
|
||||
The solution is to work at the event tap level, below the window system, and to classify events by their source rather than by the window they target.
|
||||
|
||||
## Event source IDs distinguish physical from remote
|
||||
|
||||
A `CGEventTap` callback can read each event's source state ID:
|
||||
|
||||
```
|
||||
event.getIntegerValueField(.eventSourceStateID)
|
||||
```
|
||||
|
||||
Physical hardware events consistently report source state ID `1` (`kCGEventSourceStateHIDSystemState`). Screen Sharing injects synthetic events with a large, arbitrary ID that is different for each connection (observed values: 294702567, 1726956429, 336465782).
|
||||
|
||||
The rule is: block events where the source ID is `1`; pass everything else. This was verified empirically by typing from the remote (events came with a large ID) and from the desk (events came with ID `1`).
|
||||
|
||||
The tap must be created as an active tap (`.defaultTap`, head-insert) so it can block events by returning `nil`. The tap also handles `.tapDisabledByTimeout` and `.tapDisabledByUserInput` by re-enabling itself, because macOS disables a tap that takes too long to respond.
|
||||
|
||||
Physical key-downs are routed to the password box through the `onPhysicalKey` callback. The password box reads keystrokes this way because the curtain window is non-key and click-through — normal text input via the responder chain would never reach it.
|
||||
|
||||
## Accessibility permission and the app bundle
|
||||
|
||||
The event tap requires Accessibility permission. When Curtain runs as a loose process or unsigned binary, the Accessibility grant is unstable — granting it mid-run changed tap behavior between attempts, causing "it worked, then broke" confusion.
|
||||
|
||||
The fix: `install.sh` creates a proper app bundle and ad-hoc codesigns it. TCC (the Transparency, Consent, and Control system) grants Accessibility to the bundle identifier, which is stable across relaunches. After granting Accessibility, relaunch via `launchctl kickstart` so the new permission takes effect cleanly.
|
||||
|
||||
## Session detection: netstat, not lsof
|
||||
|
||||
The first approach used `lsof -i :5900` to detect an active Screen Sharing connection. This returned nothing at all, not even the listener socket. The Screen Sharing processes run under the `_rmd` system account, and their sockets are not visible to a user-context `lsof` call. This silently broke connection detection.
|
||||
|
||||
The working approach:
|
||||
|
||||
```
|
||||
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.
|
||||
|
||||
## Screen lock
|
||||
|
||||
Three approaches were tried:
|
||||
|
||||
| Approach | Result |
|
||||
|---|---|
|
||||
| `CGSession -suspend` | Removed in recent macOS. Not available. |
|
||||
| `osascript` Ctrl+Cmd+Q | Requires Accessibility and a GUI context. Unreliable from a LaunchAgent. |
|
||||
| `SACLockScreenImmediate` (private symbol in `login.framework`) | Locks immediately. No Accessibility needed. No GUI context needed. Works from a LaunchAgent. |
|
||||
|
||||
`SACLockScreenImmediate` is a private API, accessed via `dlopen`/`dlsym`. It is what several other third-party lock tools use. Display sleep after locking uses `pmset displaysleepnow`.
|
||||
|
||||
## Preventing display sleep during a session
|
||||
|
||||
The initial approach used `caffeinate -d`. The problem: when the Curtain LaunchAgent was reloaded (not just the process), the previous `caffeinate` PID became orphaned and kept the displays awake after the session ended.
|
||||
|
||||
The fix is an in-process IOKit assertion:
|
||||
|
||||
```swift
|
||||
IOPMAssertionCreateWithName(kIOPMAssertionTypeNoDisplaySleep, ...)
|
||||
```
|
||||
|
||||
The assertion is tied to the process lifetime and is released explicitly when the session ends. No PID tracking needed.
|
||||
|
||||
## Ending the remote session needs a root helper
|
||||
|
||||
The Screen Sharing connection processes (`screensharingd`, `ScreenSharingSubscriber`) are owned by `_rmd`/root. A user process cannot kill them. `install.sh` solves this by dropping a small helper binary at `/usr/local/bin/curtain-endsession` with a NOPASSWD sudoers rule. The helper calls `pkill` on the session processes. macOS respawns the listener, so Screen Sharing stays available for the next connection.
|
||||
|
||||
## DisplayLink monitors and sharingType
|
||||
|
||||
`sharingType = .none` on a window excludes it from screen capture. On a native display, this means the curtain is invisible to the remote operator while remaining fully visible at the desk. The remote sees the real desktop.
|
||||
|
||||
DisplayLink displays are a special case. They do not have a native framebuffer connection — they exist entirely through screen capture. A window with `sharingType = .none` is excluded from screen capture, which also excludes it from the DisplayLink output. The cover does not appear on the DisplayLink monitor.
|
||||
|
||||
For those monitors, `sharingType = .readOnly` is required. The cover is capturable, so the DisplayLink monitor shows it. The trade-off: it also shows in the remote view for those displays.
|
||||
|
||||
Identifying DisplayLink displays by USB vendor ID via IOKit is unreliable because EDID passthrough makes all monitors report the same vendor and model strings. The chosen approach is to identify displays by their stable serial number (`CGDisplaySerialNumber`) and let the user mark them manually via the "Mark Current Externals as DisplayLink" menu item.
|
||||
|
||||
## Things that did not work
|
||||
|
||||
Do not re-attempt these:
|
||||
|
||||
- **Click-through cover with no event tap.** Remote works but desk keyboard reaches apps.
|
||||
- **Input-blocking key window.** Blocks desk and remote both. Unusable.
|
||||
- **Native screensaver as cover.** Shows on the remote view. Cannot be reliably started via script on recent macOS.
|
||||
- **Virtual display (same-user) approach.** Depends on macOS version behavior and did not work in the target setup.
|
||||
- **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.
|
||||
120
.github/wiki/Troubleshooting.md
vendored
Normal file
120
.github/wiki/Troubleshooting.md
vendored
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
# Troubleshooting
|
||||
|
||||
## Curtain covers the screen but desk keyboard input still reaches apps
|
||||
|
||||
Accessibility permission is not granted, or the tap is not active.
|
||||
|
||||
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`
|
||||
|
||||
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.
|
||||
|
||||
## The curtain shows but the remote operator's mouse/keyboard stops working
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
## 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`.
|
||||
|
||||
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.
|
||||
|
||||
## The session keeps dropping every ~30-60 seconds
|
||||
|
||||
This is the debounce not triggering correctly, or a network issue causing repeated transient disconnects.
|
||||
|
||||
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.
|
||||
|
||||
If this happens even on a stable network, check whether another process is interfering with port 5900 or restarting Screen Sharing.
|
||||
|
||||
## 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.
|
||||
|
||||
## I forgot my password
|
||||
|
||||
The default password is `curtain`. If you set a custom password and forgot it, delete the config file to reset:
|
||||
|
||||
```bash
|
||||
rm ~/Library/Application\ Support/Curtain/config.json
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
## The curtain does not appear when Screen Sharing connects
|
||||
|
||||
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.
|
||||
Loading…
Reference in a new issue