curtain/Sources/Curtain/AppDelegate.swift
acamarata 709669e0cb Curtain v1.0.0 — privacy curtain for macOS Screen Sharing
Menu-bar agent that, on a Screen Sharing connection, covers the host
displays and blocks physical keyboard/mouse from the apps while remote
input passes through, then locks the Mac on idle or disconnect.

- netstat-based session detection (debounced)
- CGEventTap input filter (block physical sourceStateID==1, pass remote)
- .none/.readOnly cover windows with on-curtain password box
- SACLockScreenImmediate lock + IOKit display-sleep assertion
- root helper (NOPASSWD) to disconnect the Screen Sharing session
- install/uninstall scripts, app bundle, login agent, CI
2026-06-01 14:10:50 -04:00

183 lines
7.8 KiB
Swift

import Cocoa
/// Purpose: Menu-bar agent that wires the monitor, curtain, and input filter together.
/// Lifecycle (the entire product):
/// connect -> show curtain, block physical input, allow remote, keep displays awake
/// password -> end the Screen Sharing session, drop the curtain (optionally confirm)
/// idle 30m -> end session, lock the Mac, sleep displays
/// disconnect -> lock the Mac, sleep displays
/// SPORT: MASTER-APP
final class AppDelegate: NSObject, NSApplicationDelegate {
private let monitor = SessionMonitor()
private let curtain = CurtainController()
private let input = InputFilter()
private var statusItem: NSStatusItem!
private var tickTimer: Timer?
func applicationDidFinishLaunching(_ n: Notification) {
setupMenuBar()
input.onPhysicalKey = { [weak self] kc, chars in self?.curtain.physicalKey(kc, chars) }
curtain.onUnlock = { [weak self] in self?.unlockFromDesk() }
monitor.onConnect = { [weak self] in self?.sessionStarted() }
monitor.onDisconnect = { [weak self] in self?.sessionEnded(lock: true) }
monitor.onIdleTimeout = { [weak self] in self?.idleTimedOut() }
monitor.start()
tickTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
self?.curtain.tick()
}
if !AXIsProcessTrusted() { promptForAccessibility() }
}
// MARK: - Lifecycle handlers
private func sessionStarted() {
guard Config.shared.enabled else { return }
curtain.show()
System.preventDisplaySleep()
if !input.start() { /* Accessibility missing; curtain still hides the screen */ }
updateMenuBarState(active: true)
}
private func sessionEnded(lock: Bool) {
input.stop()
curtain.hide()
System.allowDisplaySleep()
updateMenuBarState(active: false)
if lock {
System.lockScreen()
DispatchQueue.main.asyncAfter(deadline: .now() + 1) { System.sleepDisplays() }
}
}
private func idleTimedOut() {
System.endScreenShareSession() // kick the idle remote operator
sessionEnded(lock: true)
}
/// Host typed the correct password at the desk: end the remote session and reveal the desktop.
private func unlockFromDesk() {
input.stop()
curtain.hide()
System.allowDisplaySleep()
updateMenuBarState(active: false)
let alert = NSAlert()
alert.messageText = "End the remote session?"
alert.informativeText = "Unlocked at this Mac. Disconnect the active Screen Sharing session?"
alert.addButton(withTitle: "Disconnect Remote")
alert.addButton(withTitle: "Keep Connected")
alert.alertStyle = .informational
NSApp.activate(ignoringOtherApps: true)
if alert.runModal() == .alertFirstButtonReturn {
System.endScreenShareSession()
}
}
// MARK: - Menu bar
private func setupMenuBar() {
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
updateMenuBarState(active: false)
let menu = NSMenu()
menu.addItem(withTitle: "Curtain", action: nil, keyEquivalent: "")
menu.addItem(.separator())
func add(_ title: String, _ sel: Selector, key: String = "") -> NSMenuItem {
let item = NSMenuItem(title: title, action: sel, keyEquivalent: key)
item.target = self; menu.addItem(item); return item
}
let enabled = add("Armed", #selector(toggleEnabled))
enabled.state = Config.shared.enabled ? .on : .off
_ = add("Set Password…", #selector(setPassword))
_ = add("Identify Displays", #selector(identifyDisplays))
_ = add("Mark Current Externals as DisplayLink", #selector(markDisplayLink))
menu.addItem(.separator())
_ = add("Test Curtain (10s)", #selector(testCurtain))
menu.addItem(.separator())
_ = add("Quit Curtain", #selector(quit), key: "q")
statusItem.menu = menu
}
private func updateMenuBarState(active: Bool) {
if let b = statusItem.button {
b.title = active ? "🔒" : (Config.shared.enabled ? "👁" : "")
}
}
@objc private func toggleEnabled(_ item: NSMenuItem) {
Config.shared.enabled.toggle(); Config.shared.save()
item.state = Config.shared.enabled ? .on : .off
if !Config.shared.enabled, curtain.isShown { sessionEnded(lock: false) }
updateMenuBarState(active: curtain.isShown)
}
@objc private func setPassword() {
let alert = NSAlert()
alert.messageText = "Set Curtain Password"
alert.informativeText = "Typed at the desk to end a remote session."
let field = NSSecureTextField(frame: NSRect(x: 0, y: 0, width: 240, height: 24))
alert.accessoryView = field
alert.addButton(withTitle: "Save"); alert.addButton(withTitle: "Cancel")
NSApp.activate(ignoringOtherApps: true)
if alert.runModal() == .alertFirstButtonReturn, !field.stringValue.isEmpty {
Config.shared.setPassword(field.stringValue)
}
}
@objc private func markDisplayLink() {
// Externals = every non-built-in display. Practical default for DisplayLink setups.
var serials: [UInt32] = []
for s in NSScreen.screens {
let id = s.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as! CGDirectDisplayID
if CGDisplayIsBuiltin(id) == 0 { serials.append(System.serial(of: s)) }
}
Config.shared.displayLinkSerials = serials; Config.shared.save()
notify("Marked \(serials.count) display(s) as DisplayLink.")
}
@objc private func identifyDisplays() {
// Briefly flash a big number on each display so the user knows the index/serial.
var wins: [NSWindow] = []
for (i, screen) in NSScreen.screens.enumerated() {
let w = NSWindow(contentRect: screen.frame, styleMask: .borderless, backing: .buffered, defer: false)
w.level = NSWindow.Level(rawValue: Int(CGWindowLevelForKey(.maximumWindow)))
w.backgroundColor = NSColor.black.withAlphaComponent(0.85)
w.collectionBehavior = [.canJoinAllSpaces, .stationary]
let lbl = NSTextField(labelWithString: "\(i)\nserial \(System.serial(of: screen))")
lbl.frame = NSRect(x: 0, y: screen.frame.height/2 - 120, width: screen.frame.width, height: 240)
lbl.alignment = .center; lbl.font = .systemFont(ofSize: 120, weight: .bold)
lbl.textColor = .white; lbl.backgroundColor = .clear; lbl.isBezeled = false; lbl.isEditable = false
lbl.maximumNumberOfLines = 2
w.contentView?.addSubview(lbl)
w.orderFrontRegardless(); wins.append(w)
}
DispatchQueue.main.asyncAfter(deadline: .now() + 6) { wins.forEach { $0.orderOut(nil) } }
}
@objc private func testCurtain() {
curtain.show(); System.preventDisplaySleep(); _ = input.start()
updateMenuBarState(active: true)
DispatchQueue.main.asyncAfter(deadline: .now() + 10) { [weak self] in
self?.input.stop(); self?.curtain.hide(); System.allowDisplaySleep()
self?.updateMenuBarState(active: false)
}
}
@objc private func quit() { sessionEnded(lock: false); NSApp.terminate(nil) }
// MARK: - Permission + notify
private func promptForAccessibility() {
let opts = [kAXTrustedCheckOptionPrompt.takeUnretainedValue(): true] as CFDictionary
_ = AXIsProcessTrustedWithOptions(opts)
notify("Grant Curtain Accessibility in System Settings, then relaunch, so it can block desk input.")
}
private func notify(_ text: String) {
let a = NSAlert(); a.messageText = "Curtain"; a.informativeText = text
NSApp.activate(ignoringOtherApps: true); a.runModal()
}
}