curtain/Sources/Curtain/CaptureProbe.swift
Aric Camarata 8c19e960d2 Detection root-cause fix + audit batch: netstat path, UDP activator, settings coherence, refactor, docs
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.
2026-06-09 20:36:30 -04:00

131 lines
6.5 KiB
Swift

import CoreGraphics
import Foundation
import os
import CurtainShared
/// Purpose: Decide whether the console session is currently being screen-captured
/// (Screen Sharing / VNC), independent of the network transport in use.
/// Inputs: none. Reads live system state via CGSession and a few short shell probes.
/// Outputs: boolean signals; `combinedCaptureActive()` is the one the monitor polls.
/// Constraints: the authoritative signal is CGSession's `CGSSessionScreenIsCaptured`
/// key, which is true only when THIS console session is being captured.
/// It is transport-independent, so it survives macOS Sequoia/26
/// High-Performance Screen Sharing (UDP, no ESTABLISHED state) and any
/// remapped port. The only other things allowed to activate the curtain
/// are network rows that REQUIRE a real foreign peer: an ESTABLISHED
/// inbound TCP connection on port 5900 (classic VNC) or a peered UDP
/// socket on 5900-5902 (the high-performance transport). Process
/// presence and LISTEN/wildcard sockets must never activate:
/// screensharingd / ScreenSharingSubscriber linger with no session, and
/// a 5900 LISTEN socket is always present whenever Screen Sharing is
/// merely enabled. Those false positives kept the curtain up overnight
/// with no session. Shell probes are cheap but block, so the monitor
/// calls these off the main thread.
/// SPORT: MASTER-CAPTUREPROBE
enum CaptureProbe {
/// CGSession dictionary key (a CFBoolean) that is true when the console session
/// is being screen-captured. This is the primary, transport-independent signal.
private static let captureKey = "CGSSessionScreenIsCaptured"
/// Primary signal. True when the current console session is being captured.
/// A different-user virtual session reports this as false in the console
/// session, which is exactly the stand-down behavior we want.
static func isConsoleScreenCaptured() -> Bool {
guard let dict = CGSessionCopyCurrentDictionary() as? [String: Any] else { return false }
guard let captured = dict[captureKey] as? Bool else { return false }
return captured
}
/// Diagnostics only. Whether a screen-sharing helper process is running. This is
/// NOT an activation signal: ScreenSharingSubscriber / screensharingd linger long
/// after a session ends (and while Screen Sharing is merely enabled), so treating
/// process presence as "active" produced overnight false positives. Kept for the
/// probe script and troubleshooting; never call it from combinedCaptureActive.
static func screenShareProcessesPresent() -> Bool {
let out = shell("/usr/bin/pgrep", ["-fl", "ScreenSharingAgent|ScreenSharingSubscriber|screensharingd"])
return !out.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
/// A genuinely ESTABLISHED inbound TCP connection on local port 5900 (a real
/// classic VNC session). A LISTEN socket is always present when Screen Sharing
/// is enabled, so it must never count we require state ESTABLISHED AND a real
/// foreign peer (not `*.*`). We match the LOCAL address column so an outbound
/// VNC client connection (this Mac controlling another) never reads as a session.
static func establishedVNC() -> Bool {
NetstatParse.hasEstablishedVNC(netstatOutput())
}
/// A UDP socket on local port 5900-5902 connected to a real foreign peer the
/// High-Performance Screen Sharing transport (macOS 14+, Apple silicon, UDP).
/// Wildcard listeners never count, so this cannot fire at rest. This is the
/// network corroborator for high-performance sessions, where there is no
/// ESTABLISHED TCP row at all.
static func peeredUDPVNC() -> Bool {
NetstatParse.hasPeeredUDPVNC(netstatOutput())
}
/// One snapshot of every activation signal, so the monitor can log exactly
/// which signal saw the session. Equatable so transitions are cheap to detect.
struct CaptureSignals: Equatable, Sendable {
let captured: Bool
let tcpEstablished: Bool
let udpPeered: Bool
var any: Bool { captured || tcpEstablished || udpPeered }
}
/// Read all signals from one netstat snapshot (netstat is the expensive probe;
/// never run it twice per tick).
static func signals() -> CaptureSignals {
let netstat = netstatOutput()
return CaptureSignals(
captured: isConsoleScreenCaptured(),
tcpEstablished: NetstatParse.hasEstablishedVNC(netstat),
udpPeered: NetstatParse.hasPeeredUDPVNC(netstat)
)
}
/// The signal the monitor consumes. The capture key is authoritative; a real
/// ESTABLISHED inbound TCP session on 5900 (classic) or a peered UDP socket on
/// 5900-5902 (high-performance) are the only other things that may activate the
/// curtain. Process presence and LISTEN sockets are ignored.
static func combinedCaptureActive() -> Bool {
signals().any
}
// MARK: - Shell
/// netstat lives in /usr/sbin on macOS (NOT /usr/bin a wrong path here once
/// silently killed the entire network corroborator: Process.run() threw, shell()
/// returned "", and every netstat-based signal read false forever).
private static func netstatOutput() -> String {
shell("/usr/sbin/netstat", ["-an"])
}
/// Paths we have already complained about, so a permanently-broken tool logs
/// once per launch instead of every 2-second tick.
private static let warnedPaths = OSAllocatedUnfairLock(initialState: Set<String>())
private static func shell(_ path: String, _ args: [String]) -> String {
let process = Process()
process.executableURL = URL(fileURLWithPath: path)
process.arguments = args
let pipe = Pipe()
process.standardOutput = pipe
process.standardError = Pipe()
do {
try process.run()
} catch {
let firstTime = warnedPaths.withLock { warned in
warned.insert(path).inserted
}
if firstTime {
Log.error("probe helper failed to launch: \(path)\(error.localizedDescription)")
}
return ""
}
let data = pipe.fileHandleForReading.readDataToEndOfFile()
process.waitUntilExit()
return String(data: data, encoding: .utf8) ?? ""
}
}