v1.1 — settings window, modular lifecycle actions, login item, logo

Rework Curtain into a proper menu-bar app (Caffeine-style):
- SwiftUI settings window: app, on-start, on-idle, on-end, security, displays
- Modular ActionSet/ActionRunner — per-event toggles (disconnect/lock/screen-off/deactivate)
- Configurable idle timeout; open-at-login via SMAppService; optional menu bar
- Settings persisted in UserDefaults (shared with @AppStorage); salted-SHA256 password
- Drawn curtains logo (menu-bar template + offscreen-rendered app icon)
- Split into single-responsibility modules with comment blocks
This commit is contained in:
acamarata 2026-06-01 15:51:20 -04:00
parent fa2efa5d05
commit 30d5a77ffa
15 changed files with 701 additions and 258 deletions

View file

@ -1,19 +1,23 @@
# Curtain # 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. 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 to your apps, while you keep full control from your laptop. When the session goes idle or ends, it can lock the Mac and sleep 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. macOS already does Screen Sharing well. Curtain is the missing privacy layer around it: a lightweight menu-bar agent with a simple settings window, in the spirit of Caffeine.
<p align="center"><em>Connect → screen covered, desk input dead, you control remotely. Idle/disconnect → lock + displays off.</em></p>
## Why it works (the one hard part)
Your laptop and the desk share one login session, so a window that blocks input would block you too. Curtain instead filters input by **source**: macOS tags real hardware events differently from injected remote events, so Curtain blocks the desk's keyboard/mouse while letting your remote control pass through. No virtual display, no second account.
## What it does ## What it does
| Event | Behavior | | Event | Default behavior (all configurable) |
|---|---| |---|---|
| **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. | | **Remote session starts** | Curtain covers every physical display; desk keyboard/mouse are blocked from apps; your remote input works; displays kept awake. |
| **Password entered at the desk** | Ends the remote session and reveals the desktop (asks first). | | **Key pressed at the desk** | A password box appears (on the desk only). Correct password reveals the desktop and offers to disconnect the remote. |
| **Session idle ~30 min** | Disconnects the remote session, locks the Mac, sleeps the displays. | | **Session idle (default 30 min)** | Disconnect remote · lock Mac · sleep displays · deactivate curtain. |
| **You disconnect** | Locks the Mac, sleeps the displays. | | **Session ends (disconnect)** | Lock Mac · sleep displays · deactivate curtain. |
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 ## Install
@ -23,22 +27,54 @@ cd curtain
./Scripts/install.sh ./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**. The installer builds the app to `/Applications/Curtain.app`, generates the curtains icon, registers a login agent, and sets up a small root helper (one admin prompt) used to disconnect a Screen Sharing session.
Then, **once**:
1. Grant **Accessibility** to Curtain: System Settings → Privacy & Security → Accessibility. This lets it block desk input. (Curtain prompts you on first launch.)
2. Open Curtain (menu-bar icon, or launch the app) → **Set a password** and, if you use DisplayLink monitors, **Mark Externals as DisplayLink**.
Uninstall: `./Scripts/uninstall.sh` Uninstall: `./Scripts/uninstall.sh`
## The settings window
Open it from the menu-bar curtains icon, or by launching Curtain.app. Everything is a toggle; changes take effect immediately.
### Application
- **Open at login** — run Curtain automatically (via `SMAppService`).
- **Show in menu bar** — show or hide the curtains icon. Hidden still runs in the background; reopen the app to get settings back.
- **Activate Now** / **Test (10s)** — show the curtain on demand.
### On session start
- **Activate curtain when a remote session begins** — the core behavior. Turn off to leave Curtain armed but passive.
### On session idle
- **Act after the session is idle** + **Idle timeout** (1240 min).
- Independent toggles for what happens at idle: **Disconnect the remote session**, **Lock the Mac**, **Turn off the displays**, **Deactivate the curtain**.
### On session end (disconnect)
- Independent toggles: **Lock the Mac**, **Turn off the displays**, **Deactivate the curtain**.
### Security
- **Disconnect remote when password is entered at the desk** — on unlock, offer to kick the remote operator.
- **Set password** — typed at the desk to get past the curtain. Stored as a salted SHA256 hash. If unset, the default is `curtain` so you are never locked out.
### Displays
- **Identify Displays** — flashes each display's index and serial.
- **Mark Externals as DisplayLink** — marks every external monitor as DisplayLink.
## DisplayLink monitors
DisplayLink displays exist only through screen capture, so they can't be hidden invisibly the way directly-attached displays can. On those monitors the curtain also appears in your remote view. Native displays stay clear in your session while hidden at the desk. Mark your DisplayLink monitors in settings so they get covered correctly.
## Requirements ## Requirements
- macOS 13 (Ventura) or later — built and used on macOS 26 / Apple Silicon - macOS 13 (Ventura) or later. Built and used on macOS 26 / Apple Silicon.
- Screen Sharing enabled (System Settings → General → Sharing → Screen Sharing) - Screen Sharing enabled: System Settings → General → Sharing → Screen Sharing.
- Accessibility permission for Curtain (to block desk input).
## A note on DisplayLink monitors ## How it works / architecture / lessons
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. Full detail in the [wiki](../../wiki): architecture, the macOS APIs involved, and the lessons learned (including the things that did not work).
## How it works / lessons
See the [wiki](../../wiki) for the architecture, the macOS APIs involved, and the (many) lessons learned building this.
## License ## License

