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

208 lines
9.8 KiB
Swift

import Cocoa
import AVFoundation
/// Purpose: The cover backdrop for one display. Renders per Settings.coverStyle
/// (solidColor / message / blur / logo / curtainLogo / aerial), optionally
/// a live clock, and an Accessibility warning banner when desk input is not
/// blocked. The "aerial" style attaches a layer to the shared AVQueuePlayer
/// provided by CurtainController; a solid opaque base is always installed
/// first so a failed or black video never exposes the desktop beneath.
/// Inputs: aerialPlayer (optional, from CurtainController), updateClock/
/// setWarningVisible from the controller tick.
/// Outputs: none (pure display).
/// Constraints: @MainActor (AppKit). Layer state torn down in teardownAerialLayer()
/// before the window is discarded; deinit captures locals so the
/// off-main landing is safe.
/// SPORT: MASTER-CURTAIN
@MainActor
final class CoverContentView: NSView {
private var blurView: NSVisualEffectView?
private var brandIcon: NSImageView?
private let glyph = NSTextField(labelWithString: "")
private let messageLabel = NSTextField(labelWithString: "")
private let clockLabel = NSTextField(labelWithString: "")
private let warning = NSTextField(labelWithString: "")
// Aerial-video backdrop state. CoverContentView owns only its own AVPlayerLayer;
// the AVQueuePlayer and AVPlayerLooper are shared and owned by CurtainController.
// Only playerLayer is retained here; torn down in teardownAerialLayer().
private var playerLayer: AVPlayerLayer?
/// Whether this view currently has an aerial player layer attached. Used by
/// CurtainController to track how many aerial covers remain after a reconcile.
var hasAerialLayer: Bool { playerLayer != nil }
/// Designated initialiser. Pass a non-nil aerialPlayer when the current cover
/// style is "aerial"; nil for all other styles.
init(frame frameRect: NSRect, aerialPlayer: AVQueuePlayer?) {
super.init(frame: frameRect)
wantsLayer = true
autoresizingMask = [.width, .height]
build(aerialPlayer: aerialPlayer)
}
required init?(coder: NSCoder) { fatalError() }
deinit {
// deinit can land off the main actor; capture the view's own AVPlayerLayer
// and remove it on main. The shared AVQueuePlayer/AVPlayerLooper are owned
// and torn down by CurtainController, never here.
let layer = playerLayer
Task { @MainActor in
layer?.removeFromSuperlayer()
}
}
/// Stop and remove the aerial video layer (called before a window is dropped or
/// rebuilt). Idempotent. Releases the AVPlayerLayer; the shared player/looper
/// are owned by CurtainController and released there after all layers are gone.
func teardownAerialLayer() {
playerLayer?.removeFromSuperlayer()
playerLayer = nil
}
/// Re-render this view at the logo style when the async playability check
/// determines the aerial asset cannot be decoded. Tears down any existing aerial
/// layer first so the slot is clean, then applies a static logo appearance.
func applyLogoFallback() {
teardownAerialLayer()
glyph.stringValue = "🔒"
messageLabel.stringValue = "Remote Session Active"
Log.event("aerial unavailable for cover, switched to logo")
}
private func build(aerialPlayer: AVQueuePlayer?) {
let style = Settings.coverStyle
let base = color(fromHex: Settings.coverColorHex) ?? NSColor(red: 0.03, green: 0.03, blue: 0.05, alpha: 1)
layer?.backgroundColor = base.cgColor
// The aerial video renders behind every other cover element. If no shared
// player was provided (asset not found / style not aerial) the view falls
// through to the branded logo cover. An opaque base color is always set
// first so a failed or black video never exposes the desktop.
// Legacy "screensaver" configs are mapped to the safe static logo cover.
var effectiveStyle = style
if style == "screensaver" {
effectiveStyle = "logo"
} else if style == "aerial" {
if let player = aerialPlayer {
installAerialLayer(player: player)
Log.event("aerial layer attached")
effectiveStyle = "solidColor" // video is the backdrop; suppress glyph/message
} else {
Log.event("aerial unavailable, using logo")
effectiveStyle = "logo"
}
}
let renderStyle = effectiveStyle
if renderStyle == "blur" {
let v = NSVisualEffectView(frame: bounds)
v.autoresizingMask = [.width, .height]
v.material = .fullScreenUI
v.blendingMode = .behindWindow
v.state = .active
v.appearance = NSAppearance(named: .darkAqua)
addSubview(v)
blurView = v
}
// Logo glyph + tagline (logo style, and a sensible default for others).
glyph.frame = NSRect(x: 0, y: bounds.midY + 12, width: bounds.width, height: 72)
configureLabel(glyph, size: 56, weight: .thin, color: NSColor(white: 0.30, alpha: 1))
glyph.autoresizingMask = [.width, .minYMargin, .maxYMargin]
addSubview(glyph)
messageLabel.frame = NSRect(x: 0, y: bounds.midY - 40, width: bounds.width, height: 36)
configureLabel(messageLabel, size: 20, weight: .regular, color: NSColor(white: 0.50, alpha: 1))
messageLabel.autoresizingMask = [.width, .minYMargin, .maxYMargin]
addSubview(messageLabel)
switch renderStyle {
case "message":
glyph.stringValue = ""
messageLabel.stringValue = Settings.coverMessage.isEmpty ? "Remote Session Active" : Settings.coverMessage
messageLabel.font = .systemFont(ofSize: 28, weight: .light)
case "solidColor":
glyph.stringValue = ""
messageLabel.stringValue = ""
case "curtainLogo":
// Branded look: the app's own curtains artwork drawn large and centered,
// over a dark backdrop, with a quiet subtitle below it.
glyph.stringValue = ""
layer?.backgroundColor = NSColor(red: 0.03, green: 0.03, blue: 0.05, alpha: 1).cgColor
let side = min(bounds.width, bounds.height) * 0.3
let iv = NSImageView(frame: NSRect(x: (bounds.width - side) / 2,
y: bounds.midY - side / 2 + 24,
width: side, height: side))
iv.image = CurtainIcon.appIcon(size: side)
iv.imageScaling = .scaleProportionallyUpOrDown
iv.autoresizingMask = [.minXMargin, .maxXMargin, .minYMargin, .maxYMargin]
addSubview(iv)
brandIcon = iv
messageLabel.stringValue = "Locked — press your key to unlock"
default: // "logo", "blur"
glyph.stringValue = "🔒"
messageLabel.stringValue = "Remote Session Active"
}
// Live clock (centered, below the tagline). Hidden until updateClock runs.
clockLabel.frame = NSRect(x: 0, y: bounds.midY - 96, width: bounds.width, height: 30)
configureLabel(clockLabel, size: 18, weight: .regular, color: NSColor(white: 0.55, alpha: 1))
clockLabel.autoresizingMask = [.width, .minYMargin, .maxYMargin]
clockLabel.isHidden = true
addSubview(clockLabel)
// Accessibility warning banner (top, hidden until setWarningVisible(true)).
warning.frame = NSRect(x: 0, y: bounds.height - 64, width: bounds.width, height: 26)
configureLabel(warning, size: 14, weight: .semibold, color: NSColor(red: 1, green: 0.78, blue: 0.35, alpha: 1))
warning.stringValue = "Desk input not blocked — grant Accessibility in System Settings"
warning.autoresizingMask = [.width, .minYMargin]
warning.isHidden = true
addSubview(warning)
}
private func configureLabel(_ t: NSTextField, size: CGFloat, weight: NSFont.Weight, color: NSColor) {
t.alignment = .center
t.font = .systemFont(ofSize: size, weight: weight)
t.textColor = color
t.backgroundColor = .clear
t.isBezeled = false
t.isEditable = false
}
func updateClock(_ stamp: String?) {
guard let stamp else { clockLabel.isHidden = true; return }
clockLabel.stringValue = stamp
clockLabel.isHidden = false
}
func setWarningVisible(_ visible: Bool) { warning.isHidden = !visible }
private func color(fromHex hex: String) -> NSColor? {
var s = hex.trimmingCharacters(in: .whitespaces)
if s.hasPrefix("#") { s.removeFirst() }
guard s.count == 6, let v = UInt32(s, radix: 16) else { return nil }
return NSColor(red: CGFloat((v >> 16) & 0xFF) / 255,
green: CGFloat((v >> 8) & 0xFF) / 255,
blue: CGFloat(v & 0xFF) / 255, alpha: 1)
}
// MARK: - Aerial layer attachment
/// Attach an AVPlayerLayer to the shared aerial player. The layer is inserted as
/// the backmost sublayer so every label, clock, banner, and password box sit above
/// it. The opaque base color (set in build()) ensures a black or loading frame
/// never exposes the desktop the solid background is always visible under the
/// video layer. Uses aspect-fill so the video covers the full display regardless
/// of the video's native aspect ratio.
private func installAerialLayer(player: AVQueuePlayer) {
let layer = AVPlayerLayer(player: player)
layer.videoGravity = .resizeAspectFill
layer.frame = bounds
wantsLayer = true
if let host = self.layer {
layer.autoresizingMask = [.layerWidthSizable, .layerHeightSizable]
host.insertSublayer(layer, at: 0)
}
self.playerLayer = layer
}
}