curtain/Sources/Curtain/CurtainController.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

479 lines
20 KiB
Swift

import Cocoa
import ScreenCaptureKit
import AVFoundation
import CurtainShared
/// Purpose: Owns all cover windows on the host's physical monitors, plus the shared
/// aerial AVQueuePlayer/AVPlayerLooper. One borderless, max-level, opaque
/// window per display is keyed by display UUID so topology changes are
/// reconciled by identity, not array index. Native displays use sharingType
/// .none (invisible to the remote operator); DisplayLink displays use
/// .readOnly. Windows are click-through (ignoresMouseEvents) and never key so
/// they never interfere with the remote cursor. Physical input is blocked by
/// InputFilter, not by this window. Cover scope, appearance, password-box
/// placement, and new-display policy are driven by Settings with a fail-safe
/// bias: when in doubt, cover the display.
/// Inputs: physicalKey (from InputFilter), tick (1 Hz), setInputBlocked (from the
/// coordinator's Accessibility check).
/// Outputs: onUnlock on a correct password.
/// Constraints: AppKit is main-actor-isolated under Swift 6 so the whole class is
/// @MainActor. Every timer and observer is torn down in hide(); closures
/// use [weak self] to avoid retain cycles.
/// SPORT: MASTER-CURTAIN
@MainActor
final class CurtainController {
/// One cover window bound to a physical display, tracked by its stable UUID.
private struct Cover {
let uuid: String
let window: NSWindow
var isPasswordHost: Bool
}
private var covers: [String: Cover] = [:]
private var box: PasswordBox?
private var clockTimer: Timer?
private var screenObserver: NSObjectProtocol?
private var reconcileWork: DispatchWorkItem?
private var inputBlocked = true
// Shared aerial player one instance for the whole curtain session so all cover
// displays loop the same asset in lock-step without each paying the decode cost.
// Each CoverContentView attaches its own AVPlayerLayer to this player. Nilled in
// hide() after per-cover teardown, and kept alive in reconcile() while any aerial
// cover remains.
private var aerialPlayer: AVQueuePlayer?
private var aerialLooper: AVPlayerLooper?
var onUnlock: (() -> Void)?
var isShown: Bool { !covers.isEmpty }
// MARK: - Lifecycle
func show() {
guard covers.isEmpty else { return }
System.preventDisplaySleep()
// Build the shared aerial player once before creating cover windows so
// installAerialLayer() can attach a layer synchronously in makeCover().
let style = Settings.coverStyle
if style == "aerial" {
buildSharedAerialPlayer()
}
for screen in NSScreen.screens {
guard let uuid = uuidKey(for: screen) else { continue }
if shouldCover(uuid: uuid, isNew: false) {
covers[uuid] = makeCover(screen: screen, uuid: uuid)
}
}
ensurePasswordBox()
startClockIfNeeded()
Log.event("cover shown: style=\(Settings.coverStyle) displays=\(covers.count)")
screenObserver = NotificationCenter.default.addObserver(
forName: NSApplication.didChangeScreenParametersNotification,
object: nil, queue: .main
) { [weak self] _ in
Task { @MainActor in self?.scheduleReconcile() }
}
// Best-effort regression check that a .none cover is excluded from capture.
CurtainController.verifyNoneCoverHidden { ok in
if !ok { NSLog("Curtain: SCK self-test — a .none cover was visible in capture (regression)") }
}
}
func hide() {
if let token = screenObserver {
NotificationCenter.default.removeObserver(token)
screenObserver = nil
}
reconcileWork?.cancel()
reconcileWork = nil
clockTimer?.invalidate()
clockTimer = nil
covers.values.forEach {
($0.window.contentView as? CoverContentView)?.teardownAerialLayer()
$0.window.orderOut(nil)
}
covers.removeAll()
// Release the shared player only after all layers have been removed so no
// AVPlayerLayer outlives the player that backs it.
aerialLooper = nil
aerialPlayer = nil
box = nil
System.allowDisplaySleep()
}
/// Feed a physical key into the password box (from InputFilter). Gating: while the
/// box is hidden, only reveal it on any key (when configured) or on the user's
/// reveal combo. Once the box is visible, every key passes through so the user can
/// type the password without re-hitting the combo for each character.
func physicalKey(_ keycode: Int, _ chars: String?, _ flags: UInt64) {
guard let b = box else { return }
if b.isHidden {
let allow = Settings.revealOnAnyKey
|| RevealCombo.matches(combo: Settings.revealKeyCombo, keycode: keycode, chars: chars, flagsRawValue: flags)
guard allow else { return }
}
b.key(keycode: keycode, chars: chars)
}
/// Called once per second to auto-hide the password box after inactivity.
func tick() { box?.tick() }
/// Coordinator reports whether desk input is actually being blocked. When
/// false, every cover shows a warning banner; when true, banners clear.
func setInputBlocked(_ blocked: Bool) {
if !blocked { Log.event("input-not-blocked banner shown") }
inputBlocked = blocked
for cover in covers.values {
(cover.window.contentView as? CoverContentView)?.setWarningVisible(!blocked)
}
}
// MARK: - Shared aerial player
/// Build the single AVQueuePlayer + AVPlayerLooper used by all aerial covers. The
/// looper drives gapless, seamless repeat so individual cover views only need to
/// attach a layer. If the asset fails the async playability check, all aerial
/// layers are torn down and every cover re-renders at the logo style.
private func buildSharedAerialPlayer() {
guard let url = CurtainController.findAerialVideo() else {
NSLog("Curtain: no aerial video found in any known path; using logo cover")
return
}
let item = AVPlayerItem(url: url)
let queue = AVQueuePlayer()
queue.isMuted = true
queue.actionAtItemEnd = .advance
let looper = AVPlayerLooper(player: queue, templateItem: item)
aerialPlayer = queue
aerialLooper = looper
queue.play()
// Asynchronously verify the asset is actually decodable. If not, tear down
// all aerial layers and fall back to logo so no cover ever shows a black frame.
// The Task is keyed to the player it validated: a topology rebuild can replace
// the shared player while this is in flight, and a stale verdict must never
// tear down the replacement.
let asset = item.asset
Task { @MainActor [weak self] in
let playable = (try? await asset.load(.isPlayable)) ?? false
guard let self, self.aerialPlayer === queue else { return }
if !playable {
NSLog("Curtain: aerial asset not playable (\(url.lastPathComponent)); switching to logo cover")
self.teardownAerialAndSwitchToLogo()
}
}
}
/// Called when the async playability check fails. Removes aerial layers from every
/// cover and rebuilds them at the logo style, then releases the shared player.
private func teardownAerialAndSwitchToLogo() {
for cover in covers.values {
(cover.window.contentView as? CoverContentView)?.teardownAerialLayer()
(cover.window.contentView as? CoverContentView)?.applyLogoFallback()
}
aerialLooper = nil
aerialPlayer = nil
}
/// Provide the shared player to a cover view that is being built. Returns nil when
/// no aerial player is active (style is not aerial, or asset failed to load).
func sharedAerialPlayer() -> AVQueuePlayer? { aerialPlayer }
// MARK: - Topology reconcile
private func scheduleReconcile() {
reconcileWork?.cancel()
let work = DispatchWorkItem { [weak self] in
Task { @MainActor in self?.reconcile() }
}
reconcileWork = work
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3, execute: work)
}
private func reconcile() {
guard !covers.isEmpty else { return }
let beforeCount = covers.count
var liveByUUID: [String: NSScreen] = [:]
for screen in NSScreen.screens {
if let uuid = uuidKey(for: screen) { liveByUUID[uuid] = screen }
}
// Drop covers for displays that vanished.
for (uuid, cover) in covers where liveByUUID[uuid] == nil {
(cover.window.contentView as? CoverContentView)?.teardownAerialLayer()
cover.window.orderOut(nil)
covers.removeValue(forKey: uuid)
}
// Release the shared aerial player if no aerial covers remain after drops.
let anyAerialRemains = covers.values.contains {
($0.window.contentView as? CoverContentView)?.hasAerialLayer == true
}
if !anyAerialRemains {
aerialLooper = nil
aerialPlayer = nil
}
// Update survivors and add covers for newly-attached displays.
for (uuid, screen) in liveByUUID {
if covers[uuid] != nil {
let window = covers[uuid]!.window
window.setFrame(screen.frame, display: true)
window.sharingType = System.isDisplayLink(screen) ? .readOnly : .none
} else if shouldCover(uuid: uuid, isNew: true) {
// Re-build the shared player if aerial style was active before and
// we still have other aerial covers running.
if Settings.coverStyle == "aerial" && aerialPlayer == nil {
buildSharedAerialPlayer()
}
covers[uuid] = makeCover(screen: screen, uuid: uuid, forceNewDisplay: true)
}
}
ensurePasswordBox()
startClockIfNeeded()
Log.event("reconcile: displays now \(covers.count) (was \(beforeCount))")
// Reapply any active warning banner to freshly-built covers.
if !inputBlocked { setInputBlocked(false) }
}
// MARK: - Cover-scope decision
/// Decide whether a given display should be covered, honoring scope, the
/// per-display disable list, and (for mid-session arrivals) the new-display
/// policy. Two modes: "all" (default, fail-safe) and "perDisplay" (honor the
/// per-display Cover toggle ON = covered, OFF = uncovered). Legacy values
/// "onlyMarked"/"allExceptMarked" map to "perDisplay" semantics so the toggle
/// means what it says. Unknown or missing scope values default to "all" because
/// an exposed desk is the failure mode we must never reach.
private func shouldCover(uuid: String, isNew: Bool) -> Bool {
if isNew {
switch Settings.newDisplayPolicy {
case "leaveUncovered": return false
case "treatAsDisplayLink": return true // covered as .readOnly
default: return true // "cover"
}
}
let disabled = Settings.perDisplayCoverDisabled.contains(uuid)
switch Settings.coverScope {
case "perDisplay",
"onlyMarked",
"allExceptMarked":
return !disabled // ON = covered; per-display toggle drives this
default:
return true // "all" every display covered regardless of toggle
}
}
// MARK: - Window construction
private func makeCover(screen: NSScreen, uuid: String, forceNewDisplay: Bool = false) -> Cover {
let w = CoverWindow(contentRect: screen.frame, styleMask: .borderless,
backing: .buffered, defer: false)
w.backgroundColor = .black
w.level = NSWindow.Level(rawValue: Int(CGWindowLevelForKey(.maximumWindow)))
w.collectionBehavior = [.canJoinAllSpaces, .stationary, .ignoresCycle, .fullScreenAuxiliary]
w.isOpaque = true
w.hasShadow = false
w.ignoresMouseEvents = true
// A new display under "treatAsDisplayLink" is forced to .readOnly so we
// never assume a fresh, unrecognized panel is hardware-private.
let treatAsLink = forceNewDisplay && Settings.newDisplayPolicy == "treatAsDisplayLink"
w.sharingType = (System.isDisplayLink(screen) || treatAsLink) ? .readOnly : .none
let content = CoverContentView(frame: NSRect(origin: .zero, size: screen.frame.size),
aerialPlayer: aerialPlayer)
w.contentView = content
w.orderFrontRegardless()
return Cover(uuid: uuid, window: w, isPasswordHost: false)
}
// MARK: - Password box placement
/// Guarantee exactly one reachable password box, placed per the configured
/// policy. Recreates/moves the box if its host display vanished on reconcile.
private func ensurePasswordBox() {
guard !covers.isEmpty else { box = nil; return }
let targetUUID = passwordHostUUID()
// If the box already lives on the right host, keep it.
if let host = covers.first(where: { $0.value.isPasswordHost }), host.key == targetUUID {
return
}
// Clear the old host flag + remove the old box.
if let oldKey = covers.first(where: { $0.value.isPasswordHost })?.key {
covers[oldKey]?.isPasswordHost = false
}
box?.removeFromSuperview()
box = nil
guard let cover = covers[targetUUID],
let content = cover.window.contentView as? CoverContentView else { return }
let b = PasswordBox(frame: content.bounds)
b.isHidden = true
b.autoresizingMask = [.width, .height]
b.onSuccess = { [weak self] in self?.onUnlock?() }
content.addSubview(b)
box = b
covers[targetUUID]?.isPasswordHost = true
}
/// Resolve which display hosts the password box for the current placement
/// mode, always falling back to a display that actually has a cover.
private func passwordHostUUID() -> String {
let fallback = primaryCoveredUUID() ?? covers.keys.first ?? ""
switch Settings.passwordBoxPlacement {
case "all":
return fallback // "all" still anchors one interactive box; banners cover the rest
case "specific":
let wanted = Settings.passwordBoxSpecificUUID
return covers[wanted] != nil ? wanted : fallback
case "primary":
return primaryCoveredUUID() ?? fallback
default: // "followActive"
return activeCoveredUUID() ?? fallback
}
}
private func primaryCoveredUUID() -> String? {
if let main = NSScreen.screens.first, let uuid = uuidKey(for: main), covers[uuid] != nil {
return uuid
}
return covers.keys.first
}
/// The display under the mouse, else the focused screen whichever is covered.
private func activeCoveredUUID() -> String? {
let mouse = NSEvent.mouseLocation
if let hit = NSScreen.screens.first(where: { $0.frame.contains(mouse) }),
let uuid = uuidKey(for: hit), covers[uuid] != nil {
return uuid
}
if let main = NSScreen.main, let uuid = uuidKey(for: main), covers[uuid] != nil {
return uuid
}
return primaryCoveredUUID()
}
// MARK: - Live clock
private func startClockIfNeeded() {
let want = Settings.coverShowClock
if !want {
clockTimer?.invalidate(); clockTimer = nil
covers.values.forEach { ($0.window.contentView as? CoverContentView)?.updateClock(nil) }
return
}
guard clockTimer == nil else { tickClock(); return }
let t = Timer(timeInterval: 1.0, repeats: true) { [weak self] _ in
Task { @MainActor in self?.tickClock() }
}
RunLoop.main.add(t, forMode: .common)
clockTimer = t
tickClock()
}
private func tickClock() {
let f = DateFormatter()
f.dateFormat = "EEEE, MMMM d h:mm a"
let stamp = f.string(from: Date())
covers.values.forEach { ($0.window.contentView as? CoverContentView)?.updateClock(stamp) }
}
// MARK: - UUID helper
private func uuidKey(for screen: NSScreen) -> String? { System.uuid(of: screen) }
// MARK: - Aerial asset search
/// Locate a readable aerial `.mov`, first readable wins. Searches the current
/// wallpaper aerials directory, then the 4K SDR idle-asset catalog, then a shallow
/// scan of every Customer subdirectory. Returns nil when nothing readable exists.
/// Emits an NSLog when all candidate directories are exhausted so the caller can
/// decide to fall back to the logo style.
static func findAerialVideo() -> URL? {
let fm = FileManager.default
let home = NSHomeDirectory()
// 1) Current wallpaper aerial videos.
let wallpaperDir = "\(home)/Library/Application Support/com.apple.wallpaper/aerials/videos"
if let url = firstMov(in: wallpaperDir, fm: fm) { return url }
// 2) The standard 4K SDR 240fps idle-asset catalog.
let sdrDir = "/Library/Application Support/com.apple.idleassetsd/Customer/4KSDR240FPS"
if let url = firstMov(in: sdrDir, fm: fm) { return url }
// 3) Shallow scan of every Customer subdirectory for any readable .mov.
let customerDir = "/Library/Application Support/com.apple.idleassetsd/Customer"
if let subdirs = try? fm.contentsOfDirectory(atPath: customerDir) {
for sub in subdirs.sorted() {
if let url = firstMov(in: "\(customerDir)/\(sub)", fm: fm) { return url }
}
}
// All candidate paths exhausted caller uses logo fallback.
NSLog("Curtain: no aerial video found in any known path; using logo cover")
return nil
}
/// Return the first readable `.mov` directly inside a directory, sorted for a
/// stable choice across launches. nil if the directory is missing or has none.
private static func firstMov(in dir: String, fm: FileManager) -> URL? {
guard let entries = try? fm.contentsOfDirectory(atPath: dir) else { return nil }
for name in entries.sorted() where name.hasSuffix(".mov") {
let path = "\(dir)/\(name)"
if fm.isReadableFile(atPath: path) { return URL(fileURLWithPath: path) }
}
return nil
}
// MARK: - SCK self-test
/// Capture the main display via ScreenCaptureKit and confirm a `.none` cover
/// is excluded from the shareable content. SCK omits `.none`-shared windows,
/// so the heuristic is: if any of our windows are still reported on-screen in
/// the shareable window list, that's a regression. Best-effort and off the
/// main thread; falls back to a logged stub if SCK content is unavailable.
static func verifyNoneCoverHidden(completion: @escaping @Sendable (Bool) -> Void) {
if #available(macOS 12.3, *) {
SCShareableContent.getWithCompletionHandler { content, error in
guard let content, error == nil else {
NSLog("Curtain: SCK self-test not run (\(error?.localizedDescription ?? "no content"))")
completion(true)
return
}
let pid = ProcessInfo.processInfo.processIdentifier
// A correctly-hidden .none cover is absent from the shareable
// window list. If any of our windows still show up, warn.
let ourVisible = content.windows.contains {
$0.owningApplication?.processID == pid && $0.isOnScreen
}
completion(!ourVisible)
}
} else {
NSLog("Curtain: SCK self-test not run (requires macOS 12.3+)")
completion(true)
}
}
}
/// A window that never becomes key, so it can never steal focus from the remote
/// session. Input is blocked by InputFilter, not by this window grabbing events.
final class CoverWindow: NSWindow {
override var canBecomeKey: Bool { false }
override var canBecomeMain: Bool { false }
}