mirror of
https://github.com/acamarata/curtain.git
synced 2026-07-01 11:14: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.
112 lines
4.6 KiB
Swift
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
|
|
}
|
|
}
|