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.
113 lines
5.6 KiB
Swift
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
|
|
}
|
|
}
|