mirror of
https://github.com/acamarata/curtain.git
synced 2026-06-30 18:54:25 +00:00
Detection: netstat lives at /usr/sbin/netstat, not /usr/bin — the hardcoded wrong path silently killed the ESTABLISHED-TCP activator (root cause of the failed live test). Fixed and live-verified. Added peered-UDP activator (5900-5902) for High-Performance sessions, per-signal transition logging, unconditional error logging for dead probe helpers, and probe v2 with full CGSession dictionary diffing. 7 new parser tests (32 total). Fixes from a full audit + adversarial review: idle source setting honored (default now Remote session activity), cover scope reduced to a coherent two-mode model with legacy migration (per-display toggle was inverted in onlyMarked and dead in all), curtain test no longer schedules a teardown over a live session, specific-display password box placement gets a real picker, refuse-to-arm enforced, activation notification posts a real banner, menu password gate bypassed when the event tap is dead, shared single-decoder aerial player with stale-task guard and async playability check, password buffer zeroed on successful unlock and Esc, XPC interruption/invalidation handlers, modern Accessibility settings URL, launchPath modernized, codesign failures now abort release.sh, monotonic CFBundleVersion, install.sh temp cleanup, dead armDisarmHotkey setting removed. Refactor: Curtain.swift and PreferencesWindow.swift split into focused files (largest now 479 lines). Wiki, README, and contributing docs updated to match. Build clean at 0 warnings, 32/32 tests pass.
147 lines
6 KiB
Swift
147 lines
6 KiB
Swift
import CoreGraphics
|
|
import Foundation
|
|
|
|
/// Purpose: Detect Screen Sharing connect / disconnect / idle and fire callbacks.
|
|
/// How: polls every 2s. The connect signal is `CaptureProbe.signals()`: the CGSession
|
|
/// capture key is authoritative; the fallback activators are an ESTABLISHED
|
|
/// inbound TCP session on port 5900 (classic VNC) and a peered UDP socket on
|
|
/// 5900-5902 (High-Performance transport). This replaces the old netstat-only
|
|
/// detection, which silently failed under macOS Sequoia/26 High-Performance
|
|
/// Screen Sharing (UDP, no ESTABLISHED state) and on remapped ports, and it
|
|
/// drops the old process / LISTEN-socket activators that lingered with no
|
|
/// session and kept the curtain up overnight. Disconnect is debounced
|
|
/// (3 consecutive misses, ~6s) so a transient blip never kills a live session.
|
|
/// Constraints: the class lives on the main thread (the Timer runs on the main
|
|
/// runloop and all state vars are main-thread-only). Probes block on shell
|
|
/// calls, so each tick runs the probes on a background queue and hops back to
|
|
/// the main thread before touching state or firing callbacks. A `probing` flag
|
|
/// coalesces ticks so a slow probe never overlaps the next one.
|
|
///
|
|
/// Console-vs-virtual stand-down is inherent: `combinedCaptureActive()` is
|
|
/// driven by the capture key, which reads false for a different-user virtual
|
|
/// session, so we simply never report a connect in that case.
|
|
/// SPORT: MASTER-SESSIONMONITOR
|
|
@MainActor
|
|
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 probing = false
|
|
/// Last raw signal snapshot, kept only to log per-signal changes (which signal
|
|
/// actually saw the session) without spamming a line per tick.
|
|
private var lastSignals: CaptureProbe.CaptureSignals?
|
|
|
|
private var idleFired = false
|
|
/// Idle only counts once we have seen a sub-threshold reading. This prevents
|
|
/// firing on connect-time idle: if the user was already idle when the session
|
|
/// began, we wait for one reading below the threshold before arming.
|
|
private var idleArmed = false
|
|
|
|
private let missLimit = 3 // ~6s at the 2s poll interval
|
|
private let pollInterval: TimeInterval = 2
|
|
|
|
private let probeQueue = DispatchQueue(label: "com.curtain.session-monitor.probe", qos: .userInitiated)
|
|
|
|
func start() {
|
|
timer = Timer.scheduledTimer(withTimeInterval: pollInterval, repeats: true) { [weak self] _ in
|
|
Task { @MainActor in self?.tick() }
|
|
}
|
|
// Evaluate once immediately so a session already in progress is caught at start.
|
|
tick()
|
|
}
|
|
|
|
func stop() {
|
|
timer?.invalidate()
|
|
timer = nil
|
|
}
|
|
|
|
// MARK: - Poll
|
|
|
|
private func tick() {
|
|
guard !probing else { return }
|
|
probing = true
|
|
|
|
probeQueue.async { [weak self] in
|
|
let signals = CaptureProbe.signals()
|
|
let idle = SessionMonitor.idleSeconds()
|
|
Task { @MainActor in
|
|
self?.apply(signals: signals, idle: idle)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func apply(signals: CaptureProbe.CaptureSignals, idle: Int) {
|
|
defer { probing = false }
|
|
|
|
// Log raw signal changes (transitions only) so a live test shows exactly
|
|
// which signal saw the session — and which one missed it.
|
|
if signals != lastSignals {
|
|
Log.event("signals: captured=\(signals.captured) tcpEstab=\(signals.tcpEstablished) udpPeered=\(signals.udpPeered)")
|
|
lastSignals = signals
|
|
}
|
|
|
|
if signals.any {
|
|
missCount = 0
|
|
if !connected {
|
|
connected = true
|
|
idleFired = false
|
|
idleArmed = false
|
|
Log.event("session detected: captured=\(signals.captured) tcpEstab=\(signals.tcpEstablished) udpPeered=\(signals.udpPeered) idle=\(idle)")
|
|
onConnect?()
|
|
}
|
|
evaluateIdle(idle)
|
|
} else if connected {
|
|
missCount += 1
|
|
if missCount >= missLimit {
|
|
connected = false
|
|
missCount = 0
|
|
Log.event("session ended (debounced)")
|
|
onDisconnect?()
|
|
}
|
|
}
|
|
}
|
|
|
|
private func evaluateIdle(_ idle: Int) {
|
|
guard Settings.idleEnabled else { return }
|
|
let threshold = Settings.idleMinutes * 60
|
|
|
|
if idle < threshold {
|
|
// Below threshold: arm the latch and clear any prior fire.
|
|
idleArmed = true
|
|
idleFired = false
|
|
return
|
|
}
|
|
|
|
// At or above threshold: only fire if we have armed since connect and
|
|
// have not already fired this idle stretch.
|
|
if idleArmed, !idleFired {
|
|
idleFired = true
|
|
Log.event("idle timeout fired")
|
|
onIdleTimeout?()
|
|
}
|
|
}
|
|
|
|
// MARK: - Idle source
|
|
|
|
/// Seconds since the last qualifying input event, from the event system rather
|
|
/// than ioreg.
|
|
///
|
|
/// Source selection (Settings.idleSourceIsHID):
|
|
/// - `false` (default "sessionInput"): `.combinedSessionState` — counts activity
|
|
/// from ALL sources in the session, including the remote operator. This is the
|
|
/// product default because idle-on-session should respond to operator inactivity,
|
|
/// not just physical desk input.
|
|
/// - `true` ("hidIdle"): `.hidSystemState` — counts only physical HID events at
|
|
/// the desk. Remote operator activity is invisible to this source; the idle
|
|
/// clock ticks even while the operator types remotely.
|
|
nonisolated private static func idleSeconds() -> Int {
|
|
let source: CGEventSourceStateID = Settings.idleSourceIsHID ? .hidSystemState : .combinedSessionState
|
|
let seconds = CGEventSource.secondsSinceLastEventType(source, eventType: .null)
|
|
guard seconds.isFinite, seconds > 0 else { return 0 }
|
|
return Int(seconds)
|
|
}
|
|
}
|