curtain/Sources/Curtain/Config.swift
acamarata 709669e0cb 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
2026-06-01 14:10:50 -04:00

68 lines
2.7 KiB
Swift

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()
}
}