mirror of
https://github.com/acamarata/curtain.git
synced 2026-06-30 18:54: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
150 lines
7.2 KiB
Swift
150 lines
7.2 KiB
Swift
import Cocoa
|
|
|
|
/// Purpose: The visible cover + password box on the host's physical monitors.
|
|
/// How: one borderless, max-level, opaque window per display. Native displays use
|
|
/// sharingType=.none (invisible to the remote operator, who sees the real
|
|
/// desktop); DisplayLink displays use .readOnly. Windows are click-through
|
|
/// (ignoresMouseEvents) and never key, so they never interfere with the
|
|
/// remote cursor — physical input is blocked by InputFilter, not the window.
|
|
/// SPORT: MASTER-CURTAIN
|
|
final class CurtainController {
|
|
private var windows: [NSWindow] = []
|
|
private var box: PasswordBox?
|
|
var onUnlock: (() -> Void)?
|
|
|
|
var isShown: Bool { !windows.isEmpty }
|
|
|
|
func show() {
|
|
guard windows.isEmpty else { return }
|
|
for (i, screen) in NSScreen.screens.enumerated() {
|
|
let w = makeWindow(screen: screen, primary: i == 0)
|
|
windows.append(w)
|
|
}
|
|
}
|
|
|
|
func hide() {
|
|
windows.forEach { $0.orderOut(nil) }
|
|
windows.removeAll()
|
|
box = nil
|
|
}
|
|
|
|
/// Feed a physical key into the password box (from InputFilter).
|
|
func physicalKey(_ keycode: Int, _ chars: String?) {
|
|
box?.key(keycode: keycode, chars: chars)
|
|
}
|
|
|
|
/// Called once per second to auto-hide the password box after inactivity.
|
|
func tick() { box?.tick() }
|
|
|
|
private func makeWindow(screen: NSScreen, primary: Bool) -> NSWindow {
|
|
let w = CoverWindow(contentRect: screen.frame, styleMask: .borderless,
|
|
backing: .buffered, defer: false)
|
|
w.backgroundColor = .black
|
|
w.level = NSWindow.Level(rawValue: Int(CGWindowLevelForKey(.maximumWindow)))
|
|
w.collectionBehavior = [.canJoinAllSpaces, .stationary, .ignoresCycle, .fullScreenAuxiliary]
|
|
w.isOpaque = true
|
|
w.hasShadow = false
|
|
w.ignoresMouseEvents = true
|
|
w.sharingType = System.isDisplayLink(screen) ? .readOnly : .none
|
|
|
|
let content = NSView(frame: NSRect(origin: .zero, size: screen.frame.size))
|
|
content.wantsLayer = true
|
|
content.layer?.backgroundColor = NSColor(red: 0.03, green: 0.03, blue: 0.05, alpha: 1).cgColor
|
|
content.autoresizingMask = [.width, .height]
|
|
content.addSubview(centeredLabel("🔒", size: 56, y: content.bounds.midY + 12,
|
|
color: NSColor(white: 0.30, alpha: 1), width: content.bounds.width))
|
|
content.addSubview(centeredLabel("Remote Session Active", size: 20, y: content.bounds.midY - 40,
|
|
color: NSColor(white: 0.50, alpha: 1), width: content.bounds.width))
|
|
if primary {
|
|
let b = PasswordBox(frame: content.bounds)
|
|
b.isHidden = true
|
|
b.autoresizingMask = [.width, .height]
|
|
b.onSuccess = { [weak self] in self?.onUnlock?() }
|
|
content.addSubview(b)
|
|
box = b
|
|
}
|
|
w.contentView = content
|
|
w.orderFrontRegardless()
|
|
return w
|
|
}
|
|
|
|
private func centeredLabel(_ s: String, size: CGFloat, y: CGFloat, color: NSColor, width: CGFloat) -> NSTextField {
|
|
let t = NSTextField(labelWithString: s)
|
|
t.frame = NSRect(x: 0, y: y, width: width, height: size + 16)
|
|
t.alignment = .center; t.font = .systemFont(ofSize: size, weight: .thin)
|
|
t.textColor = color; t.backgroundColor = .clear; t.isBezeled = false; t.isEditable = false
|
|
t.autoresizingMask = [.width, .minYMargin, .maxYMargin]
|
|
return t
|
|
}
|
|
}
|
|
|
|
/// A window that never becomes key, so it can never steal focus from the remote session.
|
|
private final class CoverWindow: NSWindow {
|
|
override var canBecomeKey: Bool { false }
|
|
override var canBecomeMain: Bool { false }
|
|
}
|
|
|
|
/// Purpose: the on-curtain unlock box. Keystrokes arrive from InputFilter (physical
|
|
/// keyboard), never via normal responder chain, so it works while the
|
|
/// curtain stays click-through and non-key.
|
|
final class PasswordBox: NSView {
|
|
var onSuccess: (() -> Void)?
|
|
private let dots = NSTextField(labelWithString: "")
|
|
private let err = NSTextField(labelWithString: "")
|
|
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
|
|
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)
|
|
_ = label("Enter password", 120, 14, NSColor(white: 0.85, alpha: 1))
|
|
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 + 6 }
|
|
func tick() { if !isHidden && Date().timeIntervalSince1970 > hideAt { isHidden = true } }
|
|
|
|
func key(keycode: Int, chars: String?) {
|
|
if isHidden { buffer = ""; dots.stringValue = ""; err.isHidden = true; isHidden = false }
|
|
bump()
|
|
switch keycode {
|
|
case 36, 76: // Return / Enter
|
|
if Config.shared.verify(buffer) { onSuccess?() }
|
|
else { buffer = ""; dots.stringValue = ""; err.stringValue = "Wrong password"; err.isHidden = false }
|
|
case 53: // Esc
|
|
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
|
|
}
|
|
}
|
|
}
|
|
}
|