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.
270 lines
9.4 KiB
Swift
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)
|
|
}
|
|
}
|