mirror of
https://github.com/acamarata/curtain.git
synced 2026-06-30 18:54:25 +00:00
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
76 lines
2.8 KiB
Swift
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) ?? ""
|
|
}
|
|
}
|