mirror of
https://github.com/acamarata/curtain.git
synced 2026-06-30 18:54:25 +00:00
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:
parent
fa2efa5d05
commit
30d5a77ffa
15 changed files with 701 additions and 258 deletions
72
README.md
72
README.md
|
|
@ -1,19 +1,23 @@
|
|||
# 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
|
||||
|
||||
| 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. |
|
||||
| **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.
|
||||
| **Remote session starts** | Curtain covers every physical display; desk keyboard/mouse are blocked from apps; your remote input works; displays kept awake. |
|
||||
| **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 (default 30 min)** | Disconnect remote · lock Mac · sleep displays · deactivate curtain. |
|
||||
| **Session ends (disconnect)** | Lock Mac · sleep displays · deactivate curtain. |
|
||||
|
||||
## Install
|
||||
|
||||
|
|
@ -23,22 +27,54 @@ 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**.
|
||||
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`
|
||||
|
||||
## 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** (1–240 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
|
||||
|
||||
- macOS 13 (Ventura) or later — built and used on macOS 26 / Apple Silicon
|
||||
- Screen Sharing enabled (System Settings → General → Sharing → Screen Sharing)
|
||||
- macOS 13 (Ventura) or later. Built and used on macOS 26 / Apple Silicon.
|
||||
- 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.
|
||||
|
||||
## How it works / lessons
|
||||
|
||||
See the [wiki](../../wiki) for the architecture, the macOS APIs involved, and the (many) lessons learned building this.
|
||||
Full detail in the [wiki](../../wiki): architecture, the macOS APIs involved, and the lessons learned (including the things that did not work).
|
||||
|
||||
## License
|
||||
|
||||
|
|
|
|||
|
|
@ -18,6 +18,15 @@ echo "==> Installing $APP …"
|
|||
rm -rf "$APP"
|
||||
mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources"
|
||||
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
|
||||
<?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">
|
||||
|
|
@ -25,6 +34,7 @@ cat > "$APP/Contents/Info.plist" <<PLIST
|
|||
<key>CFBundleName</key><string>Curtain</string>
|
||||
<key>CFBundleIdentifier</key><string>io.acamarata.curtain</string>
|
||||
<key>CFBundleExecutable</key><string>Curtain</string>
|
||||
<key>CFBundleIconFile</key><string>AppIcon</string>
|
||||
<key>CFBundlePackageType</key><string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key><string>1.0.0</string>
|
||||
<key>LSUIElement</key><true/>
|
||||
|
|
|
|||
46
Sources/Curtain/Actions.swift
Normal file
46
Sources/Curtain/Actions.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
|
|
@ -1,183 +1,43 @@
|
|||
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
|
||||
/// Purpose: App entry orchestration. Owns the coordinator, the optional menu bar,
|
||||
/// and the settings window. Keeps logic out of the UI: it just wires
|
||||
/// callbacks between the pieces.
|
||||
/// 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?
|
||||
private let coordinator = SessionCoordinator()
|
||||
private lazy var menuBar = MenuBarController(coordinator: coordinator)
|
||||
private lazy var prefs = PreferencesWindowController(coordinator: coordinator)
|
||||
|
||||
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() }
|
||||
monitor.onDisconnect = { [weak self] in self?.sessionEnded(lock: true) }
|
||||
monitor.onIdleTimeout = { [weak self] in self?.idleTimedOut() }
|
||||
monitor.start()
|
||||
prefs.onMenuBarToggle = { [weak self] on in on ? self?.menuBar.show() : self?.menuBar.hide() }
|
||||
|
||||
tickTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
|
||||
self?.curtain.tick()
|
||||
}
|
||||
// First run: no password and no menu bar would be confusing — open settings.
|
||||
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
|
||||
|
||||
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)
|
||||
/// Re-opening the app from Finder shows the settings window.
|
||||
func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool {
|
||||
prefs.show(); return 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 quit() { coordinator.deactivateNow(); NSApp.terminate(nil) }
|
||||
|
||||
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() {
|
||||
private func requestAccessibility() {
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -133,7 +133,7 @@ final class PasswordBox: NSView {
|
|||
bump()
|
||||
switch keycode {
|
||||
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 }
|
||||
case 53: // Esc
|
||||
isHidden = true
|
||||
|
|
|
|||
120
Sources/Curtain/CurtainIcon.swift
Normal file
120
Sources/Curtain/CurtainIcon.swift
Normal 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
|
||||
}
|
||||
}
|
||||
18
Sources/Curtain/LoginItem.swift
Normal file
18
Sources/Curtain/LoginItem.swift
Normal 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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
56
Sources/Curtain/MenuBarController.swift
Normal file
56
Sources/Curtain/MenuBarController.swift
Normal 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?() }
|
||||
}
|
||||
171
Sources/Curtain/PreferencesWindow.swift
Normal file
171
Sources/Curtain/PreferencesWindow.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
76
Sources/Curtain/SessionCoordinator.swift
Normal file
76
Sources/Curtain/SessionCoordinator.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -32,11 +32,13 @@ final class SessionMonitor {
|
|||
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
|
||||
if Settings.idleEnabled {
|
||||
if !idleFired, idleSeconds() >= Settings.idleMinutes * 60 {
|
||||
idleFired = true
|
||||
onIdleTimeout?()
|
||||
} else if idleSeconds() < Settings.idleMinutes * 60 {
|
||||
idleFired = false
|
||||
}
|
||||
}
|
||||
} else if connected {
|
||||
missCount += 1
|
||||
|
|
|
|||
105
Sources/Curtain/Settings.swift
Normal file
105
Sources/Curtain/Settings.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
|
|
@ -81,6 +81,6 @@ enum System {
|
|||
/// 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))
|
||||
Settings.displayLinkSerials.contains(serial(of: screen))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,20 @@
|
|||
import Cocoa
|
||||
|
||||
// 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 delegate = AppDelegate()
|
||||
app.delegate = delegate
|
||||
app.setActivationPolicy(.accessory)
|
||||
app.setActivationPolicy(.accessory) // background agent; settings window still shows
|
||||
app.run()
|
||||
|
|
|
|||
Loading…
Reference in a new issue