curtain/Sources/Curtain/OnboardingWindow.swift
Aric Camarata 8c19e960d2 Detection root-cause fix + audit batch: netstat path, UDP activator, settings coherence, refactor, docs
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.
2026-06-09 20:36:30 -04:00

270 lines
9.4 KiB
Swift

import AppKit
import SwiftUI
/// Purpose: First-run onboarding flow that walks a brand-new user from download
/// to a working setup with no documentation: a five-step SwiftUI window
/// (welcome, accessibility grant, optional disconnect helper, optional
/// password, finish).
/// Inputs: a SessionCoordinator (for the disconnect-helper install) at init.
/// Outputs: side effects only. On finish it sets Settings.hasOnboarded = true,
/// applies LoginItem.set(Settings.launchAtLogin), and closes the window.
/// Constraints: AppKit + SwiftUI are @MainActor. The Accessibility step polls
/// AXIsProcessTrusted() on a ~1s timer; that timer is invalidated
/// when the user leaves step 2 or the window closes, so it never
/// leaks. The window is reused if show() is called again and is not
/// released on close so the controller can re-present it.
/// SPORT: MASTER-ONBOARDING
@MainActor
final class OnboardingWindowController {
private let coordinator: SessionCoordinator
private var window: NSWindow?
init(coordinator: SessionCoordinator) {
self.coordinator = coordinator
}
/// Present the onboarding window, building it on first use and reusing it after.
func show() {
if let window {
window.makeKeyAndOrderFront(nil)
NSApp.activate(ignoringOtherApps: true)
return
}
let view = OnboardingView(
enableDisconnectHelper: { [coordinator] on in
coordinator.enableDisconnectHelper(on)
},
finish: { [weak self] in
self?.completeOnboarding()
}
)
let hosting = NSHostingController(rootView: view)
let win = NSWindow(contentViewController: hosting)
win.title = "Welcome to Curtain"
win.styleMask = [.titled, .closable]
win.isReleasedWhenClosed = false
win.setContentSize(NSSize(width: 460, height: 460))
win.center()
self.window = win
win.makeKeyAndOrderFront(nil)
NSApp.activate(ignoringOtherApps: true)
}
private func completeOnboarding() {
Settings.hasOnboarded = true
LoginItem.set(Settings.launchAtLogin)
window?.close()
}
}
// MARK: - View
/// The multi-step onboarding content. Holds the current step and the live
/// accessibility-trust flag; the parent controller supplies the two closures
/// that reach into the coordinator and finish the flow.
private struct OnboardingView: View {
let enableDisconnectHelper: (Bool) -> Void
let finish: () -> Void
@State private var step: Step = .welcome
@State private var axTrusted: Bool = AXIsProcessTrusted()
@State private var axTimer: Timer?
@State private var disconnectOn = false
@State private var password = ""
@State private var passwordSaved = false
private enum Step: Int { case welcome, accessibility, disconnect, password, finish }
var body: some View {
VStack(spacing: 0) {
header
Divider()
content
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding(24)
}
.frame(width: 460, height: 460)
.onDisappear { stopAXPoll() }
}
private var header: some View {
HStack(spacing: 12) {
Image(nsImage: CurtainIcon.appIcon(size: 40))
.resizable()
.frame(width: 40, height: 40)
Text("Curtain")
.font(.title2.weight(.semibold))
Spacer()
}
.padding(.horizontal, 24)
.padding(.vertical, 16)
}
@ViewBuilder
private var content: some View {
switch step {
case .welcome: welcomeStep
case .accessibility: accessibilityStep
case .disconnect: disconnectStep
case .password: passwordStep
case .finish: finishStep
}
}
// MARK: Step 1 Welcome
private var welcomeStep: some View {
VStack(alignment: .leading, spacing: 20) {
Text("Hide your screen while you work remotely")
.font(.title3.weight(.semibold))
Text("Curtain covers your screen and blocks the keyboard and mouse at your desk while you remote in. It locks or sleeps the Mac when the session goes idle or disconnects.")
.foregroundStyle(.secondary)
Spacer()
HStack {
Spacer()
Button("Continue") { step = .accessibility }
.keyboardShortcut(.defaultAction)
}
}
}
// MARK: Step 2 Accessibility (required)
private var accessibilityStep: some View {
VStack(alignment: .leading, spacing: 16) {
Text("Allow Curtain to block desk input")
.font(.title3.weight(.semibold))
Text("Curtain needs Accessibility permission so it can capture the keyboard and mouse at your desk. Without it, your screen can be covered but input is not blocked.")
.foregroundStyle(.secondary)
HStack(spacing: 8) {
Circle()
.fill(axTrusted ? Color.green : Color.red)
.frame(width: 10, height: 10)
Text(axTrusted ? "Permission granted" : "Permission not granted yet")
.font(.callout)
}
Button("Open Accessibility Settings") { openAXSettings() }
Spacer()
HStack {
Button("Skip for now") {
stopAXPoll()
step = .disconnect
}
.help("Curtain will not be able to block keyboard and mouse input.")
Spacer()
Button("Continue") {
stopAXPoll()
step = .disconnect
}
.keyboardShortcut(.defaultAction)
.disabled(!axTrusted)
}
}
.onAppear { startAXPoll() }
}
// MARK: Step 3 Disconnect helper (optional, off)
private var disconnectStep: some View {
VStack(alignment: .leading, spacing: 16) {
Text("Disconnect the remote session (optional)")
.font(.title3.weight(.semibold))
Toggle(isOn: $disconnectOn) {
Text("Also disconnect the remote session on idle or end")
}
Text("This needs a one-time admin approval to install a small helper. Most people do not need it. Curtain still locks or sleeps the Mac without it.")
.foregroundStyle(.secondary)
.font(.callout)
Spacer()
HStack {
Spacer()
Button("Continue") {
if disconnectOn { enableDisconnectHelper(true) }
step = .password
}
.keyboardShortcut(.defaultAction)
}
}
}
// MARK: Step 4 Password (optional)
private var passwordStep: some View {
VStack(alignment: .leading, spacing: 16) {
Text("Set an unlock password (optional)")
.font(.title3.weight(.semibold))
Text("This password unlocks the screen at your desk. If you skip it, the default is \"curtain\" and you can change it later in Settings.")
.foregroundStyle(.secondary)
.font(.callout)
HStack {
SecureField("Password", text: $password)
.textFieldStyle(.roundedBorder)
Button("Set") {
Settings.setPassword(password)
passwordSaved = true
}
.disabled(password.isEmpty)
}
if passwordSaved {
Text("Password set.")
.font(.callout)
.foregroundStyle(.green)
}
Spacer()
HStack {
Button("Skip") { step = .finish }
Spacer()
Button("Continue") { step = .finish }
.keyboardShortcut(.defaultAction)
}
}
}
// MARK: Step 5 Finish
private var finishStep: some View {
VStack(alignment: .leading, spacing: 16) {
Text("You're all set")
.font(.title3.weight(.semibold))
Text("Curtain runs in the menu bar and starts at login. Open the menu bar icon any time to arm it or change settings.")
.foregroundStyle(.secondary)
Spacer()
HStack {
Spacer()
Button("Finish") { finish() }
.keyboardShortcut(.defaultAction)
}
}
}
// MARK: AX polling
private func startAXPoll() {
axTrusted = AXIsProcessTrusted()
axTimer?.invalidate()
axTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in
Task { @MainActor in axTrusted = AXIsProcessTrusted() }
}
}
private func stopAXPoll() {
axTimer?.invalidate()
axTimer = nil
}
private func openAXSettings() {
// macOS 13+ Privacy pane URL; the legacy ?Privacy_Accessibility query-string form
// stopped reliably opening the Accessibility row on Ventura+ and is dropped here.
let url = URL(string: "x-apple.systempreferences:com.apple.Privacy-Accessibility-Settings")!
NSWorkspace.shared.open(url)
}
}