mirror of
https://github.com/acamarata/curtain.git
synced 2026-06-30 18:54:25 +00:00
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:
commit
709669e0cb
15 changed files with 857 additions and 0 deletions
12
.github/workflows/build.yml
vendored
Normal file
12
.github/workflows/build.yml
vendored
Normal 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
24
.gitignore
vendored
Normal 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
21
LICENSE
Normal 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
13
Package.swift
Normal 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
45
README.md
Normal 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
7
Scripts/build.sh
Executable 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
79
Scripts/install.sh
Executable 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
19
Scripts/uninstall.sh
Executable 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."
|
||||
183
Sources/Curtain/AppDelegate.swift
Normal file
183
Sources/Curtain/AppDelegate.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
68
Sources/Curtain/Config.swift
Normal file
68
Sources/Curtain/Config.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
150
Sources/Curtain/Curtain.swift
Normal file
150
Sources/Curtain/Curtain.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
65
Sources/Curtain/InputFilter.swift
Normal file
65
Sources/Curtain/InputFilter.swift
Normal 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
|
||||
}
|
||||
76
Sources/Curtain/SessionMonitor.swift
Normal file
76
Sources/Curtain/SessionMonitor.swift
Normal 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) ?? ""
|
||||
}
|
||||
}
|
||||
86
Sources/Curtain/System.swift
Normal file
86
Sources/Curtain/System.swift
Normal 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))
|
||||
}
|
||||
}
|
||||
9
Sources/Curtain/main.swift
Normal file
9
Sources/Curtain/main.swift
Normal 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()
|
||||
Loading…
Reference in a new issue