mirror of
https://github.com/acamarata/curtain.git
synced 2026-06-30 18:54:25 +00:00
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
68 lines
2.7 KiB
Swift
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()
|
|
}
|
|
}
|