View file

@ -18,6 +18,15 @@ echo "==> Installing $APP …"
rm -rf "$APP" rm -rf "$APP"
mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources" mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources"
cp "$BIN" "$APP/Contents/MacOS/Curtain" cp "$BIN" "$APP/Contents/MacOS/Curtain"
echo "==> Generating app icon…"
ICONSET="$(mktemp -d)/Curtain.iconset"
"$BIN" --render-icon "$ICONSET" || true
if [ -d "$ICONSET" ]; then
iconutil -c icns "$ICONSET" -o "$APP/Contents/Resources/AppIcon.icns" 2>/dev/null || true
rm -rf "$ICONSET"
fi
cat > "$APP/Contents/Info.plist" <<PLIST cat > "$APP/Contents/Info.plist" <<PLIST
<?xml version="1.0" encoding="UTF-8"?> <?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"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
@ -25,6 +34,7 @@ cat > "$APP/Contents/Info.plist" <<PLIST
<key>CFBundleName</key><string>Curtain</string> <key>CFBundleName</key><string>Curtain</string>
<key>CFBundleIdentifier</key><string>io.acamarata.curtain</string> <key>CFBundleIdentifier</key><string>io.acamarata.curtain</string>
<key>CFBundleExecutable</key><string>Curtain</string> <key>CFBundleExecutable</key><string>Curtain</string>
<key>CFBundleIconFile</key><string>AppIcon</string>
<key>CFBundlePackageType</key><string>APPL</string> <key>CFBundlePackageType</key><string>APPL</string>
<key>CFBundleShortVersionString</key><string>1.0.0</string> <key>CFBundleShortVersionString</key><string>1.0.0</string>
<key>LSUIElement</key><true/> <key>LSUIElement</key><true/>

View file

@ -0,0 +1,46 @@
import Foundation
/// Purpose: A composable set of lifecycle actions. Each phase (start / idle / end)
/// maps to an ActionSet; the runner performs only the enabled actions.
/// Keeping actions independent and data-driven means new behaviors are a
/// field here, not a branch scattered across the app.
/// SPORT: MASTER-ACTIONS
struct ActionSet {
var activateCurtain = false
var disconnect = false
var lock = false
var screenOff = false
var deactivateCurtain = false
}
/// Performs an ActionSet against the live curtain + system. Ordering is deliberate:
/// disconnect the operator first, deactivate/cover the screen, then lock, then sleep
/// displays last (so the lock is in place before the panels go dark).
struct ActionRunner {
let curtain: CurtainController
let input: InputFilter
func run(_ set: ActionSet) {
if set.activateCurtain { activateCover() }
if set.disconnect { System.endScreenShareSession() }
if set.deactivateCurtain { deactivateCover() }
if set.lock { System.lockScreen() }
if set.screenOff {
// Give the lock a beat to take hold before the displays sleep.
DispatchQueue.main.asyncAfter(deadline: .now() + 1) { System.sleepDisplays() }
}
}
func activateCover() {
guard !curtain.isShown else { return }
curtain.show()
System.preventDisplaySleep()
_ = input.start() // no-op result: cover still hides even without Accessibility
}
func deactivateCover() {
input.stop()
curtain.hide()
System.allowDisplaySleep()
}
}

View file

