linkshell-cli 0.8.0 → 0.8.2

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.
Files changed (36) hide show
  1. package/dist/cli/src/commands/doctor.js +7 -0
  2. package/dist/cli/src/commands/doctor.js.map +1 -1
  3. package/dist/cli/src/commands/host.js +4 -0
  4. package/dist/cli/src/commands/host.js.map +1 -1
  5. package/dist/cli/src/commands/screen.d.ts +16 -0
  6. package/dist/cli/src/commands/screen.js +135 -0
  7. package/dist/cli/src/commands/screen.js.map +1 -0
  8. package/dist/cli/src/index.js +18 -1
  9. package/dist/cli/src/index.js.map +1 -1
  10. package/dist/cli/tsconfig.tsbuildinfo +1 -1
  11. package/dist/host/src/drivers/claude/transcript.d.ts +23 -23
  12. package/dist/host/src/fs.d.ts +3 -3
  13. package/dist/host/src/input-helper.js +504 -148
  14. package/dist/host/src/input-helper.js.map +1 -1
  15. package/dist/host/src/input.d.ts +80 -6
  16. package/dist/host/src/input.js +371 -35
  17. package/dist/host/src/input.js.map +1 -1
  18. package/dist/host/src/rpc/server.js +1 -0
  19. package/dist/host/src/rpc/server.js.map +1 -1
  20. package/dist/host/src/screen-pacer.d.ts +62 -0
  21. package/dist/host/src/screen-pacer.js +143 -0
  22. package/dist/host/src/screen-pacer.js.map +1 -0
  23. package/dist/host/src/screen-viewer.js +38 -7
  24. package/dist/host/src/screen-viewer.js.map +1 -1
  25. package/dist/host/src/screen.d.ts +27 -21
  26. package/dist/host/src/screen.js +203 -39
  27. package/dist/host/src/screen.js.map +1 -1
  28. package/dist/host/src/terminals.d.ts +1 -1
  29. package/dist/wire/src/rpc.d.ts +815 -774
  30. package/dist/wire/src/rpc.js +21 -0
  31. package/dist/wire/src/rpc.js.map +1 -1
  32. package/package.json +3 -3
  33. package/src/commands/doctor.ts +7 -0
  34. package/src/commands/host.ts +5 -0
  35. package/src/commands/screen.ts +143 -0
  36. package/src/index.ts +19 -1
@@ -1,47 +1,178 @@
1
1
  // The source of the helper that moves this Mac's pointer and presses its keys
2
- // (see `input.ts`). It is kept here as text and compiled on the computer the
3
- // first time someone asks to control the screen: a Swift file needs no step in
4
- // the package's build and no binary in it, and the compiler comes with the
5
- // command line tools a machine running coding agents already has.
6
- export const INPUT_HELPER_SOURCE = String.raw `import ApplicationServices
2
+ // (see `input.ts`). One source, used two ways:
3
+ //
4
+ // - built and signed into `LinkShell.app` when the package is made (see
5
+ // `scripts/build-helper.mjs`). The host has the system open it, so it is an
6
+ // app of its own: the Accessibility permission is asked for, listed and
7
+ // kept under the name LinkShell, whatever terminal started the host.
8
+ // - compiled on the computer the first time it is needed, where a build has no
9
+ // signed app (a checkout without the signing identity). It then runs as the
10
+ // host's child, and the permission is the terminal's.
11
+ export const INPUT_HELPER_SOURCE = String.raw `import AppKit
12
+ import ApplicationServices
13
+ import Carbon
7
14
  import CoreGraphics
15
+ import Darwin
8
16
  import Foundation
9
17
 
10
- // LinkShell's hands on this Mac: reads one JSON command a line on stdin and
11
- // posts the mouse or keyboard event it describes. Positions are fractions of
12
- // the display being watched. With --dry-run the events are printed, not posted.
18
+ // LinkShell's hands on this Mac: reads one JSON command a line and posts the
19
+ // mouse or keyboard event it describes. Positions are fractions of the display
20
+ // a viewer is watching. With --dry-run the events are reported, not posted.
21
+ //
22
+ // <screen> one viewer, commands on stdin, answers on stdout
23
+ // --connect <socket> an app of its own: any number of viewers ("v") over the host's socket,
24
+ // and the screen captures, which it starts so that they are its own too
25
+ // --status what this process is allowed to do, then exit
26
+ // --ask-control | --ask-recording have the system ask for a permission, then exit
13
27
 
14
- let arguments = CommandLine.arguments
28
+ let arguments = Array(CommandLine.arguments.dropFirst())
15
29
  let dryRun = arguments.contains("--dry-run")
16
- let screenNumber = arguments.dropFirst().compactMap { Int($0) }.first ?? 0
30
+
31
+ func argument(after flag: String) -> String? {
32
+ guard let index = arguments.firstIndex(of: flag), index + 1 < arguments.count else { return nil }
33
+ return arguments[index + 1]
34
+ }
35
+
36
+ var output = FileHandle.standardOutput
17
37
 
