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.
290 lines
12 KiB
Swift
290 lines
12 KiB
Swift
import Cocoa
|
|
import os.log
|
|
|
|
/// Purpose: The brain. Wires the session monitor, curtain, input filter, and the
|
|
/// configurable lifecycle actions together, and runs the connect / idle /
|
|
/// end / password flow as an explicit, idempotent state machine. Holds no
|
|
/// UI of its own — it owns AppKit objects and publishes state changes.
|
|
/// Inputs: none at construction; behavior is driven entirely by Settings + the
|
|
/// monitor callbacks.
|
|
/// Outputs: onStateChange (curtain active?) and onArmedChange (armed?) for the menu.
|
|
/// Constraints: @MainActor — it owns AppKit objects and the monitor callbacks
|
|
/// already hop to main. Every public transition is idempotent: a
|
|
/// double-activate, a deactivate-while-idle, or a reconnect-while-active
|
|
/// must never double-run lock/sleep or leave the tap dangling. The
|
|
/// password-unlock path disconnects the remote BEFORE revealing the
|
|
/// desktop, and never blocks the runloop with a modal (that would freeze
|
|
/// the event tap). When disarmed it stays passive and ignores the monitor.
|
|
/// SPORT: MASTER-COORDINATOR
|
|
@MainActor
|
|
final class SessionCoordinator {
|
|
|
|
/// Curtain active? Drives the menu-bar icon.
|
|
var onStateChange: ((Bool) -> Void)?
|
|
/// Armed? Drives the menu's arm/disarm item.
|
|
var onArmedChange: ((Bool) -> Void)?
|
|
|
|
let curtain = CurtainController()
|
|
let input = InputFilter()
|
|
private let monitor = SessionMonitor()
|
|
private lazy var runner = ActionRunner(curtain: curtain, input: input)
|
|
|
|
private var tickTimer: Timer?
|
|
|
|
/// Always-available escape hatch — deactivates without Accessibility. See start().
|
|
private var emergencyHotkey: EmergencyHotkey?
|
|
|
|
/// Pending connect grace; canceled if the session drops before it elapses.
|
|
private var connectGrace: DispatchWorkItem?
|
|
/// Pending test-curtain teardown; canceled if a real session connects mid-test.
|
|
private var testTeardown: DispatchWorkItem?
|
|
|
|
/// Explicit lifecycle state. Every transition is guarded so it stays idempotent.
|
|
private enum State { case idle, active }
|
|
private var state: State = .idle
|
|
|
|
// MARK: - Setup
|
|
|
|
func start() {
|
|
input.onPhysicalKey = { [weak self] kc, chars, flags in self?.curtain.physicalKey(kc, chars, flags) }
|
|
curtain.onUnlock = { [weak self] in self?.handlePasswordUnlock() }
|
|
|
|
monitor.onConnect = { [weak self] in self?.sessionConnected() }
|
|
monitor.onIdleTimeout = { [weak self] in self?.sessionIdled() }
|
|
monitor.onDisconnect = { [weak self] in self?.sessionEnded() }
|
|
monitor.start()
|
|
|
|
tickTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
|
|
Task { @MainActor in self?.curtain.tick() }
|
|
}
|
|
|
|
// Reflect the persisted disconnect-helper setting into the System handler hook.
|
|
enableDisconnectHelper(Settings.disconnectFeatureEnabled)
|
|
|
|
// Always-on emergency escape. Carbon RegisterEventHotKey needs no Accessibility,
|
|
// so Control+Option+Command+U force-deactivates even if the tap never installed.
|
|
let hotkey = EmergencyHotkey()
|
|
hotkey.register { [weak self] in
|
|
Log.event("emergency hotkey: force deactivate")
|
|
self?.deactivateNow()
|
|
self?.postNotification(title: "Curtain", body: "Curtain deactivated (emergency hotkey).")
|
|
}
|
|
emergencyHotkey = hotkey
|
|
}
|
|
|
|
// MARK: - User notifications
|
|
|
|
/// Throttled (~60s) prompt shown when an activation is refused because Accessibility
|
|
/// isn't granted. Without the tap the cover can't be unlocked at the desk.
|
|
private func notifyAccessibilityNeeded() {
|
|
Notifier.post(
|
|
title: "Curtain",
|
|
body: "Grant Accessibility to Curtain to use the privacy cover. System Settings > Privacy & Security > Accessibility.",
|
|
throttleKey: "accessibility-needed",
|
|
throttleSeconds: 60
|
|
)
|
|
}
|
|
|
|
private func postNotification(title: String, body: String) {
|
|
Notifier.post(title: title, body: body)
|
|
}
|
|
|
|
// MARK: - Monitor events (gated by armed)
|
|
|
|
private func sessionConnected() {
|
|
guard Settings.armed else { return }
|
|
// A real session wins over any in-flight test: keep whatever is on screen.
|
|
testTeardown?.cancel(); testTeardown = nil
|
|
guard Settings.onStartActivate else { return }
|
|
|
|
// Grace window: a brief, flaky connection shouldn't flash the cover up.
|
|
connectGrace?.cancel()
|
|
let work = DispatchWorkItem { [weak self] in
|
|
self?.connectGrace = nil
|
|
// Re-check armed: the user may have disarmed while the work item was
|
|
// already executing (past the point where cancel() could stop it).
|
|
guard Settings.armed else { return }
|
|
self?.enterActive(notify: true)
|
|
}
|
|
connectGrace = work
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + Double(Settings.connectGraceSeconds), execute: work)
|
|
}
|
|
|
|
private func sessionIdled() {
|
|
guard Settings.armed, state == .active else { return }
|
|
Log.event("running idle actions")
|
|
applySet(Settings.onIdle)
|
|
}
|
|
|
|
private func sessionEnded() {
|
|
guard Settings.armed else { return }
|
|
// If the session never made it past grace, just drop the pending activate.
|
|
connectGrace?.cancel(); connectGrace = nil
|
|
guard state == .active else { return }
|
|
Log.event("running end actions")
|
|
applySet(Settings.onEnd)
|
|
}
|
|
|
|
// MARK: - State machine
|
|
|
|
/// Raise the cover and move to .active. No-op if already active.
|
|
///
|
|
/// `requireAX` gates indefinite activations (real session + "Activate Now") on
|
|
/// Accessibility: without the event tap the cover both fails to block desk input
|
|
/// AND can't be unlocked at the desk, so it must never be shown. The bounded
|
|
/// `testCurtain(seconds:)` path passes `requireAX: false` — it auto-deactivates
|
|
/// after its timeout, so it's a safe visual test even without AX.
|
|
private func enterActive(notify: Bool, requireAX: Bool = true) {
|
|
guard state == .idle else { return }
|
|
Log.event("activate requested")
|
|
if requireAX, !AXIsProcessTrusted() {
|
|
Log.event("activation refused: Accessibility not granted")
|
|
NSLog("Curtain: refusing to cover — Accessibility not granted (would trap the desk)")
|
|
notifyAccessibilityNeeded()
|
|
return
|
|
}
|
|
state = .active
|
|
runner.activateCover()
|
|
Log.event("cover activated")
|
|
onStateChange?(true)
|
|
if notify { announceActivation() }
|
|
}
|
|
|
|
/// Take the cover down and move to .idle. No-op if already idle.
|
|
private func enterIdle() {
|
|
guard state == .active else { return }
|
|
state = .idle
|
|
runner.deactivateCover()
|
|
onStateChange?(false)
|
|
}
|
|
|
|
/// Run a configured set, then resync our state to whatever the cover ended at.
|
|
/// This keeps idle/end actions (which may or may not deactivate) idempotent: if
|
|
/// the set tears the cover down we land in .idle; if it leaves it up we stay
|
|
/// .active so a later disconnect re-arms cleanly without double-running actions.
|
|
private func applySet(_ set: ActionSet) {
|
|
runner.run(set)
|
|
state = curtain.isShown ? .active : .idle
|
|
onStateChange?(curtain.isShown)
|
|
}
|
|
|
|
// MARK: - Password unlock (desk reveal)
|
|
|
|
/// Host typed the correct password at the desk. Order is load-bearing: if the
|
|
/// remote should be cut, sever it FIRST so the operator never sees the desktop,
|
|
/// and only then drop the cover. No modal — that would freeze the runloop the
|
|
/// event tap rides on.
|
|
private func handlePasswordUnlock() {
|
|
Log.event("password accepted; unlockDisconnect=\(Settings.unlockDisconnect)")
|
|
if Settings.unlockDisconnect {
|
|
System.endScreenShareSession()
|
|
}
|
|
enterIdle()
|
|
}
|
|
|
|
// MARK: - Manual controls
|
|
|
|
func activateNow() {
|
|
connectGrace?.cancel(); connectGrace = nil
|
|
enterActive(notify: false)
|
|
}
|
|
|
|
/// Force the cover down with no password gate. Used internally and on quit.
|
|
func deactivateNow() {
|
|
connectGrace?.cancel(); connectGrace = nil
|
|
testTeardown?.cancel(); testTeardown = nil
|
|
enterIdle()
|
|
}
|
|
|
|
/// Menu-driven deactivate. If the setting requires a password and the cover is
|
|
/// up, refuse to deactivate, surface the on-curtain password box, and report
|
|
/// false. Otherwise deactivate and report true.
|
|
@discardableResult
|
|
func requestDeactivateFromMenu() -> Bool {
|
|
if Settings.requirePasswordToDeactivateFromMenu, state == .active, input.isTapInstalled {
|
|
// Keep the cover up: the tap is live, so a physical keypress raises the
|
|
// on-curtain password box — that is the intended unlock path. If the tap
|
|
// is NOT installed (transient tap-create failure after the AX check), the
|
|
// box can never receive keys, so refusing the menu here would strand the
|
|
// desk with only the emergency hotkey; fall through and deactivate instead.
|
|
return false
|
|
}
|
|
deactivateNow()
|
|
return true
|
|
}
|
|
|
|
var isActive: Bool { state == .active }
|
|
var isArmed: Bool { Settings.armed }
|
|
|
|
/// Persist the armed flag. Disarming forces the cover down immediately so the
|
|
/// Mac is never left covered by a system the user just turned off. When the
|
|
/// user chose "Refuse to arm" for missing Accessibility, arming is rejected
|
|
/// outright (with a notification) rather than arming a system that could only
|
|
/// warn at connect time — the cover would refuse to rise anyway.
|
|
func setArmed(_ on: Bool) {
|
|
if on, Settings.accessibilityRefuseToArm, !AXIsProcessTrusted() {
|
|
Log.event("arming refused: Accessibility not granted (refuseToArm)")
|
|
Notifier.post(title: "Curtain",
|
|
body: "Not armed: grant Accessibility first (Settings > Security).")
|
|
onArmedChange?(false)
|
|
return
|
|
}
|
|
Log.event("armed=\(on)")
|
|
Settings.armed = on
|
|
onArmedChange?(on)
|
|
if !on { deactivateNow() }
|
|
}
|
|
|
|
/// Briefly show the cover for a visual check. Cancelable, and a real connect
|
|
/// during the window cancels the teardown so we don't tear down a live session.
|
|
func testCurtain(seconds: TimeInterval) {
|
|
// Refuse to schedule a bounded test teardown while a REAL session has the
|
|
// cover up — that would drop a live session's cover after the test delay.
|
|
guard state == .idle else { return }
|
|
testTeardown?.cancel()
|
|
// Bounded + auto-deactivating, so it's safe to show even without Accessibility.
|
|
enterActive(notify: false, requireAX: false)
|
|
let work = DispatchWorkItem { [weak self] in
|
|
self?.testTeardown = nil
|
|
self?.enterIdle()
|
|
}
|
|
testTeardown = work
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + seconds, execute: work)
|
|
}
|
|
|
|
/// Persist the disconnect-helper toggle and reconcile the privileged daemon.
|
|
/// Registering/unregistering the LaunchDaemon and (re)installing the disconnect
|
|
/// handler is delegated to DisconnectClient, which is idempotent and logs errors.
|
|
func enableDisconnectHelper(_ on: Bool) {
|
|
// Settings exposes this flag read-only; persist via the shared defaults key.
|
|
UserDefaults.standard.set(on, forKey: Settings.Key.disconnectFeatureEnabled)
|
|
DisconnectClient.shared.setEnabled(on)
|
|
DisconnectClient.shared.syncWithSettings()
|
|
}
|
|
|
|
/// Cleanup path for app termination: drop the cover and release the assertion.
|
|
/// runner.deactivateCover() already releases the IOKit display-sleep assertion,
|
|
/// so a second direct call to System.allowDisplaySleep() here is redundant and
|
|
/// was removed to keep the release path in exactly one place.
|
|
func deactivateNowForQuit() {
|
|
connectGrace?.cancel(); connectGrace = nil
|
|
testTeardown?.cancel(); testTeardown = nil
|
|
runner.deactivateCover()
|
|
state = .idle
|
|
}
|
|
|
|
// MARK: - Activation feedback
|
|
|
|
private func announceActivation() {
|
|
if Settings.notifyOnActivate {
|
|
os_log("Curtain active: desk covered, physical input blocked")
|
|
// Surface a real banner via UNUserNotificationCenter so the user
|
|
// (or a test harness) can observe the activation event in Notification
|
|
// Center, not just the system log.
|
|
Notifier.post(title: "Curtain", body: "Privacy curtain activated.")
|
|
}
|
|
if Settings.playSoundOnActivate {
|
|
NSSound.beep()
|
|
}
|
|
}
|
|
}
|