@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.
- package/bin/bivy.mjs +157 -12
- package/dist/apps/annotate.js +94 -0
- package/dist/apps/display-viewer.js +80 -7
- package/dist/apps/display.js +11 -1
- package/dist/apps/gateway.js +106 -14
- package/dist/apps/input.js +96 -0
- package/dist/apps/inspector.js +84 -29
- package/dist/apps/macos-display.js +170 -0
- package/dist/apps/macos-display.swift +873 -0
- package/dist/apps/menu.js +76 -0
- package/dist/apps/preview-shell.js +232 -16
- package/dist/apps/preview-stream.js +17 -3
- package/dist/apps/registry.js +76 -5
- package/dist/apps/review.js +111 -0
- package/dist/apps/rfb.js +69 -20
- package/dist/apps/screenshot.js +8 -2
- package/dist/apps/service.js +343 -13
- package/dist/apps/styles.css +162 -10
- package/dist/apps/tokens.css +6 -0
- package/dist/apps/types.js +17 -0
- package/dist/attach.js +37 -4
- package/dist/controllers/app-commands.js +16 -4
- package/dist/controllers/artifact-commands.js +42 -0
- package/dist/controllers/presence-commands.js +19 -0
- package/dist/protocol/client-command-routes.js +3 -1
- package/dist/protocol/client-command-schemas.js +19 -3
- package/dist/server.js +200 -21
- package/dist/session/attachment-store.js +15 -0
- package/dist/session/event-log.js +25 -3
- package/dist/session/presence.js +82 -0
- package/dist/session/run-terminal.js +113 -69
- package/dist/session/session-new-dedupe.js +1 -1
- package/dist/terminal.js +23 -0
- package/dist/worktree.js +40 -3
- package/package.json +1 -1
|
@@ -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
|
+
}
|