mirror of
https://github.com/acamarata/curtain.git
synced 2026-06-30 18:54:25 +00:00
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
171 lines
7.9 KiB
Swift
171 lines
7.9 KiB
Swift
import SwiftUI
|
|
import AppKit
|
|
|
|
/// Purpose: The single settings window (Caffeine-style). Binds to the same
|
|
/// UserDefaults keys the coordinator reads, so changes take effect live.
|
|
/// SPORT: MASTER-PREFS
|
|
final class PreferencesWindowController {
|
|
private var window: NSWindow?
|
|
private weak var coordinator: SessionCoordinator?
|
|
var onMenuBarToggle: ((Bool) -> 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 self] in self?.coordinator?.activateNow() },
|
|
testNow: { [weak self] in self?.coordinator?.testCurtain() },
|
|
markDisplayLink: { Self.markExternalsAsDisplayLink() },
|
|
identifyDisplays: { Self.identifyDisplays() },
|
|
onMenuBarToggle: { [weak self] on in self?.onMenuBarToggle?(on) }
|
|
)
|
|
let w = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 460, height: 560),
|
|
styleMask: [.titled, .closable, .miniaturizable],
|
|
backing: .buffered, defer: false)
|
|
w.title = "Curtain"
|
|
w.contentViewController = NSHostingController(rootView: view)
|
|
w.center(); w.isReleasedWhenClosed = false
|
|
window = w
|
|
w.makeKeyAndOrderFront(nil); NSApp.activate(ignoringOtherApps: true)
|
|
}
|
|
|
|
// MARK: - Display helpers (shared with menu)
|
|
|
|
static func markExternalsAsDisplayLink() {
|
|
var serials: [UInt32] = []
|
|
for s in NSScreen.screens {
|
|
let id = s.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as! CGDirectDisplayID
|
|
if CGDisplayIsBuiltin(id) == 0 { serials.append(System.serial(of: s)) }
|
|
}
|
|
Settings.displayLinkSerials = serials
|
|
let a = NSAlert(); a.messageText = "Curtain"
|
|
a.informativeText = "Marked \(serials.count) external display(s) as DisplayLink."
|
|
NSApp.activate(ignoringOtherApps: true); a.runModal()
|
|
}
|
|
|
|
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 lbl = NSTextField(labelWithString: "\(i)\nserial \(System.serial(of: screen))")
|
|
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) } }
|
|
}
|
|
}
|
|
|
|
/// The SwiftUI settings form.
|
|
private struct PreferencesView: View {
|
|
// App
|
|
@AppStorage(Settings.Key.launchAtLogin) private var launchAtLogin = true
|
|
@AppStorage(Settings.Key.showInMenuBar) private var showInMenuBar = true
|
|
// Start
|
|
@AppStorage(Settings.Key.onStartActivate) private var onStartActivate = true
|
|
// Idle
|
|
@AppStorage(Settings.Key.idleEnabled) private var idleEnabled = true
|
|
@AppStorage(Settings.Key.idleMinutes) private var idleMinutes = 30
|
|
@AppStorage(Settings.Key.onIdleDisconnect) private var idleDisconnect = true
|
|
@AppStorage(Settings.Key.onIdleLock) private var idleLock = true
|
|
@AppStorage(Settings.Key.onIdleScreenOff) private var idleScreenOff = true
|
|
@AppStorage(Settings.Key.onIdleDeactivate) private var idleDeactivate = true
|
|
// End
|
|
@AppStorage(Settings.Key.onEndLock) private var endLock = true
|
|
@AppStorage(Settings.Key.onEndScreenOff) private var endScreenOff = true
|
|
@AppStorage(Settings.Key.onEndDeactivate) private var endDeactivate = true
|
|
// Password
|
|
@AppStorage(Settings.Key.onPasswordDisconnect) private var passwordDisconnect = true
|
|
@State private var newPassword = ""
|
|
|
|
let activateNow: () -> Void
|
|
let testNow: () -> Void
|
|
let markDisplayLink: () -> Void
|
|
let identifyDisplays: () -> Void
|
|
let onMenuBarToggle: (Bool) -> Void
|
|
|
|
var body: some View {
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: 18) {
|
|
header
|
|
|
|
group("Application") {
|
|
Toggle("Open at login", isOn: $launchAtLogin)
|
|
.onChange(of: launchAtLogin) { LoginItem.set($0) }
|
|
Toggle("Show in menu bar", isOn: $showInMenuBar)
|
|
.onChange(of: showInMenuBar) { onMenuBarToggle($0) }
|
|
HStack {
|
|
Button("Activate Now", action: activateNow)
|
|
Button("Test (10s)", action: testNow)
|
|
}
|
|
}
|
|
|
|
group("On session start") {
|
|
Toggle("Activate curtain when a remote session begins", isOn: $onStartActivate)
|
|
}
|
|
|
|
group("On session idle") {
|
|
Toggle("Act after the session is idle", isOn: $idleEnabled)
|
|
if idleEnabled {
|
|
Stepper("Idle timeout: \(idleMinutes) min", value: $idleMinutes, in: 1...240)
|
|
Toggle("Disconnect the remote session", isOn: $idleDisconnect)
|
|
Toggle("Lock the Mac", isOn: $idleLock)
|
|
Toggle("Turn off the displays", isOn: $idleScreenOff)
|
|
Toggle("Deactivate the curtain", isOn: $idleDeactivate)
|
|
}
|
|
}
|
|
|
|
group("On session end (disconnect)") {
|
|
Toggle("Lock the Mac", isOn: $endLock)
|
|
Toggle("Turn off the displays", isOn: $endScreenOff)
|
|
Toggle("Deactivate the curtain", isOn: $endDeactivate)
|
|
}
|
|
|
|
group("Security") {
|
|
Toggle("Disconnect remote when password is entered at the desk", isOn: $passwordDisconnect)
|
|
HStack {
|
|
SecureField("New unlock password", text: $newPassword)
|
|
Button("Set") { if !newPassword.isEmpty { Settings.setPassword(newPassword); newPassword = "" } }
|
|
}
|
|
Text(Settings.hasPassword ? "A password is set." : "No password set (default: “curtain”).")
|
|
.font(.caption).foregroundStyle(.secondary)
|
|
}
|
|
|
|
group("Displays") {
|
|
Text("DisplayLink monitors can't be hidden invisibly; mark them so the curtain covers them too.")
|
|
.font(.caption).foregroundStyle(.secondary)
|
|
HStack {
|
|
Button("Identify Displays", action: identifyDisplays)
|
|
Button("Mark Externals as DisplayLink", action: markDisplayLink)
|
|
}
|
|
}
|
|
}
|
|
.padding(22)
|
|
}
|
|
.frame(width: 460, height: 560)
|
|
}
|
|
|
|
private var header: some View {
|
|
HStack(spacing: 12) {
|
|
Image(nsImage: CurtainIcon.appIcon(size: 48))
|
|
.resizable().frame(width: 48, height: 48)
|
|
VStack(alignment: .leading) {
|
|
Text("Curtain").font(.title2).bold()
|
|
Text("Privacy for macOS Screen Sharing").font(.caption).foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
}
|
|
|
|
@ViewBuilder private func group<Content: View>(_ title: String, @ViewBuilder _ content: () -> Content) -> some View {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Text(title.uppercased()).font(.caption2).bold().foregroundStyle(.secondary)
|
|
content()
|
|
}
|
|
}
|
|
}
|