@ -1,183 +1,43 @@
import Cocoa import Cocoa
/// Purpose: Menu-bar agent that wires the monitor, curtain, and input filter together. /// Purpose: App entry orchestration. Owns the coordinator, the optional menu bar,
/// Lifecycle (the entire product): /// and the settings window. Keeps logic out of the UI: it just wires
/// connect -> show curtain, block physical input, allow remote, keep displays awake /// callbacks between the pieces.
/// 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 /// SPORT: MASTER-APP
final class AppDelegate: NSObject, NSApplicationDelegate { final class AppDelegate: NSObject, NSApplicationDelegate {
private let monitor = SessionMonitor() private let coordinator = SessionCoordinator()
private let curtain = CurtainController() private lazy var menuBar = MenuBarController(coordinator: coordinator)
private let input = InputFilter() private lazy var prefs = PreferencesWindowController(coordinator: coordinator)
private var statusItem: NSStatusItem!
private var tickTimer: Timer?
func applicationDidFinishLaunching(_ n: Notification) { func applicationDidFinishLaunching(_ n: Notification) {
setupMenuBar() Settings.registerDefaults()
input.onPhysicalKey = { [weak self] kc, chars in self?.curtain.physicalKey(kc, chars) } coordinator.onStateChange = { [weak self] active in self?.menuBar.reflect(active: active) }
coordinator.start()
curtain.onUnlock = { [weak self] in self?.unlockFromDesk() } menuBar.onOpenSettings = { [weak self] in self?.prefs.show() }
menuBar.onQuit = { [weak self] in self?.quit() }
if Settings.showInMenuBar { menuBar.show() }
monitor.onConnect = { [weak self] in self?.sessionStarted() } prefs.onMenuBarToggle = { [weak self] on in on ? self?.menuBar.show() : self?.menuBar.hide() }
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 // First run: no password and no menu bar would be confusing open settings.
self?.curtain.tick() if !Settings.hasPassword && !Settings.showInMenuBar { prefs.show() }
} if !AXIsProcessTrusted() { requestAccessibility() }
if !AXIsProcessTrusted() { promptForAccessibility() } // Reconcile the login-item state with the saved preference.
LoginItem.set(Settings.launchAtLogin)
} }
// MARK: - Lifecycle handlers /// Re-opening the app from Finder shows the settings window.
func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool {
private func sessionStarted() { prefs.show(); return true
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) { private func quit() { coordinator.deactivateNow(); NSApp.terminate(nil) }
input.stop()
curtain.hide()
System.allowDisplaySleep()
updateMenuBarState(active: false)
if lock {
System.lockScreen()
DispatchQueue.main.asyncAfter(deadline: .now() + 1) { System.sleepDisplays() }
}
}
private func idleTimedOut() { private func requestAccessibility() {
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 let opts = [kAXTrustedCheckOptionPrompt.takeUnretainedValue(): true] as CFDictionary
_ = AXIsProcessTrustedWithOptions(opts) _ = 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

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

@ -133,7 +133,7 @@ final class PasswordBox: NSView {
bump() bump()
switch keycode { switch keycode {
case 36, 76: // Return / Enter case 36, 76: // Return / Enter
if Config.shared.verify(buffer) { onSuccess?() } if Settings.verify(buffer) { onSuccess?() }
else { buffer = ""; dots.stringValue = ""; err.stringValue = "Wrong password"; err.isHidden = false } else { buffer = ""; dots.stringValue = ""; err.stringValue = "Wrong password"; err.isHidden = false }
case 53: // Esc case 53: // Esc
isHidden = true isHidden = true

View file

@ -0,0 +1,120 @@
import Cocoa
/// Purpose: Draw the Curtain logo (classic theater curtains) at any size, for both
/// the menu-bar item (monochrome template) and the app icon (full color).
/// Self-contained CoreGraphics so there are no image assets to ship.
/// SPORT: MASTER-ICON
enum CurtainIcon {
/// A monochrome, template menu-bar image (adapts to light/dark menu bars).
static func menuBarImage() -> NSImage {
let size = NSSize(width: 18, height: 18)
let img = NSImage(size: size, flipped: false) { rect in
draw(in: rect, monochrome: true); return true
}
img.isTemplate = true
return img
}
/// A full-color app icon at the given pixel size.
static func appIcon(size: CGFloat) -> NSImage {
NSImage(size: NSSize(width: size, height: size), flipped: false) { rect in
draw(in: rect, monochrome: false); return true
}
}
/// Export a full .iconset folder of PNGs (for `iconutil` to turn into .icns).
static func exportIconset(to dir: String) {
let fm = FileManager.default
try? fm.createDirectory(atPath: dir, withIntermediateDirectories: true)
let specs: [(String, CGFloat)] = [
("icon_16x16", 16), ("icon_16x16@2x", 32), ("icon_32x32", 32), ("icon_32x32@2x", 64),
("icon_128x128", 128), ("icon_128x128@2x", 256), ("icon_256x256", 256),
("icon_256x256@2x", 512), ("icon_512x512", 512), ("icon_512x512@2x", 1024)
]
for (name, px) in specs {
guard let png = pngData(size: px) else { continue }
try? png.write(to: URL(fileURLWithPath: "\(dir)/\(name).png"))
}
}
/// Render the icon to PNG via an offscreen bitmap context (no running app / window server).
static func pngData(size: CGFloat) -> Data? {
let px = Int(size)
guard let rep = NSBitmapImageRep(bitmapDataPlanes: nil, pixelsWide: px, pixelsHigh: px,
bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, isPlanar: false,
colorSpaceName: .deviceRGB, bytesPerRow: 0, bitsPerPixel: 0),
let ctx = NSGraphicsContext(bitmapImageRep: rep) else { return nil }
NSGraphicsContext.saveGraphicsState()
NSGraphicsContext.current = ctx
draw(in: NSRect(x: 0, y: 0, width: size, height: size), monochrome: false)
ctx.flushGraphics()
NSGraphicsContext.restoreGraphicsState()
return rep.representation(using: .png, properties: [:])
}
// MARK: - Drawing
private static func draw(in rect: NSRect, monochrome: Bool) {
guard let ctx = NSGraphicsContext.current?.cgContext else { return }
let w = rect.width, h = rect.height
if !monochrome {
// Rounded dark "stage" background.
let bg = NSBezierPath(roundedRect: rect, xRadius: w * 0.22, yRadius: w * 0.22)
NSColor(red: 0.10, green: 0.07, blue: 0.09, alpha: 1).setFill(); bg.fill()
}
let panelTop = h * 0.86
let panelBottom = h * 0.10
let rod = h * 0.90
let panelColor = monochrome ? NSColor.black : NSColor(red: 0.74, green: 0.12, blue: 0.16, alpha: 1)
let foldColor = monochrome ? NSColor.black.withAlphaComponent(0.55)
: NSColor(red: 0.55, green: 0.07, blue: 0.10, alpha: 1)
// Two curtain panels, slightly parted in the middle.
drawPanel(ctx, x0: w * 0.10, x1: w * 0.46, top: panelTop, bottom: panelBottom,
folds: 3, color: panelColor, fold: foldColor, mirrored: false)
drawPanel(ctx, x0: w * 0.54, x1: w * 0.90, top: panelTop, bottom: panelBottom,
folds: 3, color: panelColor, fold: foldColor, mirrored: true)
// Curtain rod / valance across the top.
let rodColor = monochrome ? NSColor.black : NSColor(red: 0.85, green: 0.68, blue: 0.30, alpha: 1)
rodColor.setFill()
NSBezierPath(roundedRect: NSRect(x: w * 0.07, y: rod, width: w * 0.86, height: h * 0.055),
xRadius: h * 0.03, yRadius: h * 0.03).fill()
}
private static func drawPanel(_ ctx: CGContext, x0: CGFloat, x1: CGFloat, top: CGFloat, bottom: CGFloat,
folds: Int, color: NSColor, fold: NSColor, mirrored: Bool) {
let width = x1 - x0
// Panel body with a gently scalloped bottom hem.
let path = NSBezierPath()
path.move(to: NSPoint(x: x0, y: top))
path.line(to: NSPoint(x: x0, y: bottom + (top - bottom) * 0.06))
let segs = folds
for i in 0..<segs {
let sx = x0 + width * CGFloat(i) / CGFloat(segs)
let ex = x0 + width * CGFloat(i + 1) / CGFloat(segs)
let mid = (sx + ex) / 2
path.curve(to: NSPoint(x: ex, y: bottom + (top - bottom) * 0.06),
controlPoint1: NSPoint(x: mid, y: bottom - (top - bottom) * 0.04),
controlPoint2: NSPoint(x: mid, y: bottom - (top - bottom) * 0.04))
}
path.line(to: NSPoint(x: x1, y: top))
path.close()
color.setFill(); path.fill()
// Vertical fold shading lines.
fold.setStroke()
for i in 1..<folds {
let fx = x0 + width * CGFloat(i) / CGFloat(folds)
let line = NSBezierPath()
line.lineWidth = max(1, width * 0.03)
line.move(to: NSPoint(x: fx, y: top))
line.line(to: NSPoint(x: fx, y: bottom + (top - bottom) * 0.07))
line.stroke()
}
_ = mirrored
}
}

View file

@ -0,0 +1,18 @@
import Foundation
import ServiceManagement
/// Purpose: Toggle "open at login" via the modern SMAppService API (macOS 13+).
/// Only works for a real installed app bundle; a no-op when run loose.
/// SPORT: MASTER-LOGINITEM
enum LoginItem {
static var isEnabled: Bool { SMAppService.mainApp.status == .enabled }
static func set(_ on: Bool) {
do {
if on { if SMAppService.mainApp.status != .enabled { try SMAppService.mainApp.register() } }
else { if SMAppService.mainApp.status == .enabled { try SMAppService.mainApp.unregister() } }
} catch {
NSLog("Curtain: login item toggle failed: \(error.localizedDescription)")
}
}
}

View file

@ -0,0 +1,56 @@
import Cocoa
/// Purpose: Optional menu-bar presence (the curtains glyph) with quick actions.
/// Can be shown/hidden per the "Show in menu bar" setting.
/// SPORT: MASTER-MENUBAR
final class MenuBarController: NSObject {
private var statusItem: NSStatusItem?
private weak var coordinator: SessionCoordinator?
var onOpenSettings: (() -> Void)?
var onQuit: (() -> Void)?
init(coordinator: SessionCoordinator) { self.coordinator = coordinator; super.init() }
func show() {
guard statusItem == nil else { return }
let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
item.button?.image = CurtainIcon.menuBarImage()
let menu = NSMenu()
add(menu, "Open Curtain Settings…", #selector(openSettings))
menu.addItem(.separator())
add(menu, "Activate Now", #selector(activate))
add(menu, "Deactivate", #selector(deactivate))
add(menu, "Test Curtain (10s)", #selector(test))
menu.addItem(.separator())
add(menu, "Quit Curtain", #selector(quit), key: "q")
item.menu = menu
statusItem = item
reflect(active: coordinator?.isActive ?? false)
}
func hide() {
if let i = statusItem { NSStatusBar.system.removeStatusItem(i) }
statusItem = nil
}
/// Update the icon to reflect active/idle. Active = highlighted (non-template).
func reflect(active: Bool) {
guard let button = statusItem?.button else { return }
let img = CurtainIcon.menuBarImage()
img.isTemplate = !active // active = tinted/filled, idle = template (adapts)
button.image = img
button.contentTintColor = active ? NSColor.systemRed : nil
}
// MARK: - Actions
private func add(_ menu: NSMenu, _ title: String, _ sel: Selector, key: String = "") {
let item = NSMenuItem(title: title, action: sel, keyEquivalent: key)
item.target = self; menu.addItem(item)
}
@objc private func openSettings() { onOpenSettings?() }
@objc private func activate() { coordinator?.activateNow() }
@objc private func deactivate() { coordinator?.deactivateNow() }
@objc private func test() { coordinator?.testCurtain() }
@objc private func quit() { onQuit?() }
}

View file

@ -0,0 +1,171 @@
import SwiftUI
import AppKit
/// Purpose: The single settings window (Caffeine-style). Binds to the same
/// UserDefaults keys the coordinator reads, so changes take effect live.
/// SPORT: MASTER-PREFS
final class PreferencesWindowController {
private var window: NSWindow?
private weak var coordinator: SessionCoordinator?
var onMenuBarToggle: ((Bool) -> Void)?
init(coordinator: SessionCoordinator) { self.coordinator = coordinator }
func show() {
if let w = window { w.makeKeyAndOrderFront(nil); NSApp.activate(ignoringOtherApps: true); return }
let view = PreferencesView(
activateNow: { [weak self] in self?.coordinator?.activateNow() },
testNow: { [weak self] in self?.coordinator?.testCurtain() },
markDisplayLink: { Self.markExternalsAsDisplayLink() },
identifyDisplays: { Self.identifyDisplays() },
onMenuBarToggle: { [weak self] on in self?.onMenuBarToggle?(on) }
)
let w = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 460, height: 560),
styleMask: [.titled, .closable, .miniaturizable],
backing: .buffered, defer: false)
w.title = "Curtain"
w.contentViewController = NSHostingController(rootView: view)
w.center(); w.isReleasedWhenClosed = false
window = w
w.makeKeyAndOrderFront(nil); NSApp.activate(ignoringOtherApps: true)
}
// MARK: - Display helpers (shared with menu)
static func markExternalsAsDisplayLink() {
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)) }
}
Settings.displayLinkSerials = serials
let a = NSAlert(); a.messageText = "Curtain"
a.informativeText = "Marked \(serials.count) external display(s) as DisplayLink."
NSApp.activate(ignoringOtherApps: true); a.runModal()
}
static func identifyDisplays() {
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 - 130, width: screen.frame.width, height: 260)
lbl.alignment = .center; lbl.font = .systemFont(ofSize: 110, 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) } }
}
}
/// The SwiftUI settings form.
private struct PreferencesView: View {
// App
@AppStorage(Settings.Key.launchAtLogin) private var launchAtLogin = true
@AppStorage(Settings.Key.showInMenuBar) private var showInMenuBar = true
// Start
@AppStorage(Settings.Key.onStartActivate) private var onStartActivate = true
// Idle
@AppStorage(Settings.Key.idleEnabled) private var idleEnabled = true
@AppStorage(Settings.Key.idleMinutes) private var idleMinutes = 30
@AppStorage(Settings.Key.onIdleDisconnect) private var idleDisconnect = true
@AppStorage(Settings.Key.onIdleLock) private var idleLock = true
@AppStorage(Settings.Key.onIdleScreenOff) private var idleScreenOff = true
@AppStorage(Settings.Key.onIdleDeactivate) private var idleDeactivate = true
// End
@AppStorage(Settings.Key.onEndLock) private var endLock = true
@AppStorage(Settings.Key.onEndScreenOff) private var endScreenOff = true
@AppStorage(Settings.Key.onEndDeactivate) private var endDeactivate = true
// Password
@AppStorage(Settings.Key.onPasswordDisconnect) private var passwordDisconnect = true
@State private var newPassword = ""
let activateNow: () -> Void
let testNow: () -> Void
let markDisplayLink: () -> Void
let identifyDisplays: () -> Void
let onMenuBarToggle: (Bool) -> Void
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 18) {
header
group("Application") {
Toggle("Open at login", isOn: $launchAtLogin)
.onChange(of: launchAtLogin) { LoginItem.set($0) }
Toggle("Show in menu bar", isOn: $showInMenuBar)
.onChange(of: showInMenuBar) { onMenuBarToggle($0) }
HStack {
Button("Activate Now", action: activateNow)
Button("Test (10s)", action: testNow)
}
}
group("On session start") {
Toggle("Activate curtain when a remote session begins", isOn: $onStartActivate)
}
group("On session idle") {
Toggle("Act after the session is idle", isOn: $idleEnabled)
if idleEnabled {
Stepper("Idle timeout: \(idleMinutes) min", value: $idleMinutes, in: 1...240)
Toggle("Disconnect the remote session", isOn: $idleDisconnect)
Toggle("Lock the Mac", isOn: $idleLock)
Toggle("Turn off the displays", isOn: $idleScreenOff)
Toggle("Deactivate the curtain", isOn: $idleDeactivate)
}
}
group("On session end (disconnect)") {
Toggle("Lock the Mac", isOn: $endLock)
Toggle("Turn off the displays", isOn: $endScreenOff)
Toggle("Deactivate the curtain", isOn: $endDeactivate)
}
group("Security") {
Toggle("Disconnect remote when password is entered at the desk", isOn: $passwordDisconnect)
HStack {
SecureField("New unlock password", text: $newPassword)
Button("Set") { if !newPassword.isEmpty { Settings.setPassword(newPassword); newPassword = "" } }
}
Text(Settings.hasPassword ? "A password is set." : "No password set (default: “curtain”).")
.font(.caption).foregroundStyle(.secondary)
}
group("Displays") {
Text("DisplayLink monitors can't be hidden invisibly; mark them so the curtain covers them too.")
.font(.caption).foregroundStyle(.secondary)
HStack {
Button("Identify Displays", action: identifyDisplays)
Button("Mark Externals as DisplayLink", action: markDisplayLink)
}
}
}
.padding(22)
}
.frame(width: 460, height: 560)
}
private var header: some View {
HStack(spacing: 12) {
Image(nsImage: CurtainIcon.appIcon(size: 48))
.resizable().frame(width: 48, height: 48)
VStack(alignment: .leading) {
Text("Curtain").font(.title2).bold()
Text("Privacy for macOS Screen Sharing").font(.caption).foregroundStyle(.secondary)
}
}
}
@ViewBuilder private func group<Content: View>(_ title: String, @ViewBuilder _ content: () -> Content) -> some View {
VStack(alignment: .leading, spacing: 8) {
Text(title.uppercased()).font(.caption2).bold().foregroundStyle(.secondary)
content()
}
}
}

