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
This commit is contained in:
acamarata 2026-06-01 14:10:50 -04:00
commit 709669e0cb
15 changed files with 857 additions and 0 deletions

12
.github/workflows/build.yml vendored Normal file
View file

@ -0,0 +1,12 @@
name: build
on:
push:
branches: [main]
pull_request:
jobs:
build:
runs-on: macos-14
steps:
- uses: actions/checkout@v4
- name: Build (release)
run: swift build -c release

24
.gitignore vendored Normal file
View file

@ -0,0 +1,24 @@
# Swift / SPM
.build/
.swiftpm/
*.xcodeproj
DerivedData/
# macOS
.DS_Store
# AI working memory (gitignored)
.claude/
# Local secrets / config
*.local
.vscode/*
.idea/
.codex/
.cursor/
.aider/
.aider.chat.history.md
.continue/
.windsurf/
.gemini/
.codeium/

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Aric Camarata
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

13
Package.swift Normal file
View file

@ -0,0 +1,13 @@
// swift-tools-version:5.9
import PackageDescription
let package = Package(
name: "Curtain",
platforms: [.macOS(.v13)],
targets: [
.executableTarget(
name: "Curtain",
path: "Sources/Curtain"
)
]
)

45
README.md Normal file
View file

@ -0,0 +1,45 @@
# Curtain
A privacy curtain for macOS Screen Sharing. When you remote into your Mac, Curtain hides the screen from anyone sitting at it and makes the local keyboard and mouse do nothing — while you keep full control from your laptop. When the session ends or goes idle, it locks the Mac and sleeps the displays.
macOS already does Screen Sharing well. Curtain is *only* the missing privacy layer around it — a lightweight menu-bar agent, in the spirit of Caffeine.
## What it does
| Event | Behavior |
|---|---|
| **Screen Sharing connects** | Curtain covers every physical display. The local keyboard/mouse are blocked from the apps; your remote keyboard/mouse work normally. A password box appears if someone presses a key at the desk. |
| **Password entered at the desk** | Ends the remote session and reveals the desktop (asks first). |
| **Session idle ~30 min** | Disconnects the remote session, locks the Mac, sleeps the displays. |
| **You disconnect** | Locks the Mac, sleeps the displays. |
The key trick: macOS tags real hardware input differently from injected remote input, so Curtain blocks the desk's keyboard/mouse while letting your remote control pass through — on the same login session, no virtual display needed.
## Install
```bash
git clone https://github.com/acamarata/curtain.git
cd curtain
./Scripts/install.sh
```
Then grant **Accessibility** to Curtain in System Settings → Privacy & Security → Accessibility (required so it can block desk input). From the menu-bar icon: **Set Password…** and, if you use DisplayLink monitors, **Mark Current Externals as DisplayLink**.
Uninstall: `./Scripts/uninstall.sh`
## Requirements
- macOS 13 (Ventura) or later — built and used on macOS 26 / Apple Silicon
- Screen Sharing enabled (System Settings → General → Sharing → Screen Sharing)
## A note on DisplayLink monitors
DisplayLink displays exist only through screen capture, so they can't be hidden invisibly the way native displays can. On those monitors the curtain also shows in your remote view. Native (directly-attached) displays are hidden from onlookers while staying clear in your remote session.
## How it works / lessons
See the [wiki](../../wiki) for the architecture, the macOS APIs involved, and the (many) lessons learned building this.
## License
MIT © Aric Camarata

7
Scripts/build.sh Executable file
View file

@ -0,0 +1,7 @@
#!/bin/bash
# Quick dev build + run (foreground, for testing). Use Scripts/install.sh for real install.
set -euo pipefail
cd "$(dirname "$0")/.."
swift build -c release
echo "Built: .build/release/Curtain"
echo "Run with: .build/release/Curtain (Ctrl-C to stop)"

79
Scripts/install.sh Executable file
View file

@ -0,0 +1,79 @@
#!/bin/bash
# Curtain installer — builds the app, installs it as a login menu-bar agent,
# and sets up the (optional) root helper that disconnects a Screen Sharing session.
set -euo pipefail
REPO="$(cd "$(dirname "$0")/.." && pwd)"
APP="/Applications/Curtain.app"
AGENT="$HOME/Library/LaunchAgents/io.acamarata.curtain.plist"
HELPER="/usr/local/bin/curtain-endsession"
SUDOERS="/etc/sudoers.d/curtain-endsession"
echo "==> Building (release)…"
cd "$REPO"
swift build -c release
BIN="$REPO/.build/release/Curtain"
echo "==> Installing $APP"
rm -rf "$APP"
mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources"
cp "$BIN" "$APP/Contents/MacOS/Curtain"
cat > "$APP/Contents/Info.plist" <<PLIST
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"><dict>
<key>CFBundleName</key><string>Curtain</string>
<key>CFBundleIdentifier</key><string>io.acamarata.curtain</string>
<key>CFBundleExecutable</key><string>Curtain</string>
<key>CFBundlePackageType</key><string>APPL</string>
<key>CFBundleShortVersionString</key><string>1.0.0</string>
<key>LSUIElement</key><true/>
<key>LSMinimumSystemVersion</key><string>13.0</string>
</dict></plist>
PLIST
# Ad-hoc sign so TCC (Accessibility) can pin a stable identity.
codesign --force --deep --sign - "$APP" 2>/dev/null || true
echo "==> Installing root helper (disconnect Screen Sharing on idle/unlock)…"
TMP_HELPER="$(mktemp)"
cat > "$TMP_HELPER" <<'HELPER'
#!/bin/bash
# Ends the active Screen Sharing session. launchd respawns the listener,
# so Screen Sharing stays available for the next connection.
pkill -f ScreenSharingSubscriber 2>/dev/null
pkill -x screensharingd 2>/dev/null
pkill -f "RemoteManagement.*[Ss]creen" 2>/dev/null
exit 0
HELPER
osascript -e "do shell script \"install -m 755 '$TMP_HELPER' '$HELPER' && printf 'admin ALL=(root) NOPASSWD: $HELPER\n' > '$SUDOERS' && chmod 440 '$SUDOERS' && visudo -cf '$SUDOERS'\" with administrator privileges"
rm -f "$TMP_HELPER"
echo "==> Installing login agent…"
mkdir -p "$HOME/Library/LaunchAgents"
cat > "$AGENT" <<PLIST
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"><dict>
<key>Label</key><string>io.acamarata.curtain</string>
<key>ProgramArguments</key><array><string>$APP/Contents/MacOS/Curtain</string></array>
<key>RunAtLoad</key><true/>
<key>KeepAlive</key><true/>
<key>LimitLoadToSessionType</key><string>Aqua</string>
</dict></plist>
PLIST
launchctl unload "$AGENT" 2>/dev/null || true
launchctl load -w "$AGENT"
cat <<EOF
✅ Curtain installed.
ONE manual step (required so Curtain can block desk input):
System Settings → Privacy & Security → Accessibility → enable "Curtain".
Then from the Curtain menu (🔒/👁 in the menu bar): quit & it relaunches at login,
or run: launchctl kickstart -k gui/\$(id -u)/io.acamarata.curtain
First-time setup from the menu bar:
• Set Password… (typed at the desk to end a session)
• Mark Current Externals as DisplayLink (if you use DisplayLink monitors)
EOF

19
Scripts/uninstall.sh Executable file
View file

@ -0,0 +1,19 @@
#!/bin/bash
# Curtain uninstaller — removes the app, login agent, and root helper.
set -uo pipefail
AGENT="$HOME/Library/LaunchAgents/io.acamarata.curtain.plist"
echo "==> Stopping agent…"
launchctl unload "$AGENT" 2>/dev/null || true
rm -f "$AGENT"
pkill -x Curtain 2>/dev/null || true
echo "==> Removing app + settings…"
rm -rf "/Applications/Curtain.app"
rm -rf "$HOME/Library/Application Support/Curtain"
echo "==> Removing root helper (needs admin)…"
osascript -e "do shell script \"rm -f /usr/local/bin/curtain-endsession /etc/sudoers.d/curtain-endsession\" with administrator privileges" || true
echo "✅ Curtain uninstalled. You may also remove it from System Settings → Privacy → Accessibility."

View file

@ -0,0 +1,183 @@
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()
}
}

View file

@ -0,0 +1,68 @@
import Foundation
import CryptoKit
/// Purpose: Persistent settings for Curtain, stored as JSON in Application Support.
/// Inputs: none (reads/writes ~/Library/Application Support/Curtain/config.json)
/// Outputs: a mutable singleton `Config.shared`
/// Constraints: password is stored as a salted SHA256 hash, never plaintext.
/// SPORT: MASTER-CONFIG
struct Config: Codable {
/// Whether Curtain is armed. When false, no curtain is shown on connect.
var enabled: Bool = true
/// Salted SHA256 of the unlock password (hex). Empty = no password set (uses default).
var passwordHash: String = ""
/// Random per-install salt for the password hash.
var salt: String = ""
/// Serial numbers of DisplayLink monitors. They can only be hidden with a
/// capturable cover (visible in the remote view too) see Lessons. Native
/// displays are hidden invisibly via sharingType=.none.
var displayLinkSerials: [UInt32] = []
/// Minutes of no input before the session is force-ended + the Mac locked.
var idleMinutes: Int = 30
private static var url: URL {
let dir = FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent("Library/Application Support/Curtain", isDirectory: true)
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
return dir.appendingPathComponent("config.json")
}
static var shared: Config = load()
static func load() -> Config {
guard let data = try? Data(contentsOf: url),
let cfg = try? JSONDecoder().decode(Config.self, from: data) else {
var c = Config(); c.salt = randomSalt(); c.save(); return c
}
return cfg
}
func save() {
if let data = try? JSONEncoder().encode(self) { try? data.write(to: Self.url) }
}
// MARK: - Password
/// Set a new unlock password (stored hashed).
mutating func setPassword(_ plain: String) {
if salt.isEmpty { salt = Self.randomSalt() }
passwordHash = Self.hash(plain, salt: salt)
save()
}
/// Verify a candidate password against the stored hash. If no password is set,
/// the built-in default "curtain" is accepted so the Mac is never unrecoverable.
func verify(_ candidate: String) -> Bool {
if passwordHash.isEmpty { return candidate == "curtain" }
return Self.hash(candidate, salt: salt) == passwordHash
}
private static func randomSalt() -> String {
(0..<16).map { _ in String(format: "%02x", UInt8.random(in: 0...255)) }.joined()
}
private static func hash(_ s: String, salt: String) -> String {
let digest = SHA256.hash(data: Data((salt + s).utf8))
return digest.map { String(format: "%02x", $0) }.joined()
}
}

View file

@ -0,0 +1,150 @@
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
}
}
}
}

View file

@ -0,0 +1,65 @@
import Cocoa
/// Purpose: Block PHYSICAL keyboard/mouse from reaching apps while passing REMOTE
/// (Screen Sharing) input through, so the host desk is inert but the
/// remote operator controls normally.
/// How: a CGEventTap inspects each event's source-state. Physical hardware events
/// report sourceStateID == 1 (kCGEventSourceStateHIDSystemState); Screen
/// Sharing injects synthetic events with a large per-session state ID (!= 1).
/// Verified empirically (see Lessons). Block ==1, pass everything else.
/// Constraints: requires Accessibility permission. Physical key-downs are routed
/// to `onPhysicalKey` to drive the password box.
/// SPORT: MASTER-INPUTFILTER
final class InputFilter {
private var tap: CFMachPort?
private var runLoopSource: CFRunLoopSource?
var onPhysicalKey: ((Int, String?) -> Void)?
/// Returns false if the tap could not be created (missing Accessibility).
@discardableResult
func start() -> Bool {
let types: [CGEventType] = [.keyDown, .keyUp, .flagsChanged, .mouseMoved,
.leftMouseDown, .leftMouseUp, .rightMouseDown, .rightMouseUp,
.otherMouseDown, .otherMouseUp, .leftMouseDragged, .rightMouseDragged, .scrollWheel]
let mask: CGEventMask = types.reduce(CGEventMask(0)) { $0 | (CGEventMask(1) << $1.rawValue) }
let me = Unmanaged.passUnretained(self).toOpaque()
guard let t = CGEvent.tapCreate(tap: .cgSessionEventTap, place: .headInsertEventTap,
options: .defaultTap, eventsOfInterest: mask, callback: callback, userInfo: me) else {
return false
}
tap = t
let src = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, t, 0)
runLoopSource = src
CFRunLoopAddSource(CFRunLoopGetCurrent(), src, .commonModes)
CGEvent.tapEnable(tap: t, enable: true)
return true
}
func stop() {
if let t = tap { CGEvent.tapEnable(tap: t, enable: false) }
if let s = runLoopSource { CFRunLoopRemoveSource(CFRunLoopGetCurrent(), s, .commonModes) }
tap = nil; runLoopSource = nil
}
fileprivate func handlePhysicalKeyDown(_ event: CGEvent) {
let kc = Int(event.getIntegerValueField(.keyboardEventKeycode))
let chars = NSEvent(cgEvent: event)?.charactersIgnoringModifiers
DispatchQueue.main.async { self.onPhysicalKey?(kc, chars) }
}
fileprivate func reenable() { if let t = tap { CGEvent.tapEnable(tap: t, enable: true) } }
}
private let callback: CGEventTapCallBack = { _, type, event, refcon in
let filter = Unmanaged<InputFilter>.fromOpaque(refcon!).takeUnretainedValue()
if type == .tapDisabledByTimeout || type == .tapDisabledByUserInput {
filter.reenable()
return Unmanaged.passUnretained(event)
}
let physical = (event.getIntegerValueField(.eventSourceStateID) == 1)
if physical {
if type == .keyDown { filter.handlePhysicalKeyDown(event) }
return nil // block hardware input from apps
}
return Unmanaged.passUnretained(event) // pass remote (synthetic) input
}

View file

@ -0,0 +1,76 @@
import Foundation
/// Purpose: Detect Screen Sharing connect / disconnect / idle and fire callbacks.
/// How: polls `netstat` for an ESTABLISHED connection on the VNC port (5900).
/// lsof does NOT work here the screensharing sockets are owned by _rmd/root
/// and are invisible to a user-context lsof (verified). Disconnect is debounced
/// (N consecutive misses) so a transient blip never kills a live session.
/// SPORT: MASTER-SESSIONMONITOR
final class SessionMonitor {
var onConnect: (() -> Void)?
var onDisconnect: (() -> Void)?
var onIdleTimeout: (() -> Void)?
private var timer: Timer?
private var connected = false
private var missCount = 0
private var idleFired = false
private let missLimit = 3 // ~6s at 2s poll
private let pollInterval: TimeInterval = 2
func start() {
connected = isVNCEstablished()
if connected { onConnect?() }
timer = Timer.scheduledTimer(withTimeInterval: pollInterval, repeats: true) { [weak self] _ in
self?.tick()
}
}
func stop() { timer?.invalidate(); timer = nil }
private func tick() {
if isVNCEstablished() {
missCount = 0
if !connected { connected = true; idleFired = false; onConnect?() }
if !idleFired, idleSeconds() >= Config.shared.idleMinutes * 60 {
idleFired = true
onIdleTimeout?()
} else if idleSeconds() < Config.shared.idleMinutes * 60 {
idleFired = false
}
} else if connected {
missCount += 1
if missCount >= missLimit { connected = false; missCount = 0; onDisconnect?() }
}
}
// MARK: - Probes
private func isVNCEstablished() -> Bool {
let out = shell("/usr/bin/netstat", ["-an"])
for line in out.split(separator: "\n") {
if line.contains(".5900 ") && line.contains("ESTABLISHED") { return true }
}
return false
}
/// Seconds since the last HID (human) input event.
private func idleSeconds() -> Int {
let out = shell("/usr/sbin/ioreg", ["-c", "IOHIDSystem"])
for line in out.split(separator: "\n") where line.contains("HIDIdleTime") {
if let ns = line.split(separator: "=").last.flatMap({ Int($0.trimmingCharacters(in: .whitespaces)) }) {
return ns / 1_000_000_000
}
}
return 0
}
private func shell(_ path: String, _ args: [String]) -> String {
let p = Process(); p.launchPath = path; p.arguments = args
let pipe = Pipe(); p.standardOutput = pipe; p.standardError = Pipe()
do { try p.run() } catch { return "" }
let data = pipe.fileHandleForReading.readDataToEndOfFile()
p.waitUntilExit()
return String(data: data, encoding: .utf8) ?? ""
}
}

View file

@ -0,0 +1,86 @@
import Cocoa
import IOKit.pwr_mgt
/// Purpose: Thin wrappers over the macOS system actions Curtain needs.
/// Constraints: every call here was validated against macOS 26 (Sequoia-era).
/// SPORT: MASTER-SYSTEM
enum System {
// MARK: - Reliable screen lock
//
// CGSession -suspend was removed in recent macOS. osascript Ctrl+Cmd+Q needs
// Accessibility and is unreliable from a launchd agent. SACLockScreenImmediate
// (private login.framework symbol) locks immediately with no extra permission.
static func lockScreen() {
let paths = [
"/System/Library/PrivateFrameworks/login.framework/Versions/Current/login",
"/System/Library/PrivateFrameworks/login.framework/login"
]
typealias LockFn = @convention(c) () -> Int32
for p in paths {
if let h = dlopen(p, RTLD_LAZY), let sym = dlsym(h, "SACLockScreenImmediate") {
_ = unsafeBitCast(sym, to: LockFn.self)()
return
}
}
// Fallback (needs Accessibility): the lock-screen shortcut.
let t = Process()
t.launchPath = "/usr/bin/osascript"
t.arguments = ["-e", "tell application \"System Events\" to keystroke \"q\" using {command down, control down}"]
try? t.run()
}
/// Put all displays to sleep (after a lock = a dark, locked Mac).
static func sleepDisplays() {
let t = Process(); t.launchPath = "/usr/bin/pmset"; t.arguments = ["displaysleepnow"]
try? t.run()
}
// MARK: - Prevent display sleep during a session
private static var assertionID: IOPMAssertionID = 0
private static var assertionActive = false
static func preventDisplaySleep() {
guard !assertionActive else { return }
let ok = IOPMAssertionCreateWithName(
kIOPMAssertionTypeNoDisplaySleep as CFString,
IOPMAssertionLevel(kIOPMAssertionLevelOn),
"Curtain active" as CFString,
&assertionID)
assertionActive = (ok == kIOReturnSuccess)
}
static func allowDisplaySleep() {
if assertionActive { IOPMAssertionRelease(assertionID); assertionActive = false }
}
// MARK: - End the active Screen Sharing session
//
// Killing the connection processes needs root, so install.sh drops a tiny
// helper at /usr/local/bin/curtain-endsession with a NOPASSWD sudoers rule.
// launchd respawns the listener, so Screen Sharing stays available afterward.
static func endScreenShareSession() {
let helper = "/usr/local/bin/curtain-endsession"
guard FileManager.default.isExecutableFile(atPath: helper) else { return }
let t = Process(); t.launchPath = "/usr/bin/sudo"; t.arguments = ["-n", helper]
try? t.run(); t.waitUntilExit()
}
// MARK: - Displays
static func serial(of screen: NSScreen) -> UInt32 {
let id = screen.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as! CGDirectDisplayID
return CGDisplaySerialNumber(id)
}
/// A native display can be hidden invisibly (sharingType .none). A DisplayLink
/// display only exists via screen capture, so .none hides it from the capture
/// too it must use .readOnly (visible in the remote view). We identify them
/// by serial because EDID passthrough makes vendor IDs identical.
static func isDisplayLink(_ screen: NSScreen) -> Bool {
Config.shared.displayLinkSerials.contains(serial(of: screen))
}
}

View file

@ -0,0 +1,9 @@
import Cocoa
// Curtain a privacy curtain for macOS Screen Sharing.
// Entry point: a background menu-bar agent (no Dock icon).
let app = NSApplication.shared
let delegate = AppDelegate()
app.delegate = delegate
app.setActivationPolicy(.accessory)
app.run()