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

113 lines
5.6 KiB
Swift

import Cocoa
/// Purpose: The on-curtain unlock box. Keystrokes arrive from InputFilter
/// (physical keyboard), never via the normal responder chain, so it
/// works while the curtain stays click-through and non-key. Respects the
/// shared Settings lockout backoff and auto-hides after inactivity.
/// Inputs: key() from CurtainController.physicalKey(), tick() at 1 Hz.
/// Outputs: onSuccess closure when the correct password is entered.
/// Constraints: @MainActor. Buffer is zeroed on every state transition (success,
/// failure, lockout, Esc, initial reveal) so the plaintext credential
/// never lingers in memory longer than necessary.
/// SPORT: MASTER-CURTAIN
@MainActor
final class PasswordBox: NSView {
var onSuccess: (() -> Void)?
private let dots = NSTextField(labelWithString: "")
private let err = NSTextField(labelWithString: "")
private let prompt = NSTextField(labelWithString: "Enter password")
private var buffer = ""
private var hideAt: TimeInterval = 0
override init(frame: NSRect) { super.init(frame: frame); build() }
required init?(coder: NSCoder) { fatalError() }
private func build() {
let pw = 380.0, ph = 196.0
let box = NSView(frame: NSRect(x: (frame.width - pw) / 2, y: (frame.height - ph) / 2, width: pw, height: ph))
box.wantsLayer = true
box.layer?.backgroundColor = NSColor(white: 0.10, alpha: 0.98).cgColor
box.layer?.cornerRadius = 16
box.autoresizingMask = [.minXMargin, .maxXMargin, .minYMargin, .maxYMargin]
addSubview(box)
func label(_ s: String, _ y: Double, _ sz: Double, _ c: NSColor) -> NSTextField {
let t = NSTextField(labelWithString: s)
t.frame = NSRect(x: 10, y: y, width: pw - 20, height: 34); t.alignment = .center
t.textColor = c; t.backgroundColor = .clear; t.isBezeled = false; t.isEditable = false
t.font = .systemFont(ofSize: sz, weight: .medium); box.addSubview(t); return t
}
_ = label("🔒", 144, 38, .white)
prompt.frame = NSRect(x: 10, y: 120, width: pw - 20, height: 34); prompt.alignment = .center
prompt.textColor = NSColor(white: 0.85, alpha: 1); prompt.backgroundColor = .clear
prompt.isBezeled = false; prompt.isEditable = false; prompt.font = .systemFont(ofSize: 14, weight: .medium)
box.addSubview(prompt)
let field = NSView(frame: NSRect(x: 90, y: 82, width: 200, height: 30))
field.wantsLayer = true; field.layer?.backgroundColor = NSColor(white: 0.20, alpha: 1).cgColor
field.layer?.cornerRadius = 6; box.addSubview(field)
dots.frame = NSRect(x: 90, y: 84, width: 200, height: 26); dots.alignment = .center
dots.textColor = .white; dots.backgroundColor = .clear; dots.isBezeled = false; dots.isEditable = false
dots.font = .systemFont(ofSize: 18); box.addSubview(dots)
err.frame = NSRect(x: 10, y: 56, width: pw - 20, height: 18); err.alignment = .center
err.textColor = NSColor(red: 1, green: 0.4, blue: 0.4, alpha: 1); err.backgroundColor = .clear
err.isBezeled = false; err.isEditable = false; err.font = .systemFont(ofSize: 12); err.isHidden = true
box.addSubview(err)
_ = label("Return to unlock · Esc to dismiss", 24, 12, NSColor(white: 0.42, alpha: 1))
}
private func bump() { hideAt = Date().timeIntervalSince1970 + Double(Settings.passwordBoxTimeoutSeconds) }
func tick() {
guard !isHidden else { return }
// While locked out, keep the box up and count the backoff down.
if Settings.isLockedOut { showLockout(); return }
if Date().timeIntervalSince1970 > hideAt { isHidden = true }
}
func key(keycode: Int, chars: String?) {
if isHidden { buffer = ""; dots.stringValue = ""; err.isHidden = true; isHidden = false }
bump()
if Settings.isLockedOut { showLockout(); return }
switch keycode {
case 36, 76: // Return / Enter
if Settings.verify(buffer) {
Settings.resetFailedAttempts()
// Zero the buffer and dot display before calling back so the
// plaintext credential doesn't linger while the curtain comes down.
buffer = ""
dots.stringValue = ""
onSuccess?()
} else {
Settings.registerFailedAttempt()
buffer = ""; dots.stringValue = ""
if Settings.isLockedOut { showLockout() }
else { err.stringValue = "Wrong password"; err.isHidden = false }
}
case 53: // Esc
// Clear the buffer and dots before hiding so a partial password attempt
// is not left in memory or on-screen if the box is re-revealed quickly.
buffer = ""
dots.stringValue = ""
isHidden = true
case 51: // Delete
if !buffer.isEmpty { buffer.removeLast() }
dots.stringValue = String(repeating: "", count: buffer.count); err.isHidden = true
default:
if let c = chars, let ch = c.first, ch.isLetter || ch.isNumber || ch.isPunctuation || ch.isSymbol {
buffer += c
dots.stringValue = String(repeating: "", count: buffer.count); err.isHidden = true
}
}
}
private func showLockout() {
let secs = Int(ceil(Settings.backoffRemaining))
buffer = ""; dots.stringValue = ""
err.stringValue = "Try again in \(secs)s"
err.isHidden = false
}
}