mirror of
https://github.com/acamarata/curtain.git
synced 2026-07-01 11:14: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.
151 lines
7.3 KiB
Swift
151 lines
7.3 KiB
Swift
import SwiftUI
|
||
import AppKit
|
||
|
||
/// Purpose: Displays tab — per-display Cover/DisplayLink toggles, cover-scope picker,
|
||
/// password-box placement picker (with specific-display sub-picker), new-display
|
||
/// policy, and display-tool buttons.
|
||
/// Extracted from PreferencesView to keep every tab file under 500 lines.
|
||
/// Inputs: @AppStorage bindings for all display-related prefs; injected closures for
|
||
/// the Identify and Mark-as-DisplayLink buttons; displayRefresh trigger from the
|
||
/// parent so the list re-reads after a toggle.
|
||
/// Outputs: writes to UserDefaults; calls identifyDisplays / markDisplayLink closures.
|
||
/// Constraints: @MainActor (SwiftUI). Cover scope uses two values only: "all" (fail-safe
|
||
/// default) and "perDisplay" (delegate to per-display Cover toggles). The legacy
|
||
/// "onlyMarked"/"allExceptMarked" values are migrated by Settings.registerDefaults.
|
||
/// SPORT: MASTER-PREFS
|
||
struct PrefDisplaysTab: View {
|
||
// Scope: two-mode model — "all" is the fail-safe, "perDisplay" delegates to toggles.
|
||
@AppStorage(Settings.Key.coverScope) private var coverScope = "all"
|
||
@AppStorage(Settings.Key.passwordBoxPlacement) private var passwordBoxPlacement = "followActive"
|
||
// Stores the UUID of the display chosen for the specific-display password box.
|
||
@AppStorage(Settings.Key.passwordBoxSpecificUUID) private var passwordBoxSpecificUUID = ""
|
||
@AppStorage(Settings.Key.newDisplayPolicy) private var newDisplayPolicy = "cover"
|
||
|
||
/// Bumped by the parent to force the dynamic display list to re-read after a toggle.
|
||
let displayRefresh: Int
|
||
let identifyDisplays: () -> Void
|
||
let markDisplayLink: () -> Void
|
||
let onMarkDisplayLink: () -> Void // called after markDisplayLink so parent can bump refresh
|
||
|
||
var body: some View {
|
||
Form {
|
||
Section {
|
||
ForEach(Array(NSScreen.screens.enumerated()), id: \.offset) { idx, screen in
|
||
displayRow(index: idx, screen: screen)
|
||
}
|
||
.id(displayRefresh)
|
||
} header: {
|
||
Text("Connected displays")
|
||
} footer: {
|
||
Text("DisplayLink monitors can't be hidden invisibly; mark them so the curtain covers them too.")
|
||
.font(.caption).foregroundStyle(.secondary)
|
||
}
|
||
|
||
Section {
|
||
// Cover-scope: two options — All displays (fail-safe) or Per-display toggles.
|
||
Picker("Cover scope", selection: $coverScope) {
|
||
Text("All displays").tag("all")
|
||
Text("Per-display Cover toggles").tag("perDisplay")
|
||
}
|
||
Text("In per-display mode each display's Cover toggle decides; new displays follow the new-display policy below. All displays is the fail-safe default.")
|
||
.font(.caption).foregroundStyle(.secondary)
|
||
|
||
Picker("Password box placement", selection: $passwordBoxPlacement) {
|
||
Text("Primary display").tag("primary")
|
||
Text("Follow active display").tag("followActive")
|
||
Text("All displays").tag("all")
|
||
Text("A specific display").tag("specific")
|
||
}
|
||
|
||
// Shown only when "specific" is chosen — lets the user pin the password
|
||
// box to one display by UUID. Shows a "(disconnected)" row if the stored
|
||
// UUID is no longer connected, so the selection is never silently lost.
|
||
if passwordBoxPlacement == "specific" {
|
||
specificDisplayPicker
|
||
}
|
||
|
||
Picker("When a new display connects", selection: $newDisplayPolicy) {
|
||
Text("Cover it").tag("cover")
|
||
Text("Leave it uncovered").tag("leaveUncovered")
|
||
Text("Treat it as DisplayLink").tag("treatAsDisplayLink")
|
||
}
|
||
} header: {
|
||
Text("Behavior")
|
||
}
|
||
|
||
Section("Tools") {
|
||
HStack {
|
||
Button("Identify Displays", action: identifyDisplays)
|
||
Button("Mark Externals as DisplayLink") {
|
||
markDisplayLink()
|
||
onMarkDisplayLink()
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.formStyle(.grouped)
|
||
}
|
||
|
||
// MARK: - Per-display row
|
||
|
||
private func displayRow(index: Int, screen: NSScreen) -> some View {
|
||
let uuid = System.uuid(of: screen)
|
||
let shortID = uuid.map { String($0.prefix(8)) } ?? "unknown"
|
||
let res = "\(Int(screen.frame.width))×\(Int(screen.frame.height))"
|
||
let cover = Binding<Bool>(
|
||
get: { uuid.map { !Settings.perDisplayCoverDisabled.contains($0) } ?? true },
|
||
set: { newValue in
|
||
guard let u = uuid else { return }
|
||
var list = Settings.perDisplayCoverDisabled
|
||
if newValue { list.removeAll { $0 == u } } else if !list.contains(u) { list.append(u) }
|
||
Settings.perDisplayCoverDisabled = list
|
||
}
|
||
)
|
||
let displayLink = Binding<Bool>(
|
||
get: { uuid.map { Settings.displayLinkUUIDs.contains($0) } ?? false },
|
||
set: { newValue in
|
||
guard let u = uuid else { return }
|
||
var list = Settings.displayLinkUUIDs
|
||
if newValue { if !list.contains(u) { list.append(u) } } else { list.removeAll { $0 == u } }
|
||
Settings.displayLinkUUIDs = list
|
||
}
|
||
)
|
||
return VStack(alignment: .leading, spacing: 4) {
|
||
Text("Display \(index) · \(res) · \(shortID)").font(.caption).bold()
|
||
HStack {
|
||
Toggle("Cover", isOn: cover)
|
||
Toggle("DisplayLink", isOn: displayLink)
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Specific-display picker for password box
|
||
|
||
/// A picker over currently connected displays, using the display's full UUID as the
|
||
/// tag value. If the stored UUID is no longer connected, an extra "(disconnected)"
|
||
/// row preserves the selection so the user can see what was chosen.
|
||
private var specificDisplayPicker: some View {
|
||
let screens = NSScreen.screens
|
||
let connectedUUIDs: [(uuid: String, label: String)] = screens.enumerated().compactMap { idx, s in
|
||
guard let u = System.uuid(of: s) else { return nil }
|
||
let res = "\(Int(s.frame.width))×\(Int(s.frame.height))"
|
||
let short = String(u.prefix(8))
|
||
return (uuid: u, label: "Display \(idx) · \(res) · \(short)")
|
||
}
|
||
let storedIsOrphan = !passwordBoxSpecificUUID.isEmpty
|
||
&& !connectedUUIDs.contains(where: { $0.uuid == passwordBoxSpecificUUID })
|
||
|
||
return Picker("Specific display", selection: $passwordBoxSpecificUUID) {
|
||
ForEach(connectedUUIDs, id: \.uuid) { item in
|
||
Text(item.label).tag(item.uuid)
|
||
}
|
||
// Keep an orphaned UUID visible so the user knows what was stored and can
|
||
// change it deliberately rather than having it silently reset to empty.
|
||
if storedIsOrphan {
|
||
Text("\(String(passwordBoxSpecificUUID.prefix(8)))… (disconnected)")
|
||
.tag(passwordBoxSpecificUUID)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
}
|
||
}
|