View file

@ -0,0 +1,76 @@
import Cocoa
/// Purpose: The brain. Wires the session monitor, curtain, input filter, and the
/// configurable lifecycle actions together. Owns the connect / idle / end
/// flow described in the README. Holds no UI.
/// SPORT: MASTER-COORDINATOR
final class SessionCoordinator {
let curtain = CurtainController()
let input = InputFilter()
private let monitor = SessionMonitor()
private lazy var runner = ActionRunner(curtain: curtain, input: input)
private var tickTimer: Timer?
/// Called when the curtain's active state changes (for the menu-bar icon).
var onStateChange: ((Bool) -> Void)?
func start() {
input.onPhysicalKey = { [weak self] kc, chars in self?.curtain.physicalKey(kc, chars) }
curtain.onUnlock = { [weak self] in self?.handlePasswordUnlock() }
monitor.onConnect = { [weak self] in self?.sessionStarted() }
monitor.onIdleTimeout = { [weak self] in self?.sessionIdled() }
monitor.onDisconnect = { [weak self] in self?.sessionEnded() }
monitor.start()
tickTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
self?.curtain.tick()
}
}
// MARK: - Lifecycle
private func sessionStarted() {
guard Settings.onStartActivate else { return }
runner.activateCover()
onStateChange?(true)
}
private func sessionIdled() {
runner.run(Settings.onIdle)
onStateChange?(curtain.isShown)
}
private func sessionEnded() {
runner.run(Settings.onEnd)
onStateChange?(curtain.isShown)
}
/// Host typed the correct password at the desk.
private func handlePasswordUnlock() {
runner.deactivateCover()
onStateChange?(false)
if Settings.onPasswordDisconnect {
let alert = NSAlert()
alert.messageText = "Unlocked at this Mac"
alert.informativeText = "Disconnect the active remote session?"
alert.addButton(withTitle: "Disconnect Remote")
alert.addButton(withTitle: "Keep Connected")
NSApp.activate(ignoringOtherApps: true)
if alert.runModal() == .alertFirstButtonReturn { System.endScreenShareSession() }
}
}
// MARK: - Manual controls (menu bar / settings)
func activateNow() { runner.activateCover(); onStateChange?(true) }
func deactivateNow() { runner.deactivateCover(); onStateChange?(false) }
var isActive: Bool { curtain.isShown }
func testCurtain(seconds: TimeInterval = 10) {
runner.activateCover(); onStateChange?(true)
DispatchQueue.main.asyncAfter(deadline: .now() + seconds) { [weak self] in
self?.runner.deactivateCover(); self?.onStateChange?(false)
}
}
}

