mirror of
https://github.com/acamarata/curtain.git
synced 2026-07-01 19:24:25 +00:00
153 lines
6.2 KiB
Markdown
153 lines
6.2 KiB
Markdown
# 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. |
|