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.
208 lines
9.8 KiB
Swift
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
|
|
}
|
|
}
|