From 30d5a77ffa2b158fe3cbe6c4d2dc08d3e4eaa654 Mon Sep 17 00:00:00 2001 From: acamarata Date: Mon, 1 Jun 2026 15:51:20 -0400 Subject: [PATCH] =?UTF-8?q?v1.1=20=E2=80=94=20settings=20window,=20modular?= =?UTF-8?q?=20lifecycle=20actions,=20login=20item,=20logo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- README.md | 72 ++++++--- Scripts/install.sh | 10 ++ Sources/Curtain/Actions.swift | 46 ++++++ Sources/Curtain/AppDelegate.swift | 186 +++-------------------- Sources/Curtain/Config.swift | 68 --------- Sources/Curtain/Curtain.swift | 2 +- Sources/Curtain/CurtainIcon.swift | 120 +++++++++++++++ Sources/Curtain/LoginItem.swift | 18 +++ Sources/Curtain/MenuBarController.swift | 56 +++++++ Sources/Curtain/PreferencesWindow.swift | 171 +++++++++++++++++++++ Sources/Curtain/SessionCoordinator.swift | 76 +++++++++ Sources/Curtain/SessionMonitor.swift | 12 +- Sources/Curtain/Settings.swift | 105 +++++++++++++ Sources/Curtain/System.swift | 2 +- Sources/Curtain/main.swift | 15 +- 15 files changed, 701 insertions(+), 258 deletions(-) create mode 100644 Sources/Curtain/Actions.swift delete mode 100644 Sources/Curtain/Config.swift create mode 100644 Sources/Curtain/CurtainIcon.swift create mode 100644 Sources/Curtain/LoginItem.swift create mode 100644 Sources/Curtain/MenuBarController.swift create mode 100644 Sources/Curtain/PreferencesWindow.swift create mode 100644 Sources/Curtain/SessionCoordinator.swift create mode 100644 Sources/Curtain/Settings.swift diff --git a/README.md b/README.md index 6839cea..49ad952 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,23 @@ # Curtain -A privacy curtain for macOS Screen Sharing. When you remote into your Mac, Curtain hides the screen from anyone sitting at it and makes the local keyboard and mouse do nothing — while you keep full control from your laptop. When the session ends or goes idle, it locks the Mac and sleeps the displays. +A privacy curtain for macOS Screen Sharing. When you remote into your Mac, Curtain hides the screen from anyone sitting at it and makes the local keyboard and mouse do nothing to your apps, while you keep full control from your laptop. When the session goes idle or ends, it can lock the Mac and sleep the displays. -macOS already does Screen Sharing well. Curtain is *only* the missing privacy layer around it — a lightweight menu-bar agent, in the spirit of Caffeine. +macOS already does Screen Sharing well. Curtain is the missing privacy layer around it: a lightweight menu-bar agent with a simple settings window, in the spirit of Caffeine. + +

Connect → screen covered, desk input dead, you control remotely. Idle/disconnect → lock + displays off.

