curtain/Sources/Curtain/SessionMonitor.swift
acamarata 709669e0cb Curtain v1.0.0 — privacy curtain for macOS Screen Sharing
Menu-bar agent that, on a Screen Sharing connection, covers the host
displays and blocks physical keyboard/mouse from the apps while remote
input passes through, then locks the Mac on idle or disconnect.

- netstat-based session detection (debounced)
- CGEventTap input filter (block physical sourceStateID==1, pass remote)
- .none/.readOnly cover windows with on-curtain password box
- SACLockScreenImmediate lock + IOKit display-sleep assertion
- root helper (NOPASSWD) to disconnect the Screen Sharing session
- install/uninstall scripts, app bundle, login agent, CI
2026-06-01 14:10:50 -04:00

76 lines
2.8 KiB
Swift

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.
/// SPORT: MASTER-SESSIONMONITOR
final class SessionMonitor {
var onConnect: (() -> Void)?
var onDisconnect: (() -> Void)?
var onIdleTimeout: (() -> Void)?
private var timer: Timer?
private var connected = false
private var missCount = 0
private var idleFired = false
private let missLimit = 3 // ~6s at 2s poll
private let pollInterval: TimeInterval = 2
func start() {
connected = isVNCEstablished()
if connected { onConnect?() }
timer = Timer.scheduledTimer(withTimeInterval: pollInterval, repeats: true) { [weak self] _ in
self?.tick()
}
}
func stop() { timer?.invalidate(); timer = nil }
private func tick() {
if isVNCEstablished() {
missCount = 0
if !connected { connected = true; idleFired = false; onConnect?() }
if !idleFired, idleSeconds() >= Config.shared.idleMinutes * 60 {
idleFired = true
onIdleTimeout?()
} else if idleSeconds() < Config.shared.idleMinutes * 60 {
idleFired = false
}
} 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
}
}
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) ?? ""
}
}