linkshell-cli 0.7.0 → 0.8.1

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 (35) 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.d.ts +1 -0
  14. package/dist/host/src/input-helper.js +564 -0
  15. package/dist/host/src/input-helper.js.map +1 -0
  16. package/dist/host/src/input.d.ts +195 -0
  17. package/dist/host/src/input.js +495 -0
  18. package/dist/host/src/input.js.map +1 -0
  19. package/dist/host/src/rpc/server.js +1 -0
  20. package/dist/host/src/rpc/server.js.map +1 -1
  21. package/dist/host/src/screen-viewer.d.ts +1 -0
  22. package/dist/host/src/screen-viewer.js +784 -0
  23. package/dist/host/src/screen-viewer.js.map +1 -0
  24. package/dist/host/src/screen.d.ts +20 -0
  25. package/dist/host/src/screen.js +147 -72
  26. package/dist/host/src/screen.js.map +1 -1
  27. package/dist/host/src/terminals.d.ts +1 -1
  28. package/dist/wire/src/rpc.d.ts +815 -774
  29. package/dist/wire/src/rpc.js +21 -0
  30. package/dist/wire/src/rpc.js.map +1 -1
  31. package/package.json +4 -4
  32. package/src/commands/doctor.ts +7 -0
  33. package/src/commands/host.ts +5 -0
  34. package/src/commands/screen.ts +143 -0
  35. package/src/index.ts +19 -1
@@ -0,0 +1,564 @@
1
+ // The source of the helper that moves this Mac's pointer and presses its keys
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
14
+ import CoreGraphics
15
+ import Darwin
16
+ import Foundation
17
+
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
27
+
28
+ let arguments = Array(CommandLine.arguments.dropFirst())
29
+ let dryRun = arguments.contains("--dry-run")
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
37
+
38
+ func emit(_ object: [String: Any]) {
39
+ guard let data = try? JSONSerialization.data(withJSONObject: object), let line = String(data: data, encoding: .utf8) else { return }
40
+ output.write((line + "\n").data(using: .utf8)!)
41
+ }
42
+
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
55
+ }
56
+
57
+ func trusted() -> Bool { dryRun || AXIsProcessTrusted() }
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
+
95
+ let source = CGEventSource(stateID: .hidSystemState)
96
+ // Events from here must not hold back the real mouse and keyboard.
97
+ source?.localEventsSuppressionInterval = 0
98
+
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
+ }
174
+
175
+ /// The American layout, for a Mac whose own can't be read.
176
+ let keyCodes: [String: CGKeyCode] = [
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,
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,
179
+ "]": 30, "o": 31, "u": 32, "[": 33, "i": 34, "p": 35, "return": 36, "l": 37, "j": 38, "'": 39, "k": 40, ";": 41, "\\": 42,
180
+ ",": 43, "/": 44, "n": 45, "m": 46, ".": 47, "tab": 48, "space": 49, "\u{60}": 50, "backspace": 51, "escape": 53,
181
+ "left": 123, "right": 124, "down": 125, "up": 126, "delete": 117, "home": 115, "end": 119, "pageup": 116, "pagedown": 121,
182
+ "f1": 122, "f2": 120, "f3": 99, "f4": 118, "f5": 96, "f6": 97, "f7": 98, "f8": 100, "f9": 101, "f10": 109, "f11": 103, "f12": 111,
183
+ ]
184
+
185
+ func flags(_ names: Any?) -> CGEventFlags {
186
+ var result: CGEventFlags = []
187
+ for name in (names as? [String]) ?? [] {
188
+ switch name {
189
+ case "cmd": result.insert(.maskCommand)
190
+ case "shift": result.insert(.maskShift)
191
+ case "alt": result.insert(.maskAlternate)
192
+ case "ctrl": result.insert(.maskControl)
193
+ default: break
194
+ }
195
+ }
196
+ return result
197
+ }
198
+
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)
431
+ }
432
+ }
433
+
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
+ }
450
+ }
451
+
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]
455
+ }
456
+
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
470
+ }
471
+
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) }
488
+ }
489
+ guard result == 0 else {
490
+ close(descriptor)
491
+ return nil
492
+ }
493
+ return FileHandle(fileDescriptor: descriptor, closeOnDealloc: true)
494
+ }
495
+
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())
510
+ exit(0)
511
+ }
512
+
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
+ }
563
+ `;
564
+ //# sourceMappingURL=input-helper.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"input-helper.js","sourceRoot":"","sources":["../../../../host/src/input-helper.ts"],"names":[],"mappings":"AAAA,8EAA8E;AAC9E,+CAA+C;AAC/C,EAAE;AACF,wEAAwE;AACxE,8EAA8E;AAC9E,0EAA0E;AAC1E,uEAAuE;AACvE,+EAA+E;AAC/E,8EAA8E;AAC9E,wDAAwD;AAExD,MAAM,CAAC,MAAM,mBAAmB,GAAG,MAAM,CAAC,GAAG,CAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAwiB5C,CAAC"}