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

112 lines
4.6 KiB
Swift

import Foundation
/// Purpose: A composable set of lifecycle actions. Each phase (start / idle / end)
/// maps to an ActionSet built from Settings; the runner performs only the
/// enabled actions. Keeping actions independent and data-driven means a new
/// behavior is a field here, not a branch scattered across the app.
/// Inputs: five booleans, all defaulting off so an empty set is a safe no-op.
/// Outputs: none (the runner mutates live curtain + system state).
/// Constraints: stored property names (disconnect, lock, screenOff,
/// deactivateCurtain, activateCurtain) are part of the contract with
/// Settings.onIdle / Settings.onEnd do not rename them.
/// SPORT: MASTER-ACTIONS
struct ActionSet {
var activateCurtain = false
var disconnect = false
var lock = false
var screenOff = false
var deactivateCurtain = false
}
/// Purpose: Perform an ActionSet against the live curtain + system. Ownership of
/// the cover lifecycle (show/hide, input tap, display-sleep assertion)
/// lives here so the coordinator stays a pure state machine.
/// Inputs: a CurtainController and an InputFilter, both owned by the coordinator.
/// Outputs: none.
/// Constraints: ordering matters for privacy. The remote must never see a bare
/// desktop frame, so when a set both reveals and disconnects, the
/// disconnect/lock run BEFORE the cover comes down. The screen-off step is
/// a cancelable work item: any new activate/deactivate/run cancels a
/// pending sleep so a stale timer can't black out a screen we just brought
/// back. Contradictory sets (activate + deactivate together) are rejected.
/// SPORT: MASTER-ACTIONS
@MainActor
final class ActionRunner {
let curtain: CurtainController
let input: InputFilter
/// Pending displays-off work, held so it can be canceled if state changes first.
private var pendingScreenOff: DispatchWorkItem?
init(curtain: CurtainController, input: InputFilter) {
self.curtain = curtain
self.input = input
}
// MARK: - Cover lifecycle
/// Bring the cover up: windows, display-sleep assertion, and the input tap.
/// If the tap can't install yet (Accessibility not granted), the cover still
/// hides the desktop visually; we leave input unblocked and retry the tap in the
/// background, flipping the cover's input-blocked state once the grant lands.
func activateCover() {
cancelPendingScreenOff()
guard !curtain.isShown else { return }
curtain.show()
System.preventDisplaySleep()
if input.start() {
curtain.setInputBlocked(true)
} else {
curtain.setInputBlocked(false)
input.retryUntilTrusted { [weak curtain] in curtain?.setInputBlocked(true) }
}
}
/// Take the cover down: stop any pending tap retry, tear down the tap, hide the
/// windows, and release the display-sleep assertion.
func deactivateCover() {
cancelPendingScreenOff()
input.cancelRetry()
input.stop()
curtain.hide()
System.allowDisplaySleep()
}
// MARK: - Set execution
func run(_ set: ActionSet) {
cancelPendingScreenOff()
// A set can't both reveal and conceal; honoring deactivate here would race
// the cover we were just asked to raise. Keep the cover, drop the reveal.
var set = set
if set.activateCurtain && set.deactivateCurtain {
NSLog("Curtain: contradictory ActionSet (activate + deactivate) — skipping deactivate")
set.deactivateCurtain = false
}
// Privacy ordering: raise the cover first if asked, then sever the remote
// and lock while still covered, and only then reveal the desktop. The
// remote never sees an uncovered frame.
if set.activateCurtain { activateCover() }
if set.disconnect { System.endScreenShareSession() }
if set.lock { System.lockScreen() }
if set.deactivateCurtain { deactivateCover() }
if set.screenOff { scheduleScreenOff() }
}
// MARK: - Cancelable screen-off
/// Sleep the displays after a short beat so a lock has time to take hold first.
/// Held as a work item so a later state change can cancel it.
private func scheduleScreenOff() {
let work = DispatchWorkItem { System.sleepDisplays() }
pendingScreenOff = work
DispatchQueue.main.asyncAfter(deadline: .now() + 1, execute: work)
}
private func cancelPendingScreenOff() {
pendingScreenOff?.cancel()
pendingScreenOff = nil
}
}