curtain/Sources/Curtain/Settings.swift
acamarata 30d5a77ffa 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
2026-06-01 15:51:20 -04:00

105 lines
4.9 KiB
Swift

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