@bivy/bivy 0.17.3-staging.9 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,873 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ //
4
+ // A preview display for macOS apps. macOS has no private displays, so this
5
+ // serves one app's windows as a VNC display instead: the same protocol Xvnc
6
+ // speaks on Linux, on a private Unix socket, so Bivy's viewer, relay and
7
+ // screenshots work unchanged.
8
+ //
9
+ // macos-display check [--prompt] permissions, as JSON
10
+ // macos-display serve <socket> <control> <token> <scale>
11
+ // macos-display run -- <command> [args…] starts the app (see below)
12
+ //
13
+ // The app is found by its environment: Bivy starts it through `run` with
14
+ // BIVY_MAC_DISPLAY=<token>, and any process descending from a process that
15
+ // carries it is the app's. The launcher matters because signed apps with the
16
+ // hardened runtime hide their environment. Its largest window is the screen; other
17
+ // windows of the app (dialogs, menus) are drawn over it. Pictures come from
18
+ // ScreenCaptureKit (Screen Recording permission); clicks, keys and window
19
+ // sizing go through Accessibility. Keys are sent to the app only; clicks land
20
+ // only when the app's window is the one under the pointer.
21
+ //
22
+ // The control socket answers one JSON line per connection: {"menu":true}
23
+ // returns the app's menu bar, read through Accessibility; {"press":["File",
24
+ // "Save"]} presses the item at that path. The app's own full screen is
25
+ // switched off: the preview already sizes it to the viewer.
26
+ import AppKit
27
+ import ApplicationServices
28
+ import CoreMedia
29
+ import CoreVideo
30
+ @preconcurrency import ScreenCaptureKit
31
+ import zlib
32
+
33
+ setvbuf(stdout, nil, _IOLBF, 0)
34
+ signal(SIGPIPE, SIG_IGN)
35
+
36
+ func say(_ line: String) { print(line) }
37
+ func warn(_ line: String) { FileHandle.standardError.write(Data((line + "\n").utf8)) }
38
+
39
+ // MARK: - Permissions
40
+
41
+ func permissions(prompt: Bool) -> String {
42
+ if prompt {
43
+ _ = CGRequestScreenCaptureAccess()
44
+ _ = AXIsProcessTrustedWithOptions([kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String: true] as CFDictionary)
45
+ }
46
+ return "{\"screenRecording\":\(CGPreflightScreenCaptureAccess()),\"accessibility\":\(AXIsProcessTrusted())}"
47
+ }
48
+
49
+ // MARK: - Which windows are the app's
50
+
51
+ /// A process's environment, read like `ps -E` does (same user only).
52
+ func environment(_ pid: pid_t) -> [[UInt8]] {
53
+ var mib: [Int32] = [CTL_KERN, KERN_PROCARGS2, pid]
54
+ var size = 0
55
+ guard sysctl(&mib, 3, nil, &size, nil, 0) == 0, size > 4 else { return [] }
56
+ var buffer = [UInt8](repeating: 0, count: size)
57
+ guard sysctl(&mib, 3, &buffer, &size, nil, 0) == 0, size > 4 else { return [] }
58
+ let argc = Int(buffer.withUnsafeBytes { $0.load(as: Int32.self) })
59
+ var i = 4
60
+ while i < size && buffer[i] != 0 { i += 1 } // executable path
61
+ while i < size && buffer[i] == 0 { i += 1 } // padding
62
+ var strings: [[UInt8]] = []
63
+ while i < size {
64
+ let start = i
65
+ while i < size && buffer[i] != 0 { i += 1 }
66
+ if i == start { break }
67
+ strings.append(Array(buffer[start..<i]))
68
+ i += 1
69
+ }
70
+ return Array(strings.dropFirst(argc))
71
+ }
72
+
73
+ func parentOf(_ pid: pid_t) -> pid_t {
74
+ var info = proc_bsdinfo()
75
+ let size = Int32(MemoryLayout<proc_bsdinfo>.size)
76
+ return proc_pidinfo(pid, PROC_PIDTBSDINFO, 0, &info, size) == size ? pid_t(info.pbi_ppid) : 0
77
+ }
78
+
79
+ /// `run`: starts the app as a child and stays its parent, passing signals on
80
+ /// and exiting as it does, so the app's processes can be traced back to it.
81
+ func run(_ command: [String]) -> Never {
82
+ var child: pid_t = 0
83
+ let argv = command.map { strdup($0) } + [nil]
84
+ let status = posix_spawnp(&child, command[0], nil, nil, argv, environ)
85
+ guard status == 0 else { warn("\(command[0]): \(String(cString: strerror(status)))"); exit(127) }
86
+ for sig in [SIGTERM, SIGINT, SIGHUP, SIGQUIT] {
87
+ signal(sig, SIG_IGN)
88
+ let source = DispatchSource.makeSignalSource(signal: sig, queue: .main)
89
+ source.setEventHandler { kill(child, sig) }
90
+ source.resume()
91
+ forwarded.append(source)
92
+ }
93
+ DispatchQueue.global().async {
94
+ var result: Int32 = 0
95
+ while waitpid(child, &result, 0) < 0 && errno == EINTR {}
96
+ exit(result & 0x7f == 0 ? (result >> 8) & 0xff : 128 + (result & 0x7f))
97
+ }
98
+ dispatchMain()
99
+ }
100
+ nonisolated(unsafe) var forwarded: [DispatchSourceSignal] = []
101
+
102
+ struct Win { let id: CGWindowID; let pid: pid_t; let layer: Int; let bounds: CGRect }
103
+ /// Dock, main menu and status items: never part of an app's picture.
104
+ let SYSTEM_LAYERS: Set<Int> = [20, 24, 25]
105
+
106
+ /// On-screen windows, front to back, in global top-left coordinates (points).
107
+ func onScreenWindows() -> [Win] {
108
+ guard let list = CGWindowListCopyWindowInfo([.optionOnScreenOnly, .excludeDesktopElements], kCGNullWindowID) as? [[String: Any]] else { return [] }
109
+ return list.compactMap { info in
110
+ guard let id = info[kCGWindowNumber as String] as? CGWindowID, let pid = info[kCGWindowOwnerPID as String] as? pid_t,
111
+ let layer = info[kCGWindowLayer as String] as? Int, let dict = info[kCGWindowBounds as String] as? NSDictionary,
112
+ let bounds = CGRect(dictionaryRepresentation: dict as CFDictionary), bounds.width > 1, bounds.height > 1 else { return nil }
113
+ if let alpha = info[kCGWindowAlpha as String] as? Double, alpha <= 0 { return nil }
114
+ return Win(id: id, pid: pid, layer: layer, bounds: bounds)
115
+ }
116
+ }
117
+
118
+ /// The visible part of the screen holding `point` (no menu bar or Dock), in
119
+ /// global top-left coordinates.
120
+ func visibleArea(at point: CGPoint) -> CGRect? {
121
+ guard let primary = NSScreen.screens.first?.frame.height else { return nil }
122
+ for screen in NSScreen.screens {
123
+ let f = screen.frame, v = screen.visibleFrame
124
+ let frame = CGRect(x: f.minX, y: primary - f.maxY, width: f.width, height: f.height)
125
+ if frame.contains(point) { return CGRect(x: v.minX, y: primary - v.maxY, width: v.width, height: v.height) }
126
+ }
127
+ return nil
128
+ }
129
+
130
+ func axValue<T>(_ element: AXUIElement, _ attribute: String) -> T? {
131
+ var value: CFTypeRef?
132
+ guard AXUIElementCopyAttributeValue(element, attribute as CFString, &value) == .success else { return nil }
133
+ return value as? T
134
+ }
135
+ func axPoint(_ element: AXUIElement, _ attribute: String, _ type: AXValueType) -> CGRect? {
136
+ guard let value: AXValue = axValue(element, attribute) else { return nil }
137
+ if type == .cgPoint { var p = CGPoint.zero; AXValueGetValue(value, type, &p); return CGRect(origin: p, size: .zero) }
138
+ var s = CGSize.zero; AXValueGetValue(value, type, &s); return CGRect(origin: .zero, size: s)
139
+ }
140
+ /// The Accessibility element for a window, matched by its frame.
141
+ func axWindow(_ win: Win) -> AXUIElement? {
142
+ let app = AXUIElementCreateApplication(win.pid)
143
+ let windows: [AXUIElement] = axValue(app, kAXWindowsAttribute) ?? []
144
+ return windows.first { w in
145
+ guard let p = axPoint(w, kAXPositionAttribute, .cgPoint)?.origin, let s = axPoint(w, kAXSizeAttribute, .cgSize)?.size else { return false }
146
+ return abs(p.x - win.bounds.minX) < 2 && abs(p.y - win.bounds.minY) < 2 && abs(s.width - win.bounds.width) < 2 && abs(s.height - win.bounds.height) < 2
147
+ } ?? axValue(app, kAXMainWindowAttribute)
148
+ }
149
+
150
+ /// A listening Unix socket only this user can connect to.
151
+ func listening(_ path: String) -> Int32 {
152
+ unlink(path)
153
+ let fd = Darwin.socket(AF_UNIX, SOCK_STREAM, 0)
154
+ var addr = sockaddr_un()
155
+ addr.sun_family = sa_family_t(AF_UNIX)
156
+ withUnsafeMutableBytes(of: &addr.sun_path) { buf in _ = path.withCString { strncpy(buf.baseAddress!.assumingMemoryBound(to: CChar.self), $0, buf.count - 1) } }
157
+ let old = umask(0o177)
158
+ let bound = withUnsafePointer(to: &addr) { $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { Darwin.bind(fd, $0, socklen_t(MemoryLayout<sockaddr_un>.size)) } }
159
+ umask(old)
160
+ guard bound == 0, chmod(path, 0o600) == 0, Darwin.listen(fd, 16) == 0 else { warn("Couldn't listen on \(path)"); exit(1) }
161
+ return fd
162
+ }
163
+
164
+ // MARK: - Menus
165
+
166
+ /// Menus deeper than this, or past this many items, are left out.
167
+ let MENU_DEPTH = 4, MENU_ITEMS = 3000
168
+ /// How a menu item's shortcut modifiers are written (AXMenuItemCmdModifiers
169
+ /// bits: 1 Shift, 2 Option, 4 Control, 8 no Command).
170
+ let SHORTCUT_MODIFIERS: [(bit: Int, symbol: String)] = [(4, "⌃"), (2, "⌥"), (1, "⇧")]
171
+
172
+ func shortcut(_ char: String?, _ modifiers: Int?) -> String? {
173
+ guard let char, !char.isEmpty, let modifiers else { return nil }
174
+ let symbols = SHORTCUT_MODIFIERS.filter { modifiers & $0.bit != 0 }.map(\.symbol).joined()
175
+ return symbols + (modifiers & 8 == 0 ? "⌘" : "") + char.uppercased()
176
+ }
177
+
178
+ /// The app's menu bar as data: titles, enabled, checked, shortcut, submenus.
179
+ /// The Apple menu is left out; the app menu (Settings, Quit) stays.
180
+ func readMenus(_ pid: pid_t) -> [[String: Any]] {
181
+ let app = AXUIElementCreateApplication(pid)
182
+ AXUIElementSetMessagingTimeout(app, 1)
183
+ guard let bar: AXUIElement = axValue(app, kAXMenuBarAttribute) else { return [] }
184
+ var budget = MENU_ITEMS
185
+ let keys = [kAXTitleAttribute, kAXEnabledAttribute, "AXMenuItemCmdChar", "AXMenuItemCmdModifiers", "AXMenuItemMarkChar", kAXChildrenAttribute] as CFArray
186
+ func items(_ menu: AXUIElement, depth: Int) -> [[String: Any]] {
187
+ var out: [[String: Any]] = []
188
+ for item in (axValue(menu, kAXChildrenAttribute) as [AXUIElement]?) ?? [] {
189
+ guard budget > 0 else { break }
190
+ budget -= 1
191
+ var values: CFArray?
192
+ guard AXUIElementCopyMultipleAttributeValues(item, keys, AXCopyMultipleAttributeOptions(), &values) == .success, let v = values as? [Any] else { continue }
193
+ let title = (v[0] as? String) ?? ""
194
+ if title.isEmpty {
195
+ if let last = out.last, last["separator"] == nil { out.append(["separator": true]) }
196
+ continue
197
+ }
198
+ var entry: [String: Any] = ["title": title, "enabled": (v[1] as? Bool) ?? false]
199
+ if let keys = shortcut(v[2] as? String, v[3] as? Int) { entry["shortcut"] = keys }
200
+ if let mark = v[4] as? String, !mark.isEmpty { entry["checked"] = true }
201
+ if depth < MENU_DEPTH, let submenu = (v[5] as? [AXUIElement])?.first {
202
+ let children = items(submenu, depth: depth + 1)
203
+ if !children.isEmpty { entry["items"] = children }
204
+ }
205
+ out.append(entry)
206
+ }
207
+ if out.last?["separator"] != nil { out.removeLast() }
208
+ return out
209
+ }
210
+ let tops: [AXUIElement] = axValue(bar, kAXChildrenAttribute) ?? []
211
+ return tops.dropFirst().compactMap { top in
212
+ guard let title: String = axValue(top, kAXTitleAttribute), !title.isEmpty, let menu = (axValue(top, kAXChildrenAttribute) as [AXUIElement]?)?.first else { return nil }
213
+ return ["title": title, "items": items(menu, depth: 1)]
214
+ }
215
+ }
216
+
217
+ /// Presses the menu item at a path of titles, like choosing it from the menu bar.
218
+ func pressMenu(_ pid: pid_t, _ path: [String]) -> String? {
219
+ let app = AXUIElementCreateApplication(pid)
220
+ AXUIElementSetMessagingTimeout(app, 1)
221
+ guard var node: AXUIElement = axValue(app, kAXMenuBarAttribute) else { return "The app has no menu bar." }
222
+ for (depth, title) in path.enumerated() {
223
+ // Below the bar, each level's items sit in a menu element.
224
+ let parent = depth == 0 ? node : ((axValue(node, kAXChildrenAttribute) as [AXUIElement]?)?.first ?? node)
225
+ let children: [AXUIElement] = axValue(parent, kAXChildrenAttribute) ?? []
226
+ guard let next = children.dropFirst(depth == 0 ? 1 : 0).first(where: { (axValue($0, kAXTitleAttribute) as String?) == title }) else {
227
+ return "No menu item \"\(path[0...depth].joined(separator: " > "))\"."
228
+ }
229
+ node = next
230
+ }
231
+ if (axValue(node, kAXEnabledAttribute) as Bool?) == false { return "\"\(path.joined(separator: " > "))\" is disabled right now." }
232
+ AXUIElementSetAttributeValue(app, kAXFrontmostAttribute as CFString, kCFBooleanTrue)
233
+ return AXUIElementPerformAction(node, kAXPressAction as CFString) == .success ? nil : "The app didn't accept the menu item."
234
+ }
235
+
236
+ /// Switches the app's own full screen off, window by window. True if any was.
237
+ func leaveFullScreen(_ pid: pid_t) -> Bool {
238
+ let app = AXUIElementCreateApplication(pid)
239
+ AXUIElementSetMessagingTimeout(app, 1)
240
+ var left = false
241
+ for window in (axValue(app, kAXWindowsAttribute) as [AXUIElement]?) ?? [] where (axValue(window, "AXFullScreen") as Bool?) == true {
242
+ left = AXUIElementSetAttributeValue(window, "AXFullScreen" as CFString, kCFBooleanFalse) == .success || left
243
+ }
244
+ return left
245
+ }
246
+
247
+ // MARK: - Keys
248
+
249
+ /// US-layout key codes for the characters a keysym can name.
250
+ let KEYCODES: [Character: UInt16] = [
251
+ "a": 0x00, "s": 0x01, "d": 0x02, "f": 0x03, "h": 0x04, "g": 0x05, "z": 0x06, "x": 0x07, "c": 0x08, "v": 0x09,
252
+ "b": 0x0B, "q": 0x0C, "w": 0x0D, "e": 0x0E, "r": 0x0F, "y": 0x10, "t": 0x11, "1": 0x12, "2": 0x13, "3": 0x14,
253
+ "4": 0x15, "6": 0x16, "5": 0x17, "=": 0x18, "9": 0x19, "7": 0x1A, "-": 0x1B, "8": 0x1C, "0": 0x1D, "]": 0x1E,
254
+ "o": 0x1F, "u": 0x20, "[": 0x21, "i": 0x22, "p": 0x23, "l": 0x25, "j": 0x26, "'": 0x27, "k": 0x28, ";": 0x29,
255
+ "\\": 0x2A, ",": 0x2B, "/": 0x2C, "n": 0x2D, "m": 0x2E, ".": 0x2F, " ": 0x31, "`": 0x32,
256
+ ]
257
+ /// Shifted characters, by the key they share.
258
+ let SHIFTED: [Character: Character] = [
259
+ "!": "1", "@": "2", "#": "3", "$": "4", "%": "5", "^": "6", "&": "7", "*": "8", "(": "9", ")": "0",
260
+ "_": "-", "+": "=", "{": "[", "}": "]", "|": "\\", ":": ";", "\"": "'", "<": ",", ">": ".", "?": "/", "~": "`",
261
+ ]
262
+ /// X keysyms for keys without a character.
263
+ let SPECIAL: [UInt32: UInt16] = [
264
+ 0xff0d: 0x24, 0xff8d: 0x4C, 0xff09: 0x30, 0xff08: 0x33, 0xff1b: 0x35, 0xffff: 0x75, 0xff63: 0x72,
265
+ 0xff50: 0x73, 0xff57: 0x77, 0xff55: 0x74, 0xff56: 0x79, 0xff51: 0x7B, 0xff52: 0x7E, 0xff53: 0x7C, 0xff54: 0x7D,
266
+ 0xffbe: 0x7A, 0xffbf: 0x78, 0xffc0: 0x63, 0xffc1: 0x76, 0xffc2: 0x60, 0xffc3: 0x61, 0xffc4: 0x62, 0xffc5: 0x64,
267
+ 0xffc6: 0x65, 0xffc7: 0x6D, 0xffc8: 0x67, 0xffc9: 0x6F,
268
+ ]
269
+ /// Modifier keysyms: Control is Control, Alt/Meta is Option, Super (a Mac
270
+ /// keyboard's ⌘ in the browser) is Command.
271
+ let MODIFIERS: [UInt32: (code: UInt16, flag: CGEventFlags)] = [
272
+ 0xffe1: (0x38, .maskShift), 0xffe2: (0x3C, .maskShift), 0xffe3: (0x3B, .maskControl), 0xffe4: (0x3E, .maskControl),
273
+ 0xffe9: (0x3A, .maskAlternate), 0xffea: (0x3D, .maskAlternate), 0xffe7: (0x3A, .maskAlternate), 0xffe8: (0x3D, .maskAlternate),
274
+ 0xffeb: (0x37, .maskCommand), 0xffec: (0x36, .maskCommand),
275
+ ]
276
+
277
+ func character(_ keysym: UInt32) -> Character? {
278
+ let scalar = keysym >= 0x0100_0000 ? keysym - 0x0100_0000 : (keysym >= 0x20 && keysym <= 0xff ? keysym : 0)
279
+ guard scalar != 0, let u = Unicode.Scalar(scalar) else { return nil }
280
+ return Character(u)
281
+ }
282
+
283
+ // MARK: - Pixels
284
+
285
+ struct PixelFormat {
286
+ var bigEndian = false, red = 16, green = 8, blue = 0
287
+ /// Frames are BGRA; most viewers ask for exactly that or RGBX.
288
+ func convert(_ src: UnsafePointer<UInt8>, _ dst: UnsafeMutablePointer<UInt8>, _ count: Int) {
289
+ if !bigEndian && red == 16 && green == 8 && blue == 0 { dst.update(from: src, count: count * 4); return }
290
+ for i in 0..<count {
291
+ let b = UInt32(src[i * 4]), g = UInt32(src[i * 4 + 1]), r = UInt32(src[i * 4 + 2])
292
+ let v = r << UInt32(red) | g << UInt32(green) | b << UInt32(blue)
293
+ if bigEndian { dst[i * 4] = UInt8(v >> 24 & 255); dst[i * 4 + 1] = UInt8(v >> 16 & 255); dst[i * 4 + 2] = UInt8(v >> 8 & 255); dst[i * 4 + 3] = UInt8(v & 255) }
294
+ else { dst[i * 4] = UInt8(v & 255); dst[i * 4 + 1] = UInt8(v >> 8 & 255); dst[i * 4 + 2] = UInt8(v >> 16 & 255); dst[i * 4 + 3] = UInt8(v >> 24 & 255) }
295
+ }
296
+ }
297
+ }
298
+
299
+ let TILE = 64
300
+ let ENCODING = (raw: Int32(0), zlib: Int32(6), desktopSize: Int32(-223), extendedDesktopSize: Int32(-308))
301
+
302
+ func be16(_ v: Int) -> [UInt8] { [UInt8(v >> 8 & 255), UInt8(v & 255)] }
303
+ func be32(_ v: Int32) -> [UInt8] { let u = UInt32(bitPattern: v); return [UInt8(u >> 24), UInt8(u >> 16 & 255), UInt8(u >> 8 & 255), UInt8(u & 255)] }
304
+
305
+ // MARK: - A connected viewer
306
+
307
+ final class Client {
308
+ let fd: Int32
309
+ var inbox: [UInt8] = []
310
+ var outbox: [UInt8] = []
311
+ /// Bytes of `outbox` already written; it is cleared once all are.
312
+ var sent = 0
313
+ var backlog: Int { outbox.count - sent }
314
+ var stage = 0 // 0 version, 1 security choice, 2 client init, 3 running
315
+ var format = PixelFormat()
316
+ var encodings: Set<Int32> = []
317
+ var wantsUpdate = false
318
+ var full = true
319
+ var sizeChanged = true
320
+ var resizeReply: Int? // status for this client's own resize request
321
+ var dirty: [Bool] = []
322
+ var reader: DispatchSourceRead?
323
+ var writer: DispatchSourceWrite?
324
+ var writing = false
325
+ private var zs: UnsafeMutablePointer<z_stream>?
326
+ init(fd: Int32) { self.fd = fd }
327
+
328
+ func deflated(_ input: inout [UInt8]) -> [UInt8] {
329
+ if zs == nil {
330
+ zs = UnsafeMutablePointer<z_stream>.allocate(capacity: 1)
331
+ zs!.initialize(to: z_stream())
332
+ deflateInit_(zs!, 3, ZLIB_VERSION, Int32(MemoryLayout<z_stream>.size))
333
+ }
334
+ var out = [UInt8](repeating: 0, count: Int(deflateBound(zs!, UInt(input.count))) + 64)
335
+ var produced = 0
336
+ input.withUnsafeMutableBufferPointer { inp in
337
+ zs!.pointee.next_in = inp.baseAddress; zs!.pointee.avail_in = UInt32(inp.count)
338
+ repeat {
339
+ if produced == out.count { out.append(contentsOf: [UInt8](repeating: 0, count: out.count)) }
340
+ out.withUnsafeMutableBufferPointer { o in
341
+ zs!.pointee.next_out = o.baseAddress! + produced; zs!.pointee.avail_out = UInt32(o.count - produced)
342
+ deflate(zs!, Z_SYNC_FLUSH)
343
+ produced = o.count - Int(zs!.pointee.avail_out)
344
+ }
345
+ } while zs!.pointee.avail_in > 0 || zs!.pointee.avail_out == 0
346
+ }
347
+ return Array(out[0..<produced])
348
+ }
349
+ func close() {
350
+ reader?.cancel(); writer?.cancel()
351
+ if let zs { deflateEnd(zs); zs.deallocate(); self.zs = nil }
352
+ }
353
+ }
354
+
355
+ // MARK: - The display
356
+
357
+ final class Host: NSObject, SCStreamOutput, SCStreamDelegate, @unchecked Sendable {
358
+ let q = DispatchQueue(label: "sh.bivy.macos-display")
359
+ let marker: [UInt8]
360
+ let scale: CGFloat
361
+ var known: [pid_t: Bool] = [:]
362
+ var ours: [Win] = []
363
+ var main: Win?
364
+ var region = CGRect.zero
365
+ var viewer: CGSize?
366
+ var fitted: CGWindowID?
367
+ var count = -1
368
+ var width: Int, height: Int
369
+ var frame: [UInt8]
370
+ var clients: [Client] = []
371
+ var mask: UInt8 = 0
372
+ var flags: CGEventFlags = []
373
+ var lastClick = (at: 0.0, point: CGPoint.zero, count: 0)
374
+ var pasteboard = NSPasteboard.general.changeCount
375
+ var stream: SCStream?
376
+ var configuring = false
377
+ var wanted: (ids: [CGWindowID], region: CGRect)?
378
+ var captured: (ids: [CGWindowID], region: CGRect)?
379
+ var acceptor: DispatchSourceRead?
380
+ /// Menus and full screen are Accessibility round trips to the app: off the
381
+ /// frame queue, one at a time.
382
+ let ax = DispatchQueue(label: "sh.bivy.macos-display.ax")
383
+ var control: DispatchSourceRead?
384
+ /// The app's processes, including ones whose windows aren't on screen now
385
+ /// (a full-screen window in another Space).
386
+ var apps = Set<pid_t>()
387
+ /// The last capture error, and when: retried after a pause, reported once.
388
+ var failure = (message: "", at: 0.0)
389
+
390
+ init(token: String, scale: CGFloat) {
391
+ marker = Array("BIVY_MAC_DISPLAY=\(token)".utf8)
392
+ self.scale = scale
393
+ width = Int(1280 * scale); height = Int(800 * scale)
394
+ frame = [UInt8](repeating: 0, count: width * height * 4)
395
+ }
396
+
397
+ func owns(_ pid: pid_t) -> Bool {
398
+ if let hit = known[pid] { return hit }
399
+ // It carries the token, or its parent is the app's (the launcher at the top).
400
+ let parent = parentOf(pid)
401
+ let hit = environment(pid).contains(marker) || (parent > 1 && parent != pid && owns(parent))
402
+ known[pid] = hit
403
+ return hit
404
+ }
405
+
406
+ /// Follows the app's windows: which is the screen, where it is, and what to capture.
407
+ func tick() {
408
+ if getppid() == 1 { exit(0) } // Bivy went away
409
+ let all = onScreenWindows()
410
+ known = known.filter { entry in all.contains { $0.pid == entry.key } }
411
+ ours = all.filter { !SYSTEM_LAYERS.contains($0.layer) && owns($0.pid) }
412
+ apps.formUnion(ours.map(\.pid))
413
+ let normal = ours.filter { $0.layer == 0 }
414
+ if normal.count != count { count = normal.count; say("windows \(count)") }
415
+ main = normal.max { $0.bounds.width * $0.bounds.height < $1.bounds.width * $1.bounds.height }
416
+ if let main, main.id != fitted { fitted = main.id; fit() }
417
+ guard let main else { return }
418
+ region = main.bounds.integral
419
+ let ids = ours.filter { $0.bounds.intersects(region) }.map(\.id)
420
+ let retry = Date().timeIntervalSince1970 - failure.at > 2
421
+ if retry, captured?.ids != ids || captured?.region != region { capture(ids: ids, region: region) }
422
+ clipboard()
423
+ }
424
+
425
+ // MARK: Capture
426
+
427
+ func capture(ids: [CGWindowID], region: CGRect) {
428
+ wanted = (ids, region)
429
+ guard !configuring else { return }
430
+ configuring = true
431
+ captured = wanted
432
+ let scale = self.scale
433
+ Task {
434
+ do {
435
+ let content = try await SCShareableContent.excludingDesktopWindows(false, onScreenWindowsOnly: true)
436
+ let windows = content.windows.filter { ids.contains($0.windowID) }
437
+ let center = CGPoint(x: region.midX, y: region.midY)
438
+ guard let display = content.displays.first(where: { CGDisplayBounds($0.displayID).contains(center) }) ?? content.displays.first else { throw CocoaError(.featureUnsupported) }
439
+ let bounds = CGDisplayBounds(display.displayID)
440
+ let config = SCStreamConfiguration()
441
+ config.sourceRect = region.offsetBy(dx: -bounds.minX, dy: -bounds.minY)
442
+ config.width = min(8192, max(1, Int(region.width * scale)))
443
+ config.height = min(8192, max(1, Int(region.height * scale)))
444
+ config.pixelFormat = kCVPixelFormatType_32BGRA
445
+ config.showsCursor = false
446
+ config.minimumFrameInterval = CMTime(value: 1, timescale: 30)
447
+ config.queueDepth = 4
448
+ let filter = SCContentFilter(display: display, including: windows)
449
+ if let stream = self.stream {
450
+ try await stream.updateContentFilter(filter)
451
+ try await stream.updateConfiguration(config)
452
+ } else {
453
+ let stream = SCStream(filter: filter, configuration: config, delegate: self)
454
+ try stream.addStreamOutput(self, type: .screen, sampleHandlerQueue: self.q)
455
+ try await stream.startCapture()
456
+ self.q.async { self.stream = stream }
457
+ }
458
+ } catch {
459
+ let message = error.localizedDescription
460
+ self.q.async {
461
+ if message != self.failure.message { warn("capture: \(message)") }
462
+ self.failure = (message, Date().timeIntervalSince1970); self.captured = nil
463
+ }
464
+ }
465
+ self.q.async {
466
+ self.configuring = false
467
+ if let wanted = self.wanted, self.captured?.ids != wanted.ids || self.captured?.region != wanted.region { self.capture(ids: wanted.ids, region: wanted.region) }
468
+ }
469
+ }
470
+ }
471
+ func stream(_ stream: SCStream, didStopWithError error: Error) {
472
+ warn("capture stopped: \(error.localizedDescription)")
473
+ q.async { if self.stream === stream { self.stream = nil; self.captured = nil } }
474
+ }
475
+
476
+ func stream(_ stream: SCStream, didOutputSampleBuffer sample: CMSampleBuffer, of type: SCStreamOutputType) {
477
+ guard type == .screen,
478
+ let info = CMSampleBufferGetSampleAttachmentsArray(sample, createIfNecessary: false) as? [[SCStreamFrameInfo: Any]],
479
+ let raw = info.first?[.status] as? Int, SCFrameStatus(rawValue: raw) == .complete,
480
+ let pixels = CMSampleBufferGetImageBuffer(sample) else { return }
481
+ CVPixelBufferLockBaseAddress(pixels, .readOnly)
482
+ defer { CVPixelBufferUnlockBaseAddress(pixels, .readOnly) }
483
+ guard let base = CVPixelBufferGetBaseAddress(pixels)?.assumingMemoryBound(to: UInt8.self) else { return }
484
+ let w = CVPixelBufferGetWidth(pixels), h = CVPixelBufferGetHeight(pixels), stride = CVPixelBufferGetBytesPerRow(pixels)
485
+ let resized = w != width || h != height
486
+ if resized {
487
+ width = w; height = h
488
+ frame = [UInt8](repeating: 0, count: w * h * 4)
489
+ for client in clients { client.sizeChanged = true; client.full = true }
490
+ }
491
+ let columns = (w + TILE - 1) / TILE, rows = (h + TILE - 1) / TILE
492
+ var changed = [Bool](repeating: resized, count: columns * rows)
493
+ frame.withUnsafeMutableBufferPointer { fb in
494
+ for y in 0..<h {
495
+ let src = base + y * stride, dst = fb.baseAddress! + y * w * 4
496
+ for column in 0..<columns where !changed[(y / TILE) * columns + column] {
497
+ let x = column * TILE, n = min(TILE, w - x) * 4
498
+ if memcmp(src + x * 4, dst + x * 4, n) != 0 { changed[(y / TILE) * columns + column] = true }
499
+ }
500
+ dst.update(from: src, count: w * 4)
501
+ }
502
+ }
503
+ for client in clients {
504
+ if client.dirty.count != changed.count { client.dirty = changed; client.full = true }
505
+ else { for i in changed.indices where changed[i] { client.dirty[i] = true } }
506
+ update(client)
507
+ }
508
+ }
509
+
510
+ // MARK: Serving viewers
511
+
512
+ func listen(path: String) {
513
+ let fd = listening(path)
514
+ let accept = DispatchSource.makeReadSource(fileDescriptor: fd, queue: q)
515
+ accept.setEventHandler { [self] in
516
+ let peer = Darwin.accept(fd, nil, nil)
517
+ guard peer >= 0 else { return }
518
+ _ = fcntl(peer, F_SETFL, fcntl(peer, F_GETFL) | O_NONBLOCK)
519
+ var on: Int32 = 1
520
+ setsockopt(peer, SOL_SOCKET, SO_NOSIGPIPE, &on, socklen_t(MemoryLayout<Int32>.size))
521
+ let client = Client(fd: peer)
522
+ clients.append(client)
523
+ let reader = DispatchSource.makeReadSource(fileDescriptor: peer, queue: q)
524
+ reader.setEventHandler { [self] in receive(client) }
525
+ reader.setCancelHandler { Darwin.close(peer) }
526
+ client.reader = reader
527
+ reader.resume()
528
+ send(client, Array("RFB 003.008\n".utf8))
529
+ }
530
+ accept.resume()
531
+ acceptor = accept
532
+ }
533
+
534
+ /// The control socket: one request line, one JSON answer, then closed.
535
+ func serveControl(path: String) {
536
+ let fd = listening(path)
537
+ let accept = DispatchSource.makeReadSource(fileDescriptor: fd, queue: ax)
538
+ accept.setEventHandler { [self] in
539
+ let peer = Darwin.accept(fd, nil, nil)
540
+ guard peer >= 0 else { return }
541
+ defer { Darwin.close(peer) }
542
+ var wait = timeval(tv_sec: 5, tv_usec: 0), on: Int32 = 1
543
+ setsockopt(peer, SOL_SOCKET, SO_RCVTIMEO, &wait, socklen_t(MemoryLayout<timeval>.size))
544
+ setsockopt(peer, SOL_SOCKET, SO_NOSIGPIPE, &on, socklen_t(MemoryLayout<Int32>.size))
545
+ var line: [UInt8] = []
546
+ var byte: UInt8 = 0
547
+ while line.count < 16384, Darwin.read(peer, &byte, 1) == 1, byte != 10 { line.append(byte) }
548
+ let answer = respond(to: (try? JSONSerialization.jsonObject(with: Data(line))) as? [String: Any] ?? [:])
549
+ let data = ((try? JSONSerialization.data(withJSONObject: answer)) ?? Data()) + Data([10])
550
+ _ = data.withUnsafeBytes { Darwin.write(peer, $0.baseAddress, $0.count) }
551
+ }
552
+ accept.resume()
553
+ control = accept
554
+ // Full screen is checked here too, so it never holds up frames.
555
+ let timer = DispatchSource.makeTimerSource(queue: ax)
556
+ timer.schedule(deadline: .now() + 1, repeating: .seconds(1))
557
+ timer.setEventHandler { [self] in
558
+ let pids = q.sync { apps = apps.filter { kill($0, 0) == 0 }; return apps }
559
+ guard pids.contains(where: leaveFullScreen) else { return }
560
+ // Once it has animated back, fit it to the viewer again.
561
+ q.asyncAfter(deadline: .now() + 1) { self.fitted = nil }
562
+ }
563
+ timer.resume()
564
+ fullScreenTimer = timer
565
+ }
566
+ var fullScreenTimer: DispatchSourceTimer?
567
+
568
+ func respond(to request: [String: Any]) -> [String: Any] {
569
+ guard let pid = q.sync(execute: { main?.pid }) else { return ["error": "The app has no window yet."] }
570
+ if request["menu"] as? Bool == true { return ["menus": readMenus(pid)] }
571
+ if let path = request["press"] as? [String], (1...8).contains(path.count) {
572
+ if let error = pressMenu(pid, path) { return ["error": error] }
573
+ return ["ok": true]
574
+ }
575
+ return ["error": "Unknown request."]
576
+ }
577
+
578
+ func drop(_ client: Client) {
579
+ guard let index = clients.firstIndex(where: { $0 === client }) else { return }
580
+ clients.remove(at: index)
581
+ client.close()
582
+ }
583
+
584
+ func send(_ client: Client, _ bytes: [UInt8]) {
585
+ client.outbox.append(contentsOf: bytes)
586
+ flush(client)
587
+ }
588
+ func flush(_ client: Client) {
589
+ while client.backlog > 0 {
590
+ let n = client.outbox.withUnsafeBytes { Darwin.write(client.fd, $0.baseAddress! + client.sent, client.backlog) }
591
+ if n > 0 { client.sent += n; continue }
592
+ if n < 0 && (errno == EAGAIN || errno == EINTR) { break }
593
+ drop(client); return
594
+ }
595
+ if client.backlog == 0 {
596
+ client.outbox.removeAll(keepingCapacity: true); client.sent = 0
597
+ if client.writing { client.writer?.suspend(); client.writing = false }
598
+ update(client)
599
+ } else if !client.writing {
600
+ if client.writer == nil {
601
+ let writer = DispatchSource.makeWriteSource(fileDescriptor: client.fd, queue: q)
602
+ writer.setEventHandler { [self] in flush(client) }
603
+ client.writer = writer
604
+ }
605
+ client.writer!.resume(); client.writing = true
606
+ }
607
+ }
608
+
609
+ func receive(_ client: Client) {
610
+ var chunk = [UInt8](repeating: 0, count: 65536)
611
+ let n = chunk.withUnsafeMutableBytes { Darwin.read(client.fd, $0.baseAddress, $0.count) }
612
+ if n <= 0 { if n == 0 || (errno != EAGAIN && errno != EINTR) { drop(client) }; return }
613
+ client.inbox.append(contentsOf: chunk[0..<n])
614
+ while clients.contains(where: { $0 === client }), let used = handle(client, client.inbox), used > 0 { client.inbox.removeFirst(used) }
615
+ }
616
+
617
+ /// One message from a viewer: the bytes it used, or nil until more arrive.
618
+ func handle(_ client: Client, _ m: [UInt8]) -> Int? {
619
+ func u16(_ i: Int) -> Int { Int(m[i]) << 8 | Int(m[i + 1]) }
620
+ func u32(_ i: Int) -> UInt32 { UInt32(m[i]) << 24 | UInt32(m[i + 1]) << 16 | UInt32(m[i + 2]) << 8 | UInt32(m[i + 3]) }
621
+ switch client.stage {
622
+ case 0:
623
+ guard m.count >= 12 else { return nil }
624
+ guard String(decoding: m[0..<12], as: UTF8.self).hasPrefix("RFB 003.") else { drop(client); return nil }
625
+ if u16(8) == 0x3030 && m[10] == 0x33 { send(client, be32(1)); client.stage = 2 } // 3.3: the server picks None
626
+ else { send(client, [1, 1]); client.stage = 1 }
627
+ return 12
628
+ case 1:
629
+ guard m.count >= 1 else { return nil }
630
+ guard m[0] == 1 else { drop(client); return nil }
631
+ send(client, be32(0)); client.stage = 2
632
+ return 1
633
+ case 2:
634
+ guard m.count >= 1 else { return nil }
635
+ let name = Array("Bivy".utf8)
636
+ send(client, be16(width) + be16(height) + [32, 24, 0, 1] + be16(255) + be16(255) + be16(255) + [16, 8, 0, 0, 0, 0] + be32(Int32(name.count)) + name)
637
+ client.stage = 3
638
+ return 1
639
+ default: break
640
+ }
641
+ guard let type = m.first else { return nil }
642
+ switch type {
643
+ case 0: // SetPixelFormat
644
+ guard m.count >= 20 else { return nil }
645
+ guard m[4] == 32, m[7] != 0 else { drop(client); return nil }
646
+ client.format = PixelFormat(bigEndian: m[6] != 0, red: Int(m[14]), green: Int(m[15]), blue: Int(m[16]))
647
+ client.full = true
648
+ return 20
649
+ case 2: // SetEncodings
650
+ guard m.count >= 4 else { return nil }
651
+ let n = u16(2)
652
+ guard m.count >= 4 + n * 4 else { return nil }
653
+ client.encodings = Set((0..<n).map { Int32(bitPattern: u32(4 + $0 * 4)) })
654
+ return 4 + n * 4
655
+ case 3: // FramebufferUpdateRequest
656
+ guard m.count >= 10 else { return nil }
657
+ client.wantsUpdate = true
658
+ if m[1] == 0 { client.full = true }
659
+ update(client)
660
+ return 10
661
+ case 4: // KeyEvent
662
+ guard m.count >= 8 else { return nil }
663
+ key(down: m[1] != 0, keysym: u32(4))
664
+ return 8
665
+ case 5: // PointerEvent
666
+ guard m.count >= 6 else { return nil }
667
+ pointer(mask: m[1], x: u16(2), y: u16(4))
668
+ return 6
669
+ case 6: // ClientCutText
670
+ guard m.count >= 8 else { return nil }
671
+ let length = Int(Int32(bitPattern: u32(4)))
672
+ guard length >= 0, length < 1 << 20 else { drop(client); return nil }
673
+ guard m.count >= 8 + length else { return nil }
674
+ let text = String(m[8..<8 + length].map { Character(Unicode.Scalar($0)) })
675
+ NSPasteboard.general.clearContents()
676
+ NSPasteboard.general.setString(text, forType: .string)
677
+ pasteboard = NSPasteboard.general.changeCount
678
+ return 8 + length
679
+ case 251: // SetDesktopSize
680
+ guard m.count >= 8 else { return nil }
681
+ let screens = Int(m[6])
682
+ guard m.count >= 8 + screens * 16 else { return nil }
683
+ viewer = CGSize(width: CGFloat(u16(2)) / scale, height: CGFloat(u16(4)) / scale)
684
+ client.resizeReply = main == nil ? 1 : 0
685
+ fit()
686
+ update(client)
687
+ return 8 + screens * 16
688
+ default:
689
+ drop(client); return nil
690
+ }
691
+ }
692
+
693
+ /// Sends what changed, if the viewer asked and has read what it was sent.
694
+ func update(_ client: Client) {
695
+ guard client.stage == 3, client.wantsUpdate, client.backlog < 1 << 20 else { return }
696
+ let columns = (width + TILE - 1) / TILE, rows = (height + TILE - 1) / TILE
697
+ if client.dirty.count != columns * rows { client.dirty = [Bool](repeating: true, count: columns * rows); client.full = true }
698
+ let resize = client.sizeChanged || client.resizeReply != nil
699
+ guard resize || client.full || client.dirty.contains(true) else { return }
700
+ var rects: [[UInt8]] = []
701
+ if resize {
702
+ if client.encodings.contains(ENCODING.extendedDesktopSize) {
703
+ let reason = client.resizeReply == nil ? 0 : 1
704
+ rects.append(be16(reason) + be16(client.resizeReply ?? 0) + be16(width) + be16(height) + be32(ENCODING.extendedDesktopSize)
705
+ + [1, 0, 0, 0] + be32(0) + be16(0) + be16(0) + be16(width) + be16(height) + be32(0))
706
+ } else if client.sizeChanged && client.encodings.contains(ENCODING.desktopSize) {
707
+ rects.append(be16(0) + be16(0) + be16(width) + be16(height) + be32(ENCODING.desktopSize))
708
+ }
709
+ if client.sizeChanged { client.full = true }
710
+ client.sizeChanged = false; client.resizeReply = nil
711
+ }
712
+ if client.full { for i in client.dirty.indices { client.dirty[i] = true } }
713
+ // Changed tiles, merged into runs along each row of tiles.
714
+ for row in 0..<rows {
715
+ var column = 0
716
+ while column < columns {
717
+ guard client.dirty[row * columns + column] else { column += 1; continue }
718
+ let start = column
719
+ while column < columns && client.dirty[row * columns + column] { client.dirty[row * columns + column] = false; column += 1 }
720
+ let x = start * TILE, y = row * TILE, w = min(column * TILE, width) - x, h = min(TILE, height - y)
721
+ rects.append(rectangle(client, x: x, y: y, w: w, h: h))
722
+ }
723
+ }
724
+ client.full = false
725
+ client.wantsUpdate = false
726
+ send(client, [0, 0] + be16(rects.count) + rects.flatMap { $0 })
727
+ }
728
+
729
+ func rectangle(_ client: Client, x: Int, y: Int, w: Int, h: Int) -> [UInt8] {
730
+ var pixels = [UInt8](repeating: 0, count: w * h * 4)
731
+ frame.withUnsafeBufferPointer { fb in
732
+ pixels.withUnsafeMutableBufferPointer { out in
733
+ for row in 0..<h { client.format.convert(fb.baseAddress! + ((y + row) * width + x) * 4, out.baseAddress! + row * w * 4, w) }
734
+ }
735
+ }
736
+ let head = be16(x) + be16(y) + be16(w) + be16(h)
737
+ guard client.encodings.contains(ENCODING.zlib) else { return head + be32(ENCODING.raw) + pixels }
738
+ let data = client.deflated(&pixels)
739
+ return head + be32(ENCODING.zlib) + be32(Int32(data.count)) + data
740
+ }
741
+
742
+ /// Text the app copied goes to viewers (as Latin-1, like any VNC server).
743
+ func clipboard() {
744
+ let board = NSPasteboard.general
745
+ guard board.changeCount != pasteboard else { return }
746
+ pasteboard = board.changeCount
747
+ guard frontmost(), let text = board.string(forType: .string) else { return }
748
+ let bytes = text.unicodeScalars.map { $0.value < 256 ? UInt8($0.value) : UInt8(ascii: "?") }
749
+ for client in clients where client.stage == 3 { send(client, [3, 0, 0, 0] + be32(Int32(bytes.count)) + bytes) }
750
+ }
751
+
752
+ // MARK: Windows and input
753
+
754
+ func frontmost() -> Bool {
755
+ guard let pid = NSWorkspace.shared.frontmostApplication?.processIdentifier else { return false }
756
+ return owns(pid)
757
+ }
758
+ /// Brings the app forward so its windows take clicks and keys.
759
+ func raise() {
760
+ guard let main, !frontmost() else { return }
761
+ AXUIElementSetAttributeValue(AXUIElementCreateApplication(main.pid), kAXFrontmostAttribute as CFString, kCFBooleanTrue)
762
+ if let window = axWindow(main) { AXUIElementPerformAction(window, kAXRaiseAction as CFString) }
763
+ for _ in 0..<10 where !frontmost() { usleep(30_000) }
764
+ }
765
+ /// Whether the app's window is what a click at `point` would hit.
766
+ func reaches(_ point: CGPoint) -> Bool {
767
+ let top = onScreenWindows().first { $0.bounds.contains(point) && ($0.layer >= 0 && $0.layer < 20 || owns($0.pid)) && !SYSTEM_LAYERS.contains($0.layer) }
768
+ return top.map { owns($0.pid) } ?? false
769
+ }
770
+
771
+ /// The main window takes the viewer's size (within the visible screen),
772
+ /// moved to its top-left corner. Apps with a minimum size stay larger and
773
+ /// the viewer scales them down.
774
+ func fit() {
775
+ guard let main, let viewer, let area = visibleArea(at: CGPoint(x: main.bounds.midX, y: main.bounds.midY)), let window = axWindow(main) else { return }
776
+ var origin = area.origin, size = CGSize(width: min(viewer.width, area.width), height: min(viewer.height, area.height))
777
+ if let value = AXValueCreate(.cgPoint, &origin) { AXUIElementSetAttributeValue(window, kAXPositionAttribute as CFString, value) }
778
+ if let value = AXValueCreate(.cgSize, &size) { AXUIElementSetAttributeValue(window, kAXSizeAttribute as CFString, value) }
779
+ }
780
+
781
+ func pointer(mask: UInt8, x: Int, y: Int) {
782
+ let changed = mask ^ self.mask
783
+ defer { self.mask = mask }
784
+ guard main != nil else { return }
785
+ let point = CGPoint(x: region.minX + CGFloat(x) / scale, y: region.minY + CGFloat(y) / scale)
786
+ let buttons: [(bit: UInt8, button: CGMouseButton, down: CGEventType, up: CGEventType, drag: CGEventType)] = [
787
+ (1, .left, .leftMouseDown, .leftMouseUp, .leftMouseDragged), (2, .center, .otherMouseDown, .otherMouseUp, .otherMouseDragged),
788
+ (4, .right, .rightMouseDown, .rightMouseUp, .rightMouseDragged),
789
+ ]
790
+ let held = buttons.first { mask & $0.bit != 0 && changed & $0.bit == 0 }
791
+ if changed & 7 == 0 {
792
+ // Hover only over the app, so the Mac's own pointer isn't pulled around.
793
+ if held != nil || reaches(point) { post(mouse: held?.drag ?? .mouseMoved, at: point, button: held?.button ?? .left) }
794
+ }
795
+ for b in buttons where changed & b.bit != 0 {
796
+ let down = mask & b.bit != 0
797
+ if down {
798
+ if !reaches(point) { raise() }
799
+ guard reaches(point) else { continue } // something else is on top: never click it
800
+ let now = Date().timeIntervalSince1970
801
+ let again = now - lastClick.at < NSEvent.doubleClickInterval && hypot(point.x - lastClick.point.x, point.y - lastClick.point.y) < 4
802
+ lastClick = (now, point, again ? lastClick.count + 1 : 1)
803
+ }
804
+ post(mouse: down ? b.down : b.up, at: point, button: b.button, clicks: lastClick.count)
805
+ }
806
+ // Wheel "buttons" 4–7 are pressed once per step.
807
+ for (bit, dx, dy) in [(UInt8(8), 0, 1), (16, 0, -1), (32, 1, 0), (64, -1, 0)] where changed & bit != 0 && mask & bit != 0 && reaches(point) {
808
+ guard let event = CGEvent(scrollWheelEvent2Source: nil, units: .pixel, wheelCount: 2, wheel1: Int32(dy * 50), wheel2: Int32(dx * 50), wheel3: 0) else { continue }
809
+ event.location = point
810
+ event.post(tap: .cghidEventTap)
811
+ }
812
+ }
813
+ func post(mouse type: CGEventType, at point: CGPoint, button: CGMouseButton, clicks: Int = 0) {
814
+ guard let event = CGEvent(mouseEventSource: nil, mouseType: type, mouseCursorPosition: point, mouseButton: button) else { return }
815
+ if clicks > 0 { event.setIntegerValueField(.mouseEventClickState, value: Int64(clicks)) }
816
+ event.flags = flags
817
+ event.post(tap: .cghidEventTap)
818
+ }
819
+
820
+ /// Keys go to the app's process only, never to whatever else has focus.
821
+ func key(down: Bool, keysym: UInt32) {
822
+ guard let main else { return }
823
+ if down { raise() }
824
+ if let modifier = MODIFIERS[keysym] {
825
+ if down { flags.insert(modifier.flag) } else { flags.remove(modifier.flag) }
826
+ guard let event = CGEvent(keyboardEventSource: nil, virtualKey: modifier.code, keyDown: down) else { return }
827
+ event.type = .flagsChanged
828
+ event.flags = flags
829
+ event.postToPid(main.pid)
830
+ return
831
+ }
832
+ var code = SPECIAL[keysym]
833
+ var shift = false
834
+ let char = code == nil ? character(keysym) : nil
835
+ if let char {
836
+ let lower = Character(char.lowercased())
837
+ if let base = SHIFTED[char] { code = KEYCODES[base]; shift = true }
838
+ else { code = KEYCODES[lower]; shift = char != lower }
839
+ }
840
+ guard code != nil || char != nil, let event = CGEvent(keyboardEventSource: nil, virtualKey: code ?? 0, keyDown: down) else { return }
841
+ event.flags = shift ? flags.union(.maskShift) : flags
842
+ // Plain typing carries the character itself, so any keyboard layout types
843
+ // what was meant; shortcuts go by key.
844
+ if let char, flags.isDisjoint(with: [.maskCommand, .maskControl]) {
845
+ let units = Array(String(char).utf16)
846
+ event.keyboardSetUnicodeString(stringLength: units.count, unicodeString: units)
847
+ }
848
+ event.postToPid(main.pid)
849
+ }
850
+ }
851
+
852
+ // MARK: - Entry
853
+
854
+ let args = CommandLine.arguments
855
+ switch args.count > 1 ? args[1] : "" {
856
+ case "run" where args.count > 3 && args[2] == "--":
857
+ run(Array(args[3...]))
858
+ case "check":
859
+ say(permissions(prompt: args.contains("--prompt")))
860
+ case "serve" where args.count == 6:
861
+ let host = Host(token: args[4], scale: CGFloat(Double(args[5]) == 2 ? 2 : 1))
862
+ host.q.sync { host.listen(path: args[2]) }
863
+ host.serveControl(path: args[3])
864
+ let timer = DispatchSource.makeTimerSource(queue: host.q)
865
+ timer.schedule(deadline: .now(), repeating: .milliseconds(200))
866
+ timer.setEventHandler { host.tick() }
867
+ timer.resume()
868
+ say("ready")
869
+ RunLoop.main.run()
870
+ default:
871
+ warn("Usage: macos-display check [--prompt] | serve <socket> <control> <token> <scale> | run -- <command> [args…]")
872
+ exit(2)
873
+ }