+ +## Why it works (the one hard part) + +Your laptop and the desk share one login session, so a window that blocks input would block you too. Curtain instead filters input by **source**: macOS tags real hardware events differently from injected remote events, so Curtain blocks the desk's keyboard/mouse while letting your remote control pass through. No virtual display, no second account. ## What it does -| Event | Behavior | +| Event | Default behavior (all configurable) | |---|---| -| **Screen Sharing connects** | Curtain covers every physical display. The local keyboard/mouse are blocked from the apps; your remote keyboard/mouse work normally. A password box appears if someone presses a key at the desk. | -| **Password entered at the desk** | Ends the remote session and reveals the desktop (asks first). | -| **Session idle ~30 min** | Disconnects the remote session, locks the Mac, sleeps the displays. | -| **You disconnect** | Locks the Mac, sleeps the displays. | - -The key trick: macOS tags real hardware input differently from injected remote input, so Curtain blocks the desk's keyboard/mouse while letting your remote control pass through — on the same login session, no virtual display needed. +| **Remote session starts** | Curtain covers every physical display; desk keyboard/mouse are blocked from apps; your remote input works; displays kept awake. | +| **Key pressed at the desk** | A password box appears (on the desk only). Correct password reveals the desktop and offers to disconnect the remote. | +| **Session idle (default 30 min)** | Disconnect remote · lock Mac · sleep displays · deactivate curtain. | +| **Session ends (disconnect)** | Lock Mac · sleep displays · deactivate curtain. | ## Install @@ -23,22 +27,54 @@ cd curtain ./Scripts/install.sh ``` -Then grant **Accessibility** to Curtain in System Settings → Privacy & Security → Accessibility (required so it can block desk input). From the menu-bar icon: **Set Password…** and, if you use DisplayLink monitors, **Mark Current Externals as DisplayLink**. +The installer builds the app to `/Applications/Curtain.app`, generates the curtains icon, registers a login agent, and sets up a small root helper (one admin prompt) used to disconnect a Screen Sharing session. + +Then, **once**: +1. Grant **Accessibility** to Curtain: System Settings → Privacy & Security → Accessibility. This lets it block desk input. (Curtain prompts you on first launch.) +2. Open Curtain (menu-bar icon, or launch the app) → **Set a password** and, if you use DisplayLink monitors, **Mark Externals as DisplayLink**. Uninstall: `./Scripts/uninstall.sh` +## The settings window + +Open it from the menu-bar curtains icon, or by launching Curtain.app. Everything is a toggle; changes take effect immediately. + +### Application +- **Open at login** — run Curtain automatically (via `SMAppService`). +- **Show in menu bar** — show or hide the curtains icon. Hidden still runs in the background; reopen the app to get settings back. +- **Activate Now** / **Test (10s)** — show the curtain on demand. + +### On session start +- **Activate curtain when a remote session begins** — the core behavior. Turn off to leave Curtain armed but passive. + +### On session idle +- **Act after the session is idle** + **Idle timeout** (1–240 min). +- Independent toggles for what happens at idle: **Disconnect the remote session**, **Lock the Mac**, **Turn off the displays**, **Deactivate the curtain**. + +### On session end (disconnect) +- Independent toggles: **Lock the Mac**, **Turn off the displays**, **Deactivate the curtain**. + +### Security +- **Disconnect remote when password is entered at the desk** — on unlock, offer to kick the remote operator. +- **Set password** — typed at the desk to get past the curtain. Stored as a salted SHA256 hash. If unset, the default is `curtain` so you are never locked out. + +### Displays +- **Identify Displays** — flashes each display's index and serial. +- **Mark Externals as DisplayLink** — marks every external monitor as DisplayLink. + +## DisplayLink monitors + +DisplayLink displays exist only through screen capture, so they can't be hidden invisibly the way directly-attached displays can. On those monitors the curtain also appears in your remote view. Native displays stay clear in your session while hidden at the desk. Mark your DisplayLink monitors in settings so they get covered correctly. + ## Requirements -- macOS 13 (Ventura) or later — built and used on macOS 26 / Apple Silicon -- Screen Sharing enabled (System Settings → General → Sharing → Screen Sharing) +- macOS 13 (Ventura) or later. Built and used on macOS 26 / Apple Silicon. +- Screen Sharing enabled: System Settings → General → Sharing → Screen Sharing. +- Accessibility permission for Curtain (to block desk input). -## A note on DisplayLink monitors +## How it works / architecture / lessons -DisplayLink displays exist only through screen capture, so they can't be hidden invisibly the way native displays can. On those monitors the curtain also shows in your remote view. Native (directly-attached) displays are hidden from onlookers while staying clear in your remote session. - -## How it works / lessons - -See the [wiki](../../wiki) for the architecture, the macOS APIs involved, and the (many) lessons learned building this. +Full detail in the [wiki](../../wiki): architecture, the macOS APIs involved, and the lessons learned (including the things that did not work). ## License diff --git a/Scripts/install.sh b/Scripts/install.sh index 36739cc..43428c4 100755 --- a/Scripts/install.sh +++ b/Scripts/install.sh @@ -18,6 +18,15 @@ echo "==> Installing $APP …" rm -rf "$APP" mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources" cp "$BIN" "$APP/Contents/MacOS/Curtain" + +echo "==> Generating app icon…" +ICONSET="$(mktemp -d)/Curtain.iconset" +"$BIN" --render-icon "$ICONSET" || true +if [ -d "$ICONSET" ]; then + iconutil -c icns "$ICONSET" -o "$APP/Contents/Resources/AppIcon.icns" 2>/dev/null || true + rm -rf "$ICONSET" +fi + cat > "$APP/Contents/Info.plist" < @@ -25,6 +34,7 @@ cat > "$APP/Contents/Info.plist" <CFBundleNameCurtain CFBundleIdentifierio.acamarata.curtain CFBundleExecutableCurtain + CFBundleIconFileAppIcon CFBundlePackageTypeAPPL CFBundleShortVersionString1.0.0 LSUIElement diff --git a/Sources/Curtain/Actions.swift b/Sources/Curtain/Actions.swift new file mode 100644 index 0000000..4fcddcf --- /dev/null +++ b/Sources/Curtain/Actions.swift @@ -0,0 +1,46 @@ +import Foundation + +/// Purpose: A composable set of lifecycle actions. Each phase (start / idle / end) +/// maps to an ActionSet; the runner performs only the enabled actions. +/// Keeping actions independent and data-driven means new behaviors are a +/// field here, not a branch scattered across the app. +/// SPORT: MASTER-ACTIONS +struct ActionSet { + var activateCurtain = false + var disconnect = false + var lock = false + var screenOff = false + var deactivateCurtain = false +} + +/// Performs an ActionSet against the live curtain + system. Ordering is deliberate: +/// disconnect the operator first, deactivate/cover the screen, then lock, then sleep +/// displays last (so the lock is in place before the panels go dark). +struct ActionRunner { + let curtain: CurtainController + let input: InputFilter + + func run(_ set: ActionSet) { + if set.activateCurtain { activateCover() } + if set.disconnect { System.endScreenShareSession() } + if set.deactivateCurtain { deactivateCover() } + if set.lock { System.lockScreen() } + if set.screenOff { + // Give the lock a beat to take hold before the displays sleep. + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { System.sleepDisplays() } + } + } + + func activateCover() { + guard !curtain.isShown else { return } + curtain.show() + System.preventDisplaySleep() + _ = input.start() // no-op result: cover still hides even without Accessibility + } + + func deactivateCover() { + input.stop() + curtain.hide() + System.allowDisplaySleep() + } +} diff --git a/Sources/Curtain/AppDelegate.swift b/Sources/Curtain/AppDelegate.swift index 2dce895..17a8afe 100644 --- a/Sources/Curtain/AppDelegate.swift +++ b/Sources/Curtain/AppDelegate.swift @@ -1,183 +1,43 @@ import Cocoa -/// Purpose: Menu-bar agent that wires the monitor, curtain, and input filter together. -/// Lifecycle (the entire product): -/// connect -> show curtain, block physical input, allow remote, keep displays awake -/// password -> end the Screen Sharing session, drop the curtain (optionally confirm) -/// idle 30m -> end session, lock the Mac, sleep displays -/// disconnect -> lock the Mac, sleep displays +/// Purpose: App entry orchestration. Owns the coordinator, the optional menu bar, +/// and the settings window. Keeps logic out of the UI: it just wires +/// callbacks between the pieces. /// SPORT: MASTER-APP final class AppDelegate: NSObject, NSApplicationDelegate { - private let monitor = SessionMonitor() - private let curtain = CurtainController() - private let input = InputFilter() - private var statusItem: NSStatusItem! - private var tickTimer: Timer? + private let coordinator = SessionCoordinator() + private lazy var menuBar = MenuBarController(coordinator: coordinator) + private lazy var prefs = PreferencesWindowController(coordinator: coordinator) func applicationDidFinishLaunching(_ n: Notification) { - setupMenuBar() + Settings.registerDefaults() - input.onPhysicalKey = { [weak self] kc, chars in self?.curtain.physicalKey(kc, chars) } + coordinator.onStateChange = { [weak self] active in self?.menuBar.reflect(active: active) } + coordinator.start() - curtain.onUnlock = { [weak self] in self?.unlockFromDesk() } + menuBar.onOpenSettings = { [weak self] in self?.prefs.show() } + menuBar.onQuit = { [weak self] in self?.quit() } + if Settings.showInMenuBar { menuBar.show() } - monitor.onConnect = { [weak self] in self?.sessionStarted() } - monitor.onDisconnect = { [weak self] in self?.sessionEnded(lock: true) } - monitor.onIdleTimeout = { [weak self] in self?.idleTimedOut() } - monitor.start() + prefs.onMenuBarToggle = { [weak self] on in on ? self?.menuBar.show() : self?.menuBar.hide() } - tickTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in - self?.curtain.tick() - } + // First run: no password and no menu bar would be confusing — open settings. + if !Settings.hasPassword && !Settings.showInMenuBar { prefs.show() } + if !AXIsProcessTrusted() { requestAccessibility() } - if !AXIsProcessTrusted() { promptForAccessibility() } + // Reconcile the login-item state with the saved preference. + LoginItem.set(Settings.launchAtLogin) } - // MARK: - Lifecycle handlers - - private func sessionStarted() { - guard Config.shared.enabled else { return } - curtain.show() - System.preventDisplaySleep() - if !input.start() { /* Accessibility missing; curtain still hides the screen */ } - updateMenuBarState(active: true) + /// Re-opening the app from Finder shows the settings window. + func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool { + prefs.show(); return true } - private func sessionEnded(lock: Bool) { - input.stop() - curtain.hide() - System.allowDisplaySleep() - updateMenuBarState(active: false) - if lock { - System.lockScreen() - DispatchQueue.main.asyncAfter(deadline: .now() + 1) { System.sleepDisplays() } - } - } + private func quit() { coordinator.deactivateNow(); NSApp.terminate(nil) } - private func idleTimedOut() { - System.endScreenShareSession() // kick the idle remote operator - sessionEnded(lock: true) - } - - /// Host typed the correct password at the desk: end the remote session and reveal the desktop. - private func unlockFromDesk() { - input.stop() - curtain.hide() - System.allowDisplaySleep() - updateMenuBarState(active: false) - let alert = NSAlert() - alert.messageText = "End the remote session?" - alert.informativeText = "Unlocked at this Mac. Disconnect the active Screen Sharing session?" - alert.addButton(withTitle: "Disconnect Remote") - alert.addButton(withTitle: "Keep Connected") - alert.alertStyle = .informational - NSApp.activate(ignoringOtherApps: true) - if alert.runModal() == .alertFirstButtonReturn { - System.endScreenShareSession() - } - } - - // MARK: - Menu bar - - private func setupMenuBar() { - statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) - updateMenuBarState(active: false) - let menu = NSMenu() - menu.addItem(withTitle: "Curtain", action: nil, keyEquivalent: "") - menu.addItem(.separator()) - func add(_ title: String, _ sel: Selector, key: String = "") -> NSMenuItem { - let item = NSMenuItem(title: title, action: sel, keyEquivalent: key) - item.target = self; menu.addItem(item); return item - } - let enabled = add("Armed", #selector(toggleEnabled)) - enabled.state = Config.shared.enabled ? .on : .off - _ = add("Set Password…", #selector(setPassword)) - _ = add("Identify Displays", #selector(identifyDisplays)) - _ = add("Mark Current Externals as DisplayLink", #selector(markDisplayLink)) - menu.addItem(.separator()) - _ = add("Test Curtain (10s)", #selector(testCurtain)) - menu.addItem(.separator()) - _ = add("Quit Curtain", #selector(quit), key: "q") - statusItem.menu = menu - } - - private func updateMenuBarState(active: Bool) { - if let b = statusItem.button { - b.title = active ? "🔒" : (Config.shared.enabled ? "👁" : "○") - } - } - - @objc private func toggleEnabled(_ item: NSMenuItem) { - Config.shared.enabled.toggle(); Config.shared.save() - item.state = Config.shared.enabled ? .on : .off - if !Config.shared.enabled, curtain.isShown { sessionEnded(lock: false) } - updateMenuBarState(active: curtain.isShown) - } - - @objc private func setPassword() { - let alert = NSAlert() - alert.messageText = "Set Curtain Password" - alert.informativeText = "Typed at the desk to end a remote session." - let field = NSSecureTextField(frame: NSRect(x: 0, y: 0, width: 240, height: 24)) - alert.accessoryView = field - alert.addButton(withTitle: "Save"); alert.addButton(withTitle: "Cancel") - NSApp.activate(ignoringOtherApps: true) - if alert.runModal() == .alertFirstButtonReturn, !field.stringValue.isEmpty { - Config.shared.setPassword(field.stringValue) - } - } - - @objc private func markDisplayLink() { - // Externals = every non-built-in display. Practical default for DisplayLink setups. - 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)) } - } - Config.shared.displayLinkSerials = serials; Config.shared.save() - notify("Marked \(serials.count) display(s) as DisplayLink.") - } - - @objc private func identifyDisplays() { - // Briefly flash a big number on each display so the user knows the index/serial. - 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 - 120, width: screen.frame.width, height: 240) - lbl.alignment = .center; lbl.font = .systemFont(ofSize: 120, 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) } } - } - - @objc private func testCurtain() { - curtain.show(); System.preventDisplaySleep(); _ = input.start() - updateMenuBarState(active: true) - DispatchQueue.main.asyncAfter(deadline: .now() + 10) { [weak self] in - self?.input.stop(); self?.curtain.hide(); System.allowDisplaySleep() - self?.updateMenuBarState(active: false) - } - } - - @objc private func quit() { sessionEnded(lock: false); NSApp.terminate(nil) } - - // MARK: - Permission + notify - - private func promptForAccessibility() { + private func requestAccessibility() { let opts = [kAXTrustedCheckOptionPrompt.takeUnretainedValue(): true] as CFDictionary _ = AXIsProcessTrustedWithOptions(opts) - notify("Grant Curtain Accessibility in System Settings, then relaunch, so it can block desk input.") - } - - private func notify(_ text: String) { - let a = NSAlert(); a.messageText = "Curtain"; a.informativeText = text - NSApp.activate(ignoringOtherApps: true); a.runModal() } } diff --git a/Sources/Curtain/Config.swift b/Sources/Curtain/Config.swift deleted file mode 100644 index 1515ad9..0000000 --- a/Sources/Curtain/Config.swift +++ /dev/null @@ -1,68 +0,0 @@ -import Foundation -import CryptoKit - -/// Purpose: Persistent settings for Curtain, stored as JSON in Application Support. -/// Inputs: none (reads/writes ~/Library/Application Support/Curtain/config.json) -/// Outputs: a mutable singleton `Config.shared` -/// Constraints: password is stored as a salted SHA256 hash, never plaintext. -/// SPORT: MASTER-CONFIG -struct Config: Codable { - /// Whether Curtain is armed. When false, no curtain is shown on connect. - var enabled: Bool = true - /// Salted SHA256 of the unlock password (hex). Empty = no password set (uses default). - var passwordHash: String = "" - /// Random per-install salt for the password hash. - var salt: String = "" - /// Serial numbers of DisplayLink monitors. They can only be hidden with a - /// capturable cover (visible in the remote view too) — see Lessons. Native - /// displays are hidden invisibly via sharingType=.none. - var displayLinkSerials: [UInt32] = [] - /// Minutes of no input before the session is force-ended + the Mac locked. - var idleMinutes: Int = 30 - - private static var url: URL { - let dir = FileManager.default.homeDirectoryForCurrentUser - .appendingPathComponent("Library/Application Support/Curtain", isDirectory: true) - try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) - return dir.appendingPathComponent("config.json") - } - - static var shared: Config = load() - - static func load() -> Config { - guard let data = try? Data(contentsOf: url), - let cfg = try? JSONDecoder().decode(Config.self, from: data) else { - var c = Config(); c.salt = randomSalt(); c.save(); return c - } - return cfg - } - - func save() { - if let data = try? JSONEncoder().encode(self) { try? data.write(to: Self.url) } - } - - // MARK: - Password - - /// Set a new unlock password (stored hashed). - mutating func setPassword(_ plain: String) { - if salt.isEmpty { salt = Self.randomSalt() } - passwordHash = Self.hash(plain, salt: salt) - save() - } - - /// Verify a candidate password against the stored hash. If no password is set, - /// the built-in default "curtain" is accepted so the Mac is never unrecoverable. - func verify(_ candidate: String) -> Bool { - if passwordHash.isEmpty { return candidate == "curtain" } - return Self.hash(candidate, salt: salt) == passwordHash - } - - private static func randomSalt() -> String { - (0..<16).map { _ in String(format: "%02x", UInt8.random(in: 0...255)) }.joined() - } - - private static func hash(_ s: String, salt: String) -> String { - let digest = SHA256.hash(data: Data((salt + s).utf8)) - return digest.map { String(format: "%02x", $0) }.joined() - } -} diff --git a/Sources/Curtain/Curtain.swift b/Sources/Curtain/Curtain.swift index ab97d7b..f53e7e9 100644 --- a/Sources/Curtain/Curtain.swift +++ b/Sources/Curtain/Curtain.swift @@ -133,7 +133,7 @@ final class PasswordBox: NSView { bump() switch keycode { case 36, 76: // Return / Enter - if Config.shared.verify(buffer) { onSuccess?() } + if Settings.verify(buffer) { onSuccess?() } else { buffer = ""; dots.stringValue = ""; err.stringValue = "Wrong password"; err.isHidden = false } case 53: // Esc isHidden = true diff --git a/Sources/Curtain/CurtainIcon.swift b/Sources/Curtain/CurtainIcon.swift new file mode 100644 index 0000000..58202f1 --- /dev/null +++ b/Sources/Curtain/CurtainIcon.swift @@ -0,0 +1,120 @@ +import Cocoa + +/// Purpose: Draw the Curtain logo (classic theater curtains) at any size, for both +/// the menu-bar item (monochrome template) and the app icon (full color). +/// Self-contained CoreGraphics so there are no image assets to ship. +/// SPORT: MASTER-ICON +enum CurtainIcon { + + /// A monochrome, template menu-bar image (adapts to light/dark menu bars). + static func menuBarImage() -> NSImage { + let size = NSSize(width: 18, height: 18) + let img = NSImage(size: size, flipped: false) { rect in + draw(in: rect, monochrome: true); return true + } + img.isTemplate = true + return img + } + + /// A full-color app icon at the given pixel size. + static func appIcon(size: CGFloat) -> NSImage { + NSImage(size: NSSize(width: size, height: size), flipped: false) { rect in + draw(in: rect, monochrome: false); return true + } + } + + /// Export a full .iconset folder of PNGs (for `iconutil` to turn into .icns). + static func exportIconset(to dir: String) { + let fm = FileManager.default + try? fm.createDirectory(atPath: dir, withIntermediateDirectories: true) + let specs: [(String, CGFloat)] = [ + ("icon_16x16", 16), ("icon_16x16@2x", 32), ("icon_32x32", 32), ("icon_32x32@2x", 64), + ("icon_128x128", 128), ("icon_128x128@2x", 256), ("icon_256x256", 256), + ("icon_256x256@2x", 512), ("icon_512x512", 512), ("icon_512x512@2x", 1024) + ] + for (name, px) in specs { + guard let png = pngData(size: px) else { continue } + try? png.write(to: URL(fileURLWithPath: "\(dir)/\(name).png")) + } + } + + /// Render the icon to PNG via an offscreen bitmap context (no running app / window server). + static func pngData(size: CGFloat) -> Data? { + let px = Int(size) + guard let rep = NSBitmapImageRep(bitmapDataPlanes: nil, pixelsWide: px, pixelsHigh: px, + bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, isPlanar: false, + colorSpaceName: .deviceRGB, bytesPerRow: 0, bitsPerPixel: 0), + let ctx = NSGraphicsContext(bitmapImageRep: rep) else { return nil } + NSGraphicsContext.saveGraphicsState() + NSGraphicsContext.current = ctx + draw(in: NSRect(x: 0, y: 0, width: size, height: size), monochrome: false) + ctx.flushGraphics() + NSGraphicsContext.restoreGraphicsState() + return rep.representation(using: .png, properties: [:]) + } + + // MARK: - Drawing + + private static func draw(in rect: NSRect, monochrome: Bool) { + guard let ctx = NSGraphicsContext.current?.cgContext else { return } + let w = rect.width, h = rect.height + + if !monochrome { + // Rounded dark "stage" background. + let bg = NSBezierPath(roundedRect: rect, xRadius: w * 0.22, yRadius: w * 0.22) + NSColor(red: 0.10, green: 0.07, blue: 0.09, alpha: 1).setFill(); bg.fill() + } + + let panelTop = h * 0.86 + let panelBottom = h * 0.10 + let rod = h * 0.90 + let panelColor = monochrome ? NSColor.black : NSColor(red: 0.74, green: 0.12, blue: 0.16, alpha: 1) + let foldColor = monochrome ? NSColor.black.withAlphaComponent(0.55) + : NSColor(red: 0.55, green: 0.07, blue: 0.10, alpha: 1) + + // Two curtain panels, slightly parted in the middle. + drawPanel(ctx, x0: w * 0.10, x1: w * 0.46, top: panelTop, bottom: panelBottom, + folds: 3, color: panelColor, fold: foldColor, mirrored: false) + drawPanel(ctx, x0: w * 0.54, x1: w * 0.90, top: panelTop, bottom: panelBottom, + folds: 3, color: panelColor, fold: foldColor, mirrored: true) + + // Curtain rod / valance across the top. + let rodColor = monochrome ? NSColor.black : NSColor(red: 0.85, green: 0.68, blue: 0.30, alpha: 1) + rodColor.setFill() + NSBezierPath(roundedRect: NSRect(x: w * 0.07, y: rod, width: w * 0.86, height: h * 0.055), + xRadius: h * 0.03, yRadius: h * 0.03).fill() + } + + private static func drawPanel(_ ctx: CGContext, x0: CGFloat, x1: CGFloat, top: CGFloat, bottom: CGFloat, + folds: Int, color: NSColor, fold: NSColor, mirrored: Bool) { + let width = x1 - x0 + // Panel body with a gently scalloped bottom hem. + let path = NSBezierPath() + path.move(to: NSPoint(x: x0, y: top)) + path.line(to: NSPoint(x: x0, y: bottom + (top - bottom) * 0.06)) + let segs = folds + for i in 0..