18
38
  func emit(_ object: [String: Any]) {
19
39
  guard let data = try? JSONSerialization.data(withJSONObject: object), let line = String(data: data, encoding: .utf8) else { return }
20
- FileHandle.standardOutput.write((line + "\n").data(using: .utf8)!)
40
+ output.write((line + "\n").data(using: .utf8)!)
21
41
  }
22
42
 
23
- func displayBounds() -> CGRect {
24
- var count: UInt32 = 0
25
- CGGetActiveDisplayList(0, nil, &count)
26
- var ids = [CGDirectDisplayID](repeating: 0, count: Int(count))
27
- CGGetActiveDisplayList(count, &ids, &count)
28
- let id = screenNumber < Int(count) ? ids[screenNumber] : CGMainDisplayID()
29
- return CGDisplayBounds(id)
43
+ /// The app the system holds responsible for this process: the one its privacy settings list.
44
+ func responsibleApp() -> String {
45
+ typealias Lookup = @convention(c) (pid_t) -> pid_t
46
+ guard let symbol = dlsym(UnsafeMutableRawPointer(bitPattern: -2), "responsibility_get_pid_responsible_for_pid") else { return "" }
47
+ let pid = unsafeBitCast(symbol, to: Lookup.self)(getpid())
48
+ guard pid > 0 else { return "" }
49
+ var buffer = [CChar](repeating: 0, count: 4096)
50
+ guard proc_pidpath(pid, &buffer, UInt32(buffer.count)) > 0 else { return "" }
51
+ let path = String(cString: buffer)
52
+ // An app is named by its bundle, anything else by its file.
53
+ if let bundle = path.components(separatedBy: "/").last(where: { $0.hasSuffix(".app") }) { return String(bundle.dropLast(4)) }
54
+ return (path as NSString).lastPathComponent
30
55
  }
31
56
 
32
57
  func trusted() -> Bool { dryRun || AXIsProcessTrusted() }
33
58
 
59
+ /// Whether the screen may be recorded. A process that was refused keeps hearing no after the
60
+ /// switch is turned on, so once it has been refused it asks a fresh copy of itself.
61
+ func recording() -> Bool {
62
+ if CGPreflightScreenCaptureAccess() { return true }
63
+ if arguments.contains("--status") { return false }
64
+ guard let program = Bundle.main.executablePath else { return false }
65
+ let check = Process()
66
+ check.executableURL = URL(fileURLWithPath: program)
67
+ check.arguments = ["--status"]
68
+ let answer = Pipe()
69
+ check.standardOutput = answer
70
+ check.standardError = FileHandle.nullDevice
71
+ guard (try? check.run()) != nil else { return false }
72
+ let data = answer.fileHandleForReading.readDataToEndOfFile()
73
+ check.waitUntilExit()
74
+ return ((try? JSONSerialization.jsonObject(with: data)) as? [String: Any])?["recording"] as? Bool ?? false
75
+ }
76
+
77
+ func status() -> [String: Any] {
78
+ ["t": "status", "trusted": trusted(), "recording": recording(), "app": responsibleApp()]
79
+ }
80
+
81
+ /// The system's own dialog, and the page of its settings where the switch is.
82
+ func askControl() {
83
+ if dryRun || AXIsProcessTrusted() { return }
84
+ let options = [kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String: true] as CFDictionary
85
+ _ = AXIsProcessTrustedWithOptions(options)
86
+ if let page = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility") { NSWorkspace.shared.open(page) }
87
+ }
88
+
89
+ func askRecording() {
90
+ if CGPreflightScreenCaptureAccess() { return }
91
+ _ = CGRequestScreenCaptureAccess()
92
+ if let page = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture") { NSWorkspace.shared.open(page) }
93
+ }
94
+
34
95
  let source = CGEventSource(stateID: .hidSystemState)
35
96
  // Events from here must not hold back the real mouse and keyboard.
36
97
  source?.localEventsSuppressionInterval = 0
37
98
 
38
- var bounds = displayBounds()
39
- var position = CGEvent(source: nil)?.location ?? CGPoint(x: bounds.midX, y: bounds.midY)
40
- var held: [String: Bool] = [:]
41
- var lastMove = Date.distantPast
42
- var lastReported = CGPoint(x: -1, y: -1)
43
- var wasTrusted = trusted()
99
+ // Keys that are where they are on every layout.
100
+ let fixedKeys: [String: CGKeyCode] = [
101
+ "return": 36, "tab": 48, "space": 49, "backspace": 51, "escape": 53,
102
+ "left": 123, "right": 124, "down": 125, "up": 126, "delete": 117, "home": 115, "end": 119, "pageup": 116, "pagedown": 121,
103
+ "f1": 122, "f2": 120, "f3": 99, "f4": 118, "f5": 96, "f6": 97, "f7": 98, "f8": 100, "f9": 101, "f10": 109, "f11": 103, "f12": 111,
104
+ ]
105
+
106
+ func sourceProperty<T>(_ source: TISInputSource, _ key: CFString) -> T? {
107
+ guard let pointer = TISGetInputSourceProperty(source, key) else { return nil }
108
+ return Unmanaged<AnyObject>.fromOpaque(pointer).takeUnretainedValue() as? T
109
+ }
110
+
111
+ // Where each character is on this Mac's own layout: a shortcut's letter is the key that types it
112
+ // here, not the key it is on an American keyboard.
113
+ var layoutID = ""
114
+ var layoutKeys: [String: CGKeyCode] = [:]
115
+
116
+ func readLayout() {
117
+ guard let layout = TISCopyCurrentASCIICapableKeyboardLayoutInputSource()?.takeRetainedValue() else { return }
118
+ let id: String = sourceProperty(layout, kTISPropertyInputSourceID) ?? ""
119
+ if id == layoutID, !layoutKeys.isEmpty { return }
120
+ guard let data: NSData = sourceProperty(layout, kTISPropertyUnicodeKeyLayoutData) else { return }
121
+ layoutID = id
122
+ layoutKeys = [:]
123
+ let table = data.bytes.assumingMemoryBound(to: UCKeyboardLayout.self)
124
+ for code in 0..<127 {
125
+ var dead: UInt32 = 0
126
+ var length = 0
127
+ var characters = [UniChar](repeating: 0, count: 4)
128
+ let status = UCKeyTranslate(table, UInt16(code), UInt16(kUCKeyActionDown), 0, UInt32(LMGetKbdType()), OptionBits(kUCKeyTranslateNoDeadKeysBit), &dead, 4, &length, &characters)
129
+ guard status == noErr, length == 1 else { continue }
130
+ let character = String(utf16CodeUnits: characters, count: 1).lowercased()
131
+ // The main keys come before the number pad's.
132
+ if layoutKeys[character] == nil { layoutKeys[character] = CGKeyCode(code) }
133
+ }
134
+ }
135
+
136
+ func keyCode(_ name: String) -> CGKeyCode? {
137
+ if let fixed = fixedKeys[name] { return fixed }
138
+ readLayout()
139
+ return layoutKeys[name] ?? keyCodes[name]
140
+ }
141
+
142
+ // The keys a modifier is, pressed and let go around what it modifies as a hand does: some programs
143
+ // look at the keyboard's state, not at the event's flags.
144
+ let modifierKeys: [(name: String, code: CGKeyCode, flag: CGEventFlags)] = [
145
+ ("ctrl", 59, .maskControl), ("alt", 58, .maskAlternate), ("shift", 56, .maskShift), ("cmd", 55, .maskCommand),
146
+ ]
147
+
148
+ // Text from the phone is already what it should be. An input method on this Mac (pinyin, say) would
149
+ // take its letters and compose with them again, so while text is coming the Mac types with its plain
150
+ // layout; a moment after the last of it, the input source the user had is back.
151
+ var ownSource: TISInputSource?
152
+ var restore: DispatchWorkItem?
153
+
154
+ func plainWhileTyping() {
155
+ if dryRun { return }
156
+ if let current = TISCopyCurrentKeyboardInputSource()?.takeRetainedValue() {
157
+ let plain: Bool = sourceProperty(current, kTISPropertyInputSourceIsASCIICapable) ?? true
158
+ if !plain, let layout = TISCopyCurrentASCIICapableKeyboardLayoutInputSource()?.takeRetainedValue() {
159
+ if ownSource == nil { ownSource = current }
160
+ TISSelectInputSource(layout)
161
+ // The program in front takes a moment to follow.
162
+ usleep(150_000)
163
+ }
164
+ }
165
+ guard ownSource != nil else { return }
166
+ restore?.cancel()
167
+ let work = DispatchWorkItem {
168
+ if let source = ownSource { TISSelectInputSource(source) }
169
+ ownSource = nil
170
+ }
171
+ restore = work
172
+ DispatchQueue.main.asyncAfter(deadline: .now() + 2.5, execute: work)
173
+ }
44
174
 
175
+ /// The American layout, for a Mac whose own can't be read.
45
176
  let keyCodes: [String: CGKeyCode] = [
46
177
  "a": 0, "s": 1, "d": 2, "f": 3, "h": 4, "g": 5, "z": 6, "x": 7, "c": 8, "v": 9, "b": 11, "q": 12, "w": 13, "e": 14, "r": 15,
47
178
  "y": 16, "t": 17, "1": 18, "2": 19, "3": 20, "4": 21, "6": 22, "5": 23, "=": 24, "9": 25, "7": 26, "-": 27, "8": 28, "0": 29,
@@ -65,144 +196,369 @@ func flags(_ names: Any?) -> CGEventFlags {
65
196
  return result
66
197
  }
67
198
 
68
- func post(_ event: CGEvent?, _ what: [String: Any]) {
69
- if dryRun {
70
- emit(what.merging(["t": "posted"]) { current, _ in current })
71
- return
199
+ /// One device watching one display.
200
+ final class Viewer {
201
+ let id: String?
202
+ let screen: Int
203
+ var bounds: CGRect
204
+ var position: CGPoint
205
+ var held: [String: Bool] = [:]
206
+ /// Modifiers this viewer is holding down for a click or a drag.
207
+ var holding: [String] = []
208
+ var lastMove = Date.distantPast
209
+ var lastReported = CGPoint(x: -1, y: -1)
210
+
211
+ init(id: String?, screen: Int) {
212
+ self.id = id
213
+ self.screen = screen
214
+ bounds = Viewer.bounds(of: screen)
215
+ position = CGEvent(source: nil)?.location ?? CGPoint(x: bounds.midX, y: bounds.midY)
216
+ }
217
+
218
+ /// ffmpeg's "Capture screen N" is the Nth active display.
219
+ static func bounds(of screen: Int) -> CGRect {
220
+ var count: UInt32 = 0
221
+ CGGetActiveDisplayList(0, nil, &count)
222
+ var ids = [CGDirectDisplayID](repeating: 0, count: Int(count))
223
+ CGGetActiveDisplayList(count, &ids, &count)
224
+ return CGDisplayBounds(screen < Int(count) ? ids[screen] : CGMainDisplayID())
225
+ }
226
+
227
+ func tell(_ object: [String: Any]) {
228
+ var message = object
229
+ if let id = id { message["v"] = id }
230
+ emit(message)
231
+ }
232
+
233
+ func post(_ event: CGEvent?, _ what: [String: Any]) {
234
+ if dryRun {
235
+ tell(what.merging(["t": "posted"]) { current, _ in current })
236
+ return
237
+ }
238
+ event?.post(tap: .cghidEventTap)
239
+ }
240
+
241
+ func point(_ command: [String: Any]) -> CGPoint? {
242
+ guard let x = command["x"] as? Double, let y = command["y"] as? Double else { return nil }
243
+ // Just inside the far edges: a point on the edge itself belongs to the next display.
244
+ return CGPoint(
245
+ x: bounds.minX + min(max(x, 0), 1) * (bounds.width - 1),
246
+ y: bounds.minY + min(max(y, 0), 1) * (bounds.height - 1)
247
+ )
248
+ }
249
+
250
+ func move(to target: CGPoint) {
251
+ let kind: CGEventType = held["left"] == true ? .leftMouseDragged : held["right"] == true ? .rightMouseDragged : .mouseMoved
252
+ let event = CGEvent(mouseEventSource: source, mouseType: kind, mouseCursorPosition: target, mouseButton: held["right"] == true ? .right : .left)
253
+ event?.setIntegerValueField(.mouseEventDeltaX, value: Int64(target.x - position.x))
254
+ event?.setIntegerValueField(.mouseEventDeltaY, value: Int64(target.y - position.y))
255
+ event?.flags = []
256
+ position = target
257
+ lastMove = Date()
258
+ post(event, ["kind": kind == .mouseMoved ? "move" : "drag", "x": Double(target.x), "y": Double(target.y)])
259
+ }
260
+
261
+ /// Presses (or lets go of) modifier keys; the flags held once it has.
262
+ func modifiers(_ names: [String], down: Bool, over base: CGEventFlags = []) -> CGEventFlags {
263
+ var flags = base
264
+ let keys = modifierKeys.filter { names.contains($0.name) }
265
+ for key in down ? keys : keys.reversed() {
266
+ if down { flags.insert(key.flag) } else { flags.remove(key.flag) }
267
+ let event = CGEvent(keyboardEventSource: source, virtualKey: key.code, keyDown: down)
268
+ event?.type = .flagsChanged
269
+ event?.flags = flags
270
+ post(event, ["kind": down ? "moddown" : "modup", "k": key.name])
271
+ }
272
+ return flags
273
+ }
274
+
275
+ func button(_ name: String, down: Bool, clicks: Int64, with names: [String]) {
276
+ if held[name] == down { return }
277
+ held[name] = down
278
+ // Shift-click, command-drag: the keys go down before the button and stay until it is up.
279
+ var modifiers: CGEventFlags = []
280
+ if down {
281
+ holding = names
282
+ modifiers = self.modifiers(names, down: true)
283
+ } else {
284
+ modifiers = flags(holding)
285
+ }
286
+ defer {
287
+ if !down {
288
+ _ = self.modifiers(holding, down: false, over: flags(holding))
289
+ holding = []
290
+ }
291
+ }
292
+ let right = name == "right"
293
+ let kind: CGEventType = right ? (down ? .rightMouseDown : .rightMouseUp) : (down ? .leftMouseDown : .leftMouseUp)
294
+ let event = CGEvent(mouseEventSource: source, mouseType: kind, mouseCursorPosition: position, mouseButton: right ? .right : .left)
295
+ event?.setIntegerValueField(.mouseEventClickState, value: clicks)
296
+ event?.flags = modifiers
297
+ lastMove = Date()
298
+ post(event, ["kind": down ? "down" : "up", "b": name, "n": clicks, "x": Double(position.x), "y": Double(position.y), "flags": modifiers.rawValue])
299
+ }
300
+
301
+ func key(_ code: CGKeyCode, with names: [String], name: String) {
302
+ let modifiers = self.modifiers(names, down: true)
303
+ for down in [true, false] {
304
+ let event = CGEvent(keyboardEventSource: source, virtualKey: code, keyDown: down)
305
+ event?.flags = modifiers
306
+ post(event, ["kind": down ? "keydown" : "keyup", "k": name, "code": Int(code), "flags": modifiers.rawValue])
307
+ }
308
+ _ = self.modifiers(names, down: false, over: modifiers)
309
+ }
310
+
311
+ func type(_ text: String) {
312
+ plainWhileTyping()
313
+ // A string a key event: whatever the keyboard layout or input method, the characters arrive as written.
314
+ let units = Array(text.utf16)
315
+ var index = 0
316
+ while index < units.count {
317
+ var end = min(index + 20, units.count)
318
+ // Not between the halves of a surrogate pair.
319
+ if end < units.count, UTF16.isLeadSurrogate(units[end - 1]) { end -= 1 }
320
+ let chunk = Array(units[index..<end])
321
+ for down in [true, false] {
322
+ let event = CGEvent(keyboardEventSource: source, virtualKey: 0, keyDown: down)
323
+ event?.flags = []
324
+ event?.keyboardSetUnicodeString(stringLength: chunk.count, unicodeString: chunk)
325
+ post(event, ["kind": down ? "textdown" : "textup", "s": String(utf16CodeUnits: chunk, count: chunk.count)])
326
+ }
327
+ index = end
328
+ }
329
+ }
330
+
331
+ /// The viewer left: nothing stays pressed.
332
+ func releaseAll() {
333
+ for (name, down) in held where down { button(name, down: false, clicks: 1, with: []) }
334
+ }
335
+
336
+ func handle(_ command: [String: Any]) {
337
+ guard let kind = command["t"] as? String else { return }
338
+ if kind == "prompt" { return askControl() }
339
+ guard trusted() else { return }
340
+ switch kind {
341
+ case "move":
342
+ if let target = point(command) { move(to: target) }
343
+ case "down", "up":
344
+ let name = (command["b"] as? String) == "right" ? "right" : "left"
345
+ let clicks = Int64(min(max((command["n"] as? Int) ?? 1, 1), 3))
346
+ button(name, down: kind == "down", clicks: clicks, with: (command["m"] as? [String]) ?? [])
347
+ case "scroll":
348
+ let dx = Int32(max(min((command["dx"] as? Double) ?? 0, 2000), -2000))
349
+ let dy = Int32(max(min((command["dy"] as? Double) ?? 0, 2000), -2000))
350
+ let event = CGEvent(scrollWheelEvent2Source: source, units: .pixel, wheelCount: 2, wheel1: dy, wheel2: dx, wheel3: 0)
351
+ event?.flags = flags(command["m"])
352
+ post(event, ["kind": "scroll", "dx": Int(dx), "dy": Int(dy)])
353
+ case "key":
354
+ guard let name = command["k"] as? String, let code = keyCode(name) else { return }
355
+ key(code, with: (command["m"] as? [String]) ?? [], name: name)
356
+ case "text":
357
+ if let text = command["s"] as? String { type(text) }
358
+ default:
359
+ break
360
+ }
361
+ }
362
+
363
+ /// Where the pointer is when someone at the Mac (or another program) moved it.
364
+ func report() {
365
+ guard Date().timeIntervalSince(lastMove) > 0.3, let location = CGEvent(source: nil)?.location else { return }
366
+ bounds = Viewer.bounds(of: screen)
367
+ if abs(location.x - lastReported.x) < 0.5, abs(location.y - lastReported.y) < 0.5 { return }
368
+ lastReported = location
369
+ position = location
370
+ guard bounds.contains(location) else { return }
371
+ tell(["t": "cursor", "x": Double((location.x - bounds.minX) / max(bounds.width - 1, 1)), "y": Double((location.y - bounds.minY) / max(bounds.height - 1, 1))])
372
+ }
373
+
374
+ func ready() {
375
+ tell(["t": "ready", "trusted": trusted(), "w": Double(bounds.width), "h": Double(bounds.height), "app": responsibleApp()])
376
+ }
377
+ }
378
+
379
+ var viewers: [String: Viewer] = [:]
380
+ var captures: [String: Process] = [:]
381
+ var wasTrusted = trusted()
382
+
383
+ func end(_ process: Process) {
384
+ guard process.isRunning else { return }
385
+ process.terminate()
386
+ // ffmpeg holding a screen can sit through being asked.
387
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) {
388
+ if process.isRunning { kill(process.processIdentifier, SIGKILL) }
389
+ }
390
+ }
391
+
392
+ /// Runs a screen capture as this app's own child, so the recording is this app's to be allowed,
393
+ /// with its picture going straight down a socket of the host's.
394
+ func capture(_ id: String, _ command: [String: Any]) {
395
+ let failed = { (reason: String) in emit(["t": "captured", "v": id, "code": -1, "errors": reason]) }
396
+ guard let program = command["exec"] as? String, let args = command["args"] as? [String], let path = command["socket"] as? String else { return failed("not a capture") }
397
+ // The one program this runs for the host.
398
+ guard dryRun || (program as NSString).lastPathComponent == "ffmpeg" else { return failed("not ffmpeg") }
399
+ guard let sink = connect(to: path) else { return failed("the host's socket could not be reached") }
400
+ let process = Process()
401
+ process.executableURL = URL(fileURLWithPath: program)
402
+ process.arguments = args
403
+ process.standardInput = FileHandle.nullDevice
404
+ process.standardOutput = sink
405
+ let errors = Pipe()
406
+ process.standardError = errors
407
+ var said = Data()
408
+ errors.fileHandleForReading.readabilityHandler = { handle in
409
+ let chunk = handle.availableData
410
+ if chunk.isEmpty {
411
+ handle.readabilityHandler = nil
412
+ return
413
+ }
414
+ DispatchQueue.main.async {
415
+ said.append(chunk)
416
+ if said.count > 2000 { said.removeFirst(said.count - 2000) }
417
+ }
418
+ }
419
+ process.terminationHandler = { finished in
420
+ DispatchQueue.main.async {
421
+ captures[id] = nil
422
+ try? sink.close()
423
+ emit(["t": "captured", "v": id, "code": Int(finished.terminationStatus), "errors": String(data: said, encoding: .utf8) ?? ""])
424
+ }
425
+ }
426
+ do {
427
+ try process.run()
428
+ captures[id] = process
429
+ } catch {
430
+ failed(error.localizedDescription)
72
431
  }
73
- event?.post(tap: .cghidEventTap)
74
432
  }
75
433
 
76
- func point(_ command: [String: Any]) -> CGPoint? {
77
- guard let x = command["x"] as? Double, let y = command["y"] as? Double else { return nil }
78
- // Just inside the far edges: a point on the edge itself belongs to the next display.
79
- return CGPoint(
80
- x: bounds.minX + min(max(x, 0), 1) * (bounds.width - 1),
81
- y: bounds.minY + min(max(y, 0), 1) * (bounds.height - 1)
82
- )
434
+ /// Lines from a pipe or a socket, each handled on the main queue; then end.
435
+ func readLines(from handle: FileHandle, each: @escaping (String) -> Void, end: @escaping () -> Void) {
436
+ Thread.detachNewThread {
437
+ var buffer = Data()
438
+ while true {
439
+ let chunk = handle.availableData
440
+ if chunk.isEmpty { break }
441
+ buffer.append(chunk)
442
+ while let newline = buffer.firstIndex(of: 10) {
443
+ let line = String(data: buffer.subdata(in: buffer.startIndex..<newline), encoding: .utf8) ?? ""
444
+ buffer.removeSubrange(buffer.startIndex...newline)
445
+ DispatchQueue.main.sync { each(line) }
446
+ }
447
+ }
448
+ DispatchQueue.main.sync { end() }
449
+ }
83
450
  }
84
451
 
85
- func move(to target: CGPoint) {
86
- let kind: CGEventType = held["left"] == true ? .leftMouseDragged : held["right"] == true ? .rightMouseDragged : .mouseMoved
87
- let event = CGEvent(mouseEventSource: source, mouseType: kind, mouseCursorPosition: target, mouseButton: held["right"] == true ? .right : .left)
88
- event?.setIntegerValueField(.mouseEventDeltaX, value: Int64(target.x - position.x))
89
- event?.setIntegerValueField(.mouseEventDeltaY, value: Int64(target.y - position.y))
90
- event?.flags = []
91
- position = target
92
- lastMove = Date()
93
- post(event, ["kind": kind == .mouseMoved ? "move" : "drag", "x": Double(target.x), "y": Double(target.y)])
452
+ func object(_ line: String) -> [String: Any]? {
453
+ guard let data = line.data(using: .utf8) else { return nil }
454
+ return (try? JSONSerialization.jsonObject(with: data)) as? [String: Any]
94
455
  }
95
456
 
96
- func button(_ name: String, down: Bool, clicks: Int64, modifiers: CGEventFlags) {
97
- if held[name] == down { return }
98
- held[name] = down
99
- let right = name == "right"
100
- let kind: CGEventType = right ? (down ? .rightMouseDown : .rightMouseUp) : (down ? .leftMouseDown : .leftMouseUp)
101
- let event = CGEvent(mouseEventSource: source, mouseType: kind, mouseCursorPosition: position, mouseButton: right ? .right : .left)
102
- event?.setIntegerValueField(.mouseEventClickState, value: clicks)
103
- event?.flags = modifiers
104
- lastMove = Date()
105
- post(event, ["kind": down ? "down" : "up", "b": name, "n": clicks, "x": Double(position.x), "y": Double(position.y), "flags": modifiers.rawValue])
457
+ func watch() -> DispatchSourceTimer {
458
+ let timer = DispatchSource.makeTimerSource(queue: .main)
459
+ timer.schedule(deadline: .now(), repeating: .milliseconds(250))
460
+ timer.setEventHandler {
461
+ let now = trusted()
462
+ if now != wasTrusted {
463
+ wasTrusted = now
464
+ emit(["t": "trusted", "on": now])
465
+ }
466
+ for viewer in viewers.values { viewer.report() }
467
+ }
468
+ timer.resume()
469
+ return timer
106
470
  }
107
471
 
108
- func key(_ code: CGKeyCode, modifiers: CGEventFlags, name: String) {
109
- for down in [true, false] {
110
- let event = CGEvent(keyboardEventSource: source, virtualKey: code, keyDown: down)
111
- event?.flags = modifiers
112
- post(event, ["kind": down ? "keydown" : "keyup", "k": name, "code": Int(code), "flags": modifiers.rawValue])
472
+ func connect(to path: String) -> FileHandle? {
473
+ let descriptor = socket(AF_UNIX, SOCK_STREAM, 0)
474
+ guard descriptor >= 0 else { return nil }
475
+ var address = sockaddr_un()
476
+ address.sun_family = sa_family_t(AF_UNIX)
477
+ let bytes = Array(path.utf8)
478
+ guard bytes.count < MemoryLayout.size(ofValue: address.sun_path) else { return nil }
479
+ withUnsafeMutablePointer(to: &address.sun_path) { pointer in
480
+ pointer.withMemoryRebound(to: UInt8.self, capacity: bytes.count + 1) { raw in
481
+ for (index, byte) in bytes.enumerated() { raw[index] = byte }
482
+ raw[bytes.count] = 0
483
+ }
484
+ }
485
+ let size = socklen_t(MemoryLayout<sockaddr_un>.size)
486
+ let result = withUnsafePointer(to: &address) { pointer in
487
+ pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { Darwin.connect(descriptor, $0, size) }
113
488
  }
489
+ guard result == 0 else {
490
+ close(descriptor)
491
+ return nil
492
+ }
493
+ return FileHandle(fileDescriptor: descriptor, closeOnDealloc: true)
114
494
  }
115
495
 
116
- func type(_ text: String) {
117
- // A string a key event: whatever the keyboard layout or input method, the characters arrive as written.
118
- let units = Array(text.utf16)
119
- var index = 0
120
- while index < units.count {
121
- var end = min(index + 20, units.count)
122
- // Not between the halves of a surrogate pair.
123
- if end < units.count, UTF16.isLeadSurrogate(units[end - 1]) { end -= 1 }
124
- let chunk = Array(units[index..<end])
125
- for down in [true, false] {
126
- let event = CGEvent(keyboardEventSource: source, virtualKey: 0, keyDown: down)
127
- event?.flags = []
128
- event?.keyboardSetUnicodeString(stringLength: chunk.count, unicodeString: chunk)
129
- post(event, ["kind": down ? "textdown" : "textup", "s": String(utf16CodeUnits: chunk, count: chunk.count)])
130
- }
131
- index = end
132
- }
133
- }
134
-
135
- func releaseAll() {
136
- for (name, down) in held where down { button(name, down: false, clicks: 1, modifiers: []) }
137
- }
138
-
139
- func handle(_ command: [String: Any]) {
140
- guard let kind = command["t"] as? String else { return }
141
- if kind == "prompt" {
142
- // Shows the system's "wants to control this computer" dialog on the Mac, once.
143
- if !dryRun {
144
- let options = [kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String: true] as CFDictionary
145
- _ = AXIsProcessTrustedWithOptions(options)
146
- }
147
- return
148
- }
149
- guard trusted() else { return }
150
- switch kind {
151
- case "move":
152
- if let target = point(command) { move(to: target) }
153
- case "down", "up":
154
- let name = (command["b"] as? String) == "right" ? "right" : "left"
155
- let clicks = Int64(min(max((command["n"] as? Int) ?? 1, 1), 3))
156
- button(name, down: kind == "down", clicks: clicks, modifiers: flags(command["m"]))
157
- case "scroll":
158
- let dx = Int32(max(min((command["dx"] as? Double) ?? 0, 2000), -2000))
159
- let dy = Int32(max(min((command["dy"] as? Double) ?? 0, 2000), -2000))
160
- let event = CGEvent(scrollWheelEvent2Source: source, units: .pixel, wheelCount: 2, wheel1: dy, wheel2: dx, wheel3: 0)
161
- event?.flags = flags(command["m"])
162
- post(event, ["kind": "scroll", "dx": Int(dx), "dy": Int(dy)])
163
- case "key":
164
- guard let name = command["k"] as? String, let code = keyCodes[name] else { return }
165
- key(code, modifiers: flags(command["m"]), name: name)
166
- case "text":
167
- if let text = command["s"] as? String { type(text) }
168
- default:
169
- break
170
- }
171
- }
172
-
173
- func report() {
174
- let now = trusted()
175
- if now != wasTrusted {
176
- wasTrusted = now
177
- emit(["t": "trusted", "v": now])
178
- }
179
- // Where the pointer is when someone at the Mac (or another program) moved it.
180
- guard Date().timeIntervalSince(lastMove) > 0.3, let location = CGEvent(source: nil)?.location else { return }
181
- bounds = displayBounds()
182
- if abs(location.x - lastReported.x) < 0.5, abs(location.y - lastReported.y) < 0.5 { return }
183
- lastReported = location
184
- position = location
185
- guard bounds.contains(location) else { return }
186
- emit(["t": "cursor", "x": Double((location.x - bounds.minX) / max(bounds.width - 1, 1)), "y": Double((location.y - bounds.minY) / max(bounds.height - 1, 1))])
187
- }
188
-
189
- emit(["t": "ready", "trusted": wasTrusted, "w": Double(bounds.width), "h": Double(bounds.height)])
190
-
191
- let timer = DispatchSource.makeTimerSource(queue: .main)
192
- timer.schedule(deadline: .now(), repeating: .milliseconds(250))
193
- timer.setEventHandler { report() }
194
- timer.resume()
195
-
196
- Thread.detachNewThread {
197
- while let line = readLine(strippingNewline: true) {
198
- guard let data = line.data(using: .utf8), let command = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] else { continue }
199
- DispatchQueue.main.sync { handle(command) }
200
- }
201
- // The viewer left: nothing stays pressed.
202
- DispatchQueue.main.sync { releaseAll() }
496
+ signal(SIGPIPE, SIG_IGN)
497
+
498
+ if arguments.contains("--status") {
499
+ emit(status())
500
+ exit(0)
501
+ }
502
+ if arguments.contains("--ask-control") {
503
+ askControl()
504
+ emit(status())
505
+ exit(0)
506
+ }
507
+ if arguments.contains("--ask-recording") {
508
+ askRecording()
509
+ emit(status())
203
510
  exit(0)
204
511
  }
205
512
 
206
- dispatchMain()
513
+ if let path = argument(after: "--connect") {
514
+ // An app of its own, with no window and no place in the Dock.
515
+ guard let link = connect(to: path) else { exit(1) }
516
+ output = link
517
+ emit(status())
518
+ let timer = watch()
519
+ readLines(from: link, each: { line in
520
+ guard let command = object(line), let kind = command["t"] as? String else { return }
521
+ if kind == "status" { return emit(status()) }
522
+ if kind == "ask" { return askControl() }
523
+ if kind == "ask-recording" { return askRecording() }
524
+ guard let id = command["v"] as? String else { return }
525
+ if kind == "capture" {
526
+ capture(id, command)
527
+ } else if kind == "stop" {
528
+ if let process = captures[id] { end(process) }
529
+ } else if kind == "open" {
530
+ viewers[id]?.releaseAll()
531
+ let viewer = Viewer(id: id, screen: (command["screen"] as? Int) ?? 0)
532
+ viewers[id] = viewer
533
+ viewer.ready()
534
+ } else if kind == "close" {
535
+ viewers.removeValue(forKey: id)?.releaseAll()
536
+ } else {
537
+ viewers[id]?.handle(command)
538
+ }
539
+ }, end: {
540
+ // The host went: so does this, and what it was running.
541
+ for viewer in viewers.values { viewer.releaseAll() }
542
+ for process in captures.values where process.isRunning { kill(process.processIdentifier, SIGKILL) }
543
+ timer.cancel()
544
+ exit(0)
545
+ })
546
+ let app = NSApplication.shared
547
+ app.setActivationPolicy(.accessory)
548
+ app.run()
549
+ } else {
550
+ let viewer = Viewer(id: nil, screen: arguments.compactMap { Int($0) }.first ?? 0)
551
+ viewers[""] = viewer
552
+ viewer.ready()
553
+ let timer = watch()
554
+ readLines(from: FileHandle.standardInput, each: { line in
555
+ if let command = object(line) { viewer.handle(command) }
556
+ }, end: {
557
+ viewer.releaseAll()
558
+ timer.cancel()
559
+ exit(0)
560
+ })
561
+ dispatchMain()
562
+ }
207
563
  `;
208
564
  //# sourceMappingURL=input-helper.js.map