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.
479 lines
20 KiB
Swift
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 }
|
|
}
|