mirror of
https://github.com/acamarata/curtain.git
synced 2026-06-30 18:54:25 +00:00
Detection: netstat lives at /usr/sbin/netstat, not /usr/bin — the hardcoded wrong path silently killed the ESTABLISHED-TCP activator (root cause of the failed live test). Fixed and live-verified. Added peered-UDP activator (5900-5902) for High-Performance sessions, per-signal transition logging, unconditional error logging for dead probe helpers, and probe v2 with full CGSession dictionary diffing. 7 new parser tests (32 total). Fixes from a full audit + adversarial review: idle source setting honored (default now Remote session activity), cover scope reduced to a coherent two-mode model with legacy migration (per-display toggle was inverted in onlyMarked and dead in all), curtain test no longer schedules a teardown over a live session, specific-display password box placement gets a real picker, refuse-to-arm enforced, activation notification posts a real banner, menu password gate bypassed when the event tap is dead, shared single-decoder aerial player with stale-task guard and async playability check, password buffer zeroed on successful unlock and Esc, XPC interruption/invalidation handlers, modern Accessibility settings URL, launchPath modernized, codesign failures now abort release.sh, monotonic CFBundleVersion, install.sh temp cleanup, dead armDisarmHotkey setting removed. Refactor: Curtain.swift and PreferencesWindow.swift split into focused files (largest now 479 lines). Wiki, README, and contributing docs updated to match. Build clean at 0 warnings, 32/32 tests pass.
296 lines
13 KiB
Swift
296 lines
13 KiB
Swift
import SwiftUI
|
|
import AppKit
|
|
import CurtainShared
|
|
|
|
/// Purpose: The single settings window. Every Curtain preference is a control here,
|
|
/// grouped by concern and bound to the same UserDefaults keys the headless
|
|
/// coordinator reads, so a change in the UI takes effect live.
|
|
/// Inputs: a SessionCoordinator (for the manual actions) plus a handful of injected
|
|
/// closures the AppDelegate wires (onboarding, menu-bar toggle).
|
|
/// Outputs: writes to UserDefaults via @AppStorage + Settings helpers; invokes the
|
|
/// coordinator and injected closures for side effects.
|
|
/// Constraints: SwiftUI + AppKit run on the main actor; the controller is @MainActor.
|
|
/// The coordinator is held weakly inside the escaping closures so the window
|
|
/// never extends the coordinator's lifetime. Bindings use Settings.Key.*
|
|
/// constants so keys match the headless side byte-for-byte.
|
|
/// SPORT: MASTER-PREFS
|
|
@MainActor
|
|
final class PreferencesWindowController {
|
|
private var window: NSWindow?
|
|
private weak var coordinator: SessionCoordinator?
|
|
var onMenuBarToggle: ((Bool) -> Void)?
|
|
/// Wired by the AppDelegate to reopen the first-run setup flow.
|
|
var openOnboarding: (() -> 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 coordinator] in coordinator?.activateNow() },
|
|
testNow: { [weak coordinator] in coordinator?.testCurtain(seconds: 10) },
|
|
markDisplayLink: { Self.markExternalsAsDisplayLink() },
|
|
identifyDisplays: { Self.identifyDisplays() },
|
|
enableDisconnectHelper: { [weak coordinator] on in coordinator?.enableDisconnectHelper(on) },
|
|
openOnboarding: { [weak self] in self?.openOnboarding?() },
|
|
onMenuBarToggle: { [weak self] on in self?.onMenuBarToggle?(on) }
|
|
)
|
|
let w = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 620, height: 560),
|
|
styleMask: [.titled, .closable, .miniaturizable, .resizable],
|
|
backing: .buffered, defer: false)
|
|
w.title = "Curtain"
|
|
w.contentViewController = NSHostingController(rootView: view)
|
|
w.setContentSize(NSSize(width: 620, height: 560))
|
|
w.contentMinSize = NSSize(width: 600, height: 520)
|
|
w.center(); w.isReleasedWhenClosed = false
|
|
window = w
|
|
w.makeKeyAndOrderFront(nil); NSApp.activate(ignoringOtherApps: true)
|
|
}
|
|
|
|
// MARK: - Display helpers (shared with the menu)
|
|
|
|
/// Mark every external (non-builtin) display as a DisplayLink, keyed by the
|
|
/// stable per-display UUID so the mapping survives reboots and port changes.
|
|
static func markExternalsAsDisplayLink() {
|
|
var uuids: [String] = []
|
|
for s in NSScreen.screens {
|
|
guard let id = s.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? CGDirectDisplayID else { continue }
|
|
if CGDisplayIsBuiltin(id) == 0, let u = System.uuid(of: s) { uuids.append(u) }
|
|
}
|
|
Settings.displayLinkUUIDs = uuids
|
|
let a = NSAlert(); a.messageText = "Curtain"
|
|
a.informativeText = "Marked \(uuids.count) external display(s) as DisplayLink."
|
|
NSApp.activate(ignoringOtherApps: true); a.runModal()
|
|
}
|
|
|
|
/// Flash a big index + short UUID on each screen so the user can tell them apart.
|
|
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 shortID = System.uuid(of: screen).map { String($0.prefix(8)) } ?? "unknown"
|
|
let lbl = NSTextField(labelWithString: "\(i)\n\(shortID)")
|
|
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) } }
|
|
}
|
|
}
|
|
|
|
// MARK: - Settings import/export shape
|
|
|
|
/// A flat, versioned snapshot of every persisted preference, used for Export/Import.
|
|
struct SettingsSnapshot: Codable {
|
|
var version = 1
|
|
var values: [String: AnyCodable] = [:]
|
|
}
|
|
|
|
/// Minimal AnyCodable so a heterogeneous defaults dictionary survives JSON round-trips.
|
|
struct AnyCodable: Codable {
|
|
let value: Any
|
|
init(_ value: Any) { self.value = value }
|
|
init(from decoder: Decoder) throws {
|
|
let c = try decoder.singleValueContainer()
|
|
if let b = try? c.decode(Bool.self) { value = b }
|
|
else if let i = try? c.decode(Int.self) { value = i }
|
|
else if let d = try? c.decode(Double.self) { value = d }
|
|
else if let s = try? c.decode(String.self) { value = s }
|
|
else if let a = try? c.decode([AnyCodable].self) { value = a.map(\.value) }
|
|
else { value = "" }
|
|
}
|
|
func encode(to encoder: Encoder) throws {
|
|
var c = encoder.singleValueContainer()
|
|
switch value {
|
|
case let b as Bool: try c.encode(b)
|
|
case let i as Int: try c.encode(i)
|
|
case let d as Double: try c.encode(d)
|
|
case let s as String: try c.encode(s)
|
|
case let a as [String]: try c.encode(a.map(AnyCodable.init))
|
|
default: try c.encode("")
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - The SwiftUI settings form
|
|
|
|
private struct PreferencesView: View {
|
|
// State shared across tabs that need to refresh or toggle together.
|
|
@State private var hasPassword = Settings.hasPassword
|
|
/// Bumped to force the dynamic display list to re-read after a Cover/DisplayLink toggle.
|
|
@State private var displayRefresh = 0
|
|
|
|
let activateNow: () -> Void
|
|
let testNow: () -> Void
|
|
let markDisplayLink: () -> Void
|
|
let identifyDisplays: () -> Void
|
|
let enableDisconnectHelper: (Bool) -> Void
|
|
let openOnboarding: () -> Void
|
|
let onMenuBarToggle: (Bool) -> Void
|
|
|
|
/// The settings sections, shown as a System Settings style sidebar. Each case
|
|
/// carries its sidebar title and SF Symbol; the order here is the sidebar order.
|
|
private enum PrefSection: String, Identifiable, Hashable, CaseIterable {
|
|
case general, appearance, idleEnd, security, disconnect, displays, advanced
|
|
|
|
var id: String { rawValue }
|
|
|
|
var title: String {
|
|
switch self {
|
|
case .general: return "General"
|
|
case .appearance: return "Appearance"
|
|
case .idleEnd: return "Idle & End"
|
|
case .security: return "Security"
|
|
case .disconnect: return "Disconnect"
|
|
case .displays: return "Displays"
|
|
case .advanced: return "Advanced"
|
|
}
|
|
}
|
|
|
|
var symbol: String {
|
|
switch self {
|
|
case .general: return "gearshape"
|
|
case .appearance: return "paintbrush"
|
|
case .idleEnd: return "moon.zzz"
|
|
case .security: return "lock.shield"
|
|
case .disconnect: return "network.slash"
|
|
case .displays: return "display"
|
|
case .advanced: return "slider.horizontal.3"
|
|
}
|
|
}
|
|
}
|
|
|
|
@State private var selection: PrefSection = .general
|
|
|
|
var body: some View {
|
|
HStack(spacing: 0) {
|
|
sidebar
|
|
.frame(width: 190)
|
|
Divider()
|
|
detail
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
}
|
|
.frame(minWidth: 620, idealWidth: 620, maxWidth: .infinity,
|
|
minHeight: 560, idealHeight: 560, maxHeight: .infinity)
|
|
}
|
|
|
|
/// An always-visible source-list sidebar. A plain List inside the HStack keeps the
|
|
/// translucent System Settings look without the split view that kept collapsing.
|
|
/// Adapts the non-optional selection to the optional binding List wants for single
|
|
/// selection. A nil set (clicking empty space) is ignored so a section is always shown.
|
|
private var sidebarSelection: Binding<PrefSection?> {
|
|
Binding<PrefSection?>(
|
|
get: { selection },
|
|
set: { if let new = $0 { selection = new } }
|
|
)
|
|
}
|
|
|
|
private var sidebar: some View {
|
|
List(PrefSection.allCases, selection: sidebarSelection) { section in
|
|
Label(section.title, systemImage: section.symbol)
|
|
.tag(section)
|
|
}
|
|
.listStyle(.sidebar)
|
|
}
|
|
|
|
/// The right-hand content for the selected sidebar section.
|
|
@ViewBuilder
|
|
private var detail: some View {
|
|
switch selection {
|
|
case .general:
|
|
PrefGeneralTab(
|
|
activateNow: activateNow,
|
|
testNow: testNow,
|
|
onMenuBarToggle: onMenuBarToggle
|
|
)
|
|
case .appearance:
|
|
PrefAppearanceTab()
|
|
case .idleEnd:
|
|
PrefIdleEndTab()
|
|
case .security:
|
|
PrefSecurityTab(hasPassword: $hasPassword)
|
|
case .disconnect:
|
|
PrefDisconnectTab(enableDisconnectHelper: enableDisconnectHelper)
|
|
case .displays:
|
|
PrefDisplaysTab(
|
|
displayRefresh: displayRefresh,
|
|
identifyDisplays: identifyDisplays,
|
|
markDisplayLink: markDisplayLink,
|
|
onMarkDisplayLink: { displayRefresh += 1 }
|
|
)
|
|
case .advanced:
|
|
PrefAdvancedTab(
|
|
openOnboarding: openOnboarding,
|
|
exportSettings: exportSettings,
|
|
importSettings: importSettings,
|
|
resetToDefaults: resetToDefaults
|
|
)
|
|
}
|
|
}
|
|
|
|
// MARK: - Reset / Export / Import
|
|
|
|
private func resetToDefaults() {
|
|
let d = UserDefaults.standard
|
|
for key in Self.exportableKeys { d.removeObject(forKey: key) }
|
|
Settings.registerDefaults()
|
|
LoginItem.set(d.bool(forKey: Settings.Key.launchAtLogin))
|
|
hasPassword = Settings.hasPassword
|
|
displayRefresh += 1
|
|
}
|
|
|
|
private func exportSettings() {
|
|
let panel = NSSavePanel()
|
|
panel.allowedContentTypes = [.json]
|
|
panel.nameFieldStringValue = "Curtain-Settings.json"
|
|
guard panel.runModal() == .OK, let url = panel.url else { return }
|
|
let d = UserDefaults.standard
|
|
var snapshot = SettingsSnapshot()
|
|
for key in Self.exportableKeys {
|
|
if let obj = d.object(forKey: key) { snapshot.values[key] = AnyCodable(obj) }
|
|
}
|
|
let encoder = JSONEncoder(); encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
|
if let data = try? encoder.encode(snapshot) { try? data.write(to: url) }
|
|
}
|
|
|
|
private func importSettings() {
|
|
let panel = NSOpenPanel()
|
|
panel.allowedContentTypes = [.json]
|
|
panel.allowsMultipleSelection = false
|
|
guard panel.runModal() == .OK, let url = panel.url,
|
|
let data = try? Data(contentsOf: url),
|
|
let snapshot = try? JSONDecoder().decode(SettingsSnapshot.self, from: data) else { return }
|
|
let d = UserDefaults.standard
|
|
for (key, wrapped) in snapshot.values where Self.exportableKeys.contains(key) {
|
|
d.set(wrapped.value, forKey: key)
|
|
}
|
|
LoginItem.set(d.bool(forKey: Settings.Key.launchAtLogin))
|
|
hasPassword = Settings.hasPassword
|
|
displayRefresh += 1
|
|
}
|
|
|
|
/// Every non-secret preference key, used by Reset/Export/Import. Password hash,
|
|
/// salt, and algo are deliberately excluded so a snapshot never leaks the secret.
|
|
/// armDisarmHotkey removed — EmergencyHotkey is intentionally hardcoded and there
|
|
/// is no runtime reader for that key.
|
|
static let exportableKeys: [String] = [
|
|
Settings.Key.armed, Settings.Key.launchAtLogin, Settings.Key.showInMenuBar,
|
|
Settings.Key.onStartActivate, Settings.Key.connectGraceSeconds, Settings.Key.notifyOnActivate, Settings.Key.playSoundOnActivate,
|
|
Settings.Key.coverStyle, Settings.Key.coverColor, Settings.Key.coverMessage, Settings.Key.coverShowClock,
|
|
Settings.Key.revealTrigger, Settings.Key.revealKeyCombo,
|
|
Settings.Key.idleEnabled, Settings.Key.idleMinutes, Settings.Key.idleSource,
|
|
Settings.Key.onIdleDisconnect, Settings.Key.onIdleLock, Settings.Key.onIdleScreenOff, Settings.Key.onIdleDeactivate,
|
|
Settings.Key.onEndLock, Settings.Key.onEndScreenOff, Settings.Key.onEndDeactivate,
|
|
Settings.Key.onUnlockAction, Settings.Key.passwordBoxTimeoutSeconds,
|
|
Settings.Key.requirePasswordToDeactivateFromMenu, Settings.Key.accessibilityMissingBehavior,
|
|
Settings.Key.disconnectFeatureEnabled,
|
|
Settings.Key.displayLinkUUIDs, Settings.Key.perDisplayCoverDisabled,
|
|
Settings.Key.coverScope, Settings.Key.passwordBoxPlacement, Settings.Key.passwordBoxSpecificUUID, Settings.Key.newDisplayPolicy,
|
|
Settings.Key.diagnosticsLoggingEnabled,
|
|
]
|
|
}
|