mirror of
https://github.com/acamarata/curtain.git
synced 2026-07-01 03:04:25 +00:00
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
183 lines
7.8 KiB
Swift
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()
|
|
}
|
|
}
|