curtain/Sources/Curtain/Notifier.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

53 lines
2.6 KiB
Swift

import Foundation
import UserNotifications
/// Purpose: Thin wrapper over UNUserNotificationCenter for the app's banners. Replaces
/// the deprecated NSUserNotification path with the modern UserNotifications API.
/// Inputs: title/body strings; optional throttleKey + window to suppress repeats.
/// Outputs: an immediate (nil-trigger) user notification, or nothing when throttled.
/// Constraints: @MainActor touches shared throttle state and the notification center
/// from a single context to satisfy Swift 6 strict concurrency. Every call is
/// defensive: a missing bundle id or unavailable center must never crash the
/// agent, so failures are logged and swallowed. Authorization is best-effort.
/// SPORT: MASTER-NOTIFIER
@MainActor
enum Notifier {
/// Last post time per throttle key, used to suppress rapid repeats.
private static var lastPost: [String: Date] = [:]
/// Ask once (at launch) for permission to show banners and play sounds. The result
/// is ignored: if the user declines, post(...) simply becomes a no-op silently.
static func requestAuthorization() {
guard Bundle.main.bundleIdentifier != nil else {
NSLog("Curtain: skipping notification authorization — no bundle identifier")
return
}
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound]) { _, error in
if let error { NSLog("Curtain: notification authorization failed: \(error.localizedDescription)") }
}
}
/// Post an immediate banner. When `throttleKey` is set and `throttleSeconds > 0`,
/// repeats inside the window are dropped. Safe to call from anywhere; hops to main.
static func post(title: String, body: String, throttleKey: String? = nil, throttleSeconds: TimeInterval = 0) {
Task { @MainActor in
guard Bundle.main.bundleIdentifier != nil else { return }
if let key = throttleKey, throttleSeconds > 0 {
let now = Date()
if let last = lastPost[key], now.timeIntervalSince(last) < throttleSeconds { return }
lastPost[key] = now
}
let content = UNMutableNotificationContent()
content.title = title
content.body = body
let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil)
UNUserNotificationCenter.current().add(request) { error in
if let error { NSLog("Curtain: failed to post notification: \(error.localizedDescription)") }
}
}
}
}