View file

@ -32,11 +32,13 @@ final class SessionMonitor {
if isVNCEstablished() { if isVNCEstablished() {
missCount = 0 missCount = 0
if !connected { connected = true; idleFired = false; onConnect?() } if !connected { connected = true; idleFired = false; onConnect?() }
if !idleFired, idleSeconds() >= Config.shared.idleMinutes * 60 { if Settings.idleEnabled {
idleFired = true if !idleFired, idleSeconds() >= Settings.idleMinutes * 60 {
onIdleTimeout?() idleFired = true
} else if idleSeconds() < Config.shared.idleMinutes * 60 { onIdleTimeout?()
idleFired = false } else if idleSeconds() < Settings.idleMinutes * 60 {
idleFired = false
}
} }
} else if connected { } else if connected {
missCount += 1 missCount += 1

View file

@ -0,0 +1,105 @@
import Foundation
import CryptoKit
/// Purpose: Single source of truth for every Curtain preference, backed by
/// UserDefaults so the SwiftUI settings view (@AppStorage) and the
/// headless coordinator read/write the exact same keys.
/// Inputs: none (reads/writes the standard user defaults under the keys below).
/// Outputs: typed get/set accessors + password helpers.
/// Constraints: password is stored as a salted SHA256 hash, never plaintext.
/// SPORT: MASTER-SETTINGS
enum Settings {
/// Defaults key strings. The SwiftUI view binds to these same strings via @AppStorage.
enum Key {
static let launchAtLogin = "launchAtLogin"
static let showInMenuBar = "showInMenuBar"
// On session start
static let onStartActivate = "onStart.activateCurtain"
// On idle
static let idleEnabled = "idle.enabled"
static let idleMinutes = "idle.minutes"
static let onIdleDisconnect = "onIdle.disconnect"
static let onIdleLock = "onIdle.lock"
static let onIdleScreenOff = "onIdle.screenOff"
static let onIdleDeactivate = "onIdle.deactivate"
// On session end (disconnect)
static let onEndLock = "onEnd.lock"
static let onEndScreenOff = "onEnd.screenOff"
static let onEndDeactivate = "onEnd.deactivate"
// On password entered at the desk
static let onPasswordDisconnect = "onPassword.disconnect"
// Security + displays
static let passwordHash = "password.hash"
static let passwordSalt = "password.salt"
static let displayLinkSerials = "displayLinkSerials"
}
/// Register sensible defaults once at launch.
static func registerDefaults() {
UserDefaults.standard.register(defaults: [
Key.launchAtLogin: true,
Key.showInMenuBar: true,
Key.onStartActivate: true,
Key.idleEnabled: true,
Key.idleMinutes: 30,
Key.onIdleDisconnect: true,
Key.onIdleLock: true,
Key.onIdleScreenOff: true,
Key.onIdleDeactivate: true,
Key.onEndLock: true,
Key.onEndScreenOff: true,
Key.onEndDeactivate: true,
Key.onPasswordDisconnect: true,
])
}
// MARK: - Typed accessors (headless side)
private static let d = UserDefaults.standard
static var launchAtLogin: Bool { get { d.bool(forKey: Key.launchAtLogin) } set { d.set(newValue, forKey: Key.launchAtLogin) } }
static var showInMenuBar: Bool { get { d.bool(forKey: Key.showInMenuBar) } set { d.set(newValue, forKey: Key.showInMenuBar) } }
static var onStartActivate: Bool { d.bool(forKey: Key.onStartActivate) }
static var idleEnabled: Bool { d.bool(forKey: Key.idleEnabled) }
static var idleMinutes: Int { max(1, d.integer(forKey: Key.idleMinutes)) }
static var onPasswordDisconnect: Bool { d.bool(forKey: Key.onPasswordDisconnect) }
static var onIdle: ActionSet {
ActionSet(disconnect: d.bool(forKey: Key.onIdleDisconnect),
lock: d.bool(forKey: Key.onIdleLock),
screenOff: d.bool(forKey: Key.onIdleScreenOff),
deactivateCurtain: d.bool(forKey: Key.onIdleDeactivate))
}
static var onEnd: ActionSet {
ActionSet(disconnect: false, // already disconnected
lock: d.bool(forKey: Key.onEndLock),
screenOff: d.bool(forKey: Key.onEndScreenOff),
deactivateCurtain: d.bool(forKey: Key.onEndDeactivate))
}
static var displayLinkSerials: [UInt32] {
get { (d.array(forKey: Key.displayLinkSerials) as? [Int])?.map { UInt32(truncatingIfNeeded: $0) } ?? [] }
set { d.set(newValue.map { Int($0) }, forKey: Key.displayLinkSerials) }
}
// MARK: - Password
static func setPassword(_ plain: String) {
var salt = d.string(forKey: Key.passwordSalt) ?? ""
if salt.isEmpty { salt = randomSalt(); d.set(salt, forKey: Key.passwordSalt) }
d.set(hash(plain, salt: salt), forKey: Key.passwordHash)
}
/// Verify a candidate. If no password is set, the built-in "curtain" is accepted
/// so the Mac is never unrecoverable.
static func verify(_ candidate: String) -> Bool {
let stored = d.string(forKey: Key.passwordHash) ?? ""
if stored.isEmpty { return candidate == "curtain" }
return hash(candidate, salt: d.string(forKey: Key.passwordSalt) ?? "") == stored
}
static var hasPassword: Bool { !(d.string(forKey: Key.passwordHash) ?? "").isEmpty }
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 {
SHA256.hash(data: Data((salt + s).utf8)).map { String(format: "%02x", $0) }.joined()
}
}

View file

@ -81,6 +81,6 @@ enum System {
/// too it must use .readOnly (visible in the remote view). We identify them /// too it must use .readOnly (visible in the remote view). We identify them
/// by serial because EDID passthrough makes vendor IDs identical. /// by serial because EDID passthrough makes vendor IDs identical.
static func isDisplayLink(_ screen: NSScreen) -> Bool { static func isDisplayLink(_ screen: NSScreen) -> Bool {
Config.shared.displayLinkSerials.contains(serial(of: screen)) Settings.displayLinkSerials.contains(serial(of: screen))
} }
} }

View file

@ -1,9 +1,20 @@
import Cocoa import Cocoa
// Curtain a privacy curtain for macOS Screen Sharing. // Curtain a privacy curtain for macOS Screen Sharing.
// Entry point: a background menu-bar agent (no Dock icon). // Normally launches as a background menu-bar agent (no Dock icon).
//
// Hidden build helper: `Curtain --render-icon <dir>` writes an .iconset of PNGs so
// install.sh can build AppIcon.icns without shipping any image assets.
if CommandLine.arguments.contains("--render-icon"),
let dirIndex = CommandLine.arguments.firstIndex(of: "--render-icon"),
CommandLine.arguments.indices.contains(dirIndex + 1) {
CurtainIcon.exportIconset(to: CommandLine.arguments[dirIndex + 1]) // offscreen bitmap render
exit(0)
}
let app = NSApplication.shared let app = NSApplication.shared
let delegate = AppDelegate() let delegate = AppDelegate()
app.delegate = delegate app.delegate = delegate
app.setActivationPolicy(.accessory) app.setActivationPolicy(.accessory) // background agent; settings window still shows
app.run() app.run()