curtain/.github/wiki/Architecture.md

6.2 KiB

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:

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:

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:

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:

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:

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.