@linkshell/host 0.2.0 → 0.3.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.
- package/dist/host/src/drivers/claude/transcript.d.ts +13 -13
- package/dist/host/src/input-helper.d.ts +1 -0
- package/dist/host/src/input-helper.js +564 -0
- package/dist/host/src/input-helper.js.map +1 -0
- package/dist/host/src/input.d.ts +195 -0
- package/dist/host/src/input.js +495 -0
- package/dist/host/src/input.js.map +1 -0
- package/dist/host/src/rpc/server.js +1 -0
- package/dist/host/src/rpc/server.js.map +1 -1
- package/dist/host/src/screen-viewer.d.ts +1 -0
- package/dist/host/src/screen-viewer.js +784 -0
- package/dist/host/src/screen-viewer.js.map +1 -0
- package/dist/host/src/screen.d.ts +20 -0
- package/dist/host/src/screen.js +147 -72
- package/dist/host/src/screen.js.map +1 -1
- package/dist/host/tsconfig.tsbuildinfo +1 -1
- package/dist/wire/src/rpc.d.ts +41 -0
- package/dist/wire/src/rpc.js +21 -0
- package/dist/wire/src/rpc.js.map +1 -1
- package/helper/LinkShell.app/Contents/Info.plist +19 -0
- package/helper/LinkShell.app/Contents/MacOS/LinkShell +0 -0
- package/helper/LinkShell.app/Contents/Resources/AppIcon.icns +0 -0
- package/helper/LinkShell.app/Contents/_CodeSignature/CodeResources +128 -0
- package/package.json +4 -3
|
@@ -86,9 +86,9 @@ export declare const transcriptTimes: WeakMap<{
|
|
|
86
86
|
};
|
|
87
87
|
parentToolCallId?: string | undefined;
|
|
88
88
|
} | {
|
|
89
|
-
status: "pending" | "in_progress" | "completed" | "failed";
|
|
90
|
-
kind: "other" | "read" | "edit" | "delete" | "move" | "search" | "execute" | "think" | "fetch";
|
|
91
89
|
sessionUpdate: "tool_call";
|
|
90
|
+
status: "pending" | "in_progress" | "completed" | "failed";
|
|
91
|
+
kind: "read" | "edit" | "delete" | "move" | "search" | "execute" | "think" | "fetch" | "other";
|
|
92
92
|
toolCallId: string;
|
|
93
93
|
title: string;
|
|
94
94
|
content?: ({
|
|
@@ -135,9 +135,9 @@ export declare const transcriptTimes: WeakMap<{
|
|
|
135
135
|
} | {
|
|
136
136
|
type: "subagent";
|
|
137
137
|
action: "message" | "spawn" | "wait" | "stop" | "resume" | "list";
|
|
138
|
-
model?: string | undefined;
|
|
139
138
|
task?: string | undefined;
|
|
140
139
|
agentType?: string | undefined;
|
|
140
|
+
model?: string | undefined;
|
|
141
141
|
} | {
|
|
142
142
|
type: "skill";
|
|
143
143
|
name: string;
|
|
@@ -202,9 +202,9 @@ export declare const transcriptTimes: WeakMap<{
|
|
|
202
202
|
} | {
|
|
203
203
|
type: "subagent";
|
|
204
204
|
action: "message" | "spawn" | "wait" | "stop" | "resume" | "list";
|
|
205
|
-
model?: string | undefined;
|
|
206
205
|
task?: string | undefined;
|
|
207
206
|
agentType?: string | undefined;
|
|
207
|
+
model?: string | undefined;
|
|
208
208
|
} | {
|
|
209
209
|
type: "skill";
|
|
210
210
|
name: string;
|
|
@@ -228,12 +228,12 @@ export declare const transcriptTimes: WeakMap<{
|
|
|
228
228
|
appendOutput?: string | undefined;
|
|
229
229
|
rawOutput?: unknown;
|
|
230
230
|
} | {
|
|
231
|
+
sessionUpdate: "plan";
|
|
231
232
|
entries: {
|
|
232
233
|
status: "pending" | "in_progress" | "completed";
|
|
233
234
|
content: string;
|
|
234
235
|
priority: "high" | "medium" | "low";
|
|
235
236
|
}[];
|
|
236
|
-
sessionUpdate: "plan";
|
|
237
237
|
} | {
|
|
238
238
|
sessionUpdate: "available_commands_update";
|
|
239
239
|
availableCommands: {
|
|
@@ -246,8 +246,8 @@ export declare const transcriptTimes: WeakMap<{
|
|
|
246
246
|
currentModeId: string;
|
|
247
247
|
} | {
|
|
248
248
|
sessionUpdate: "session_info_update";
|
|
249
|
-
model?: string | undefined;
|
|
250
249
|
title?: string | undefined;
|
|
250
|
+
model?: string | undefined;
|
|
251
251
|
} | {
|
|
252
252
|
sessionUpdate: "usage_update";
|
|
253
253
|
usedTokens?: number | undefined;
|
|
@@ -258,6 +258,7 @@ export declare const transcriptTimes: WeakMap<{
|
|
|
258
258
|
role: "agent" | "thought" | "user";
|
|
259
259
|
parentToolCallId?: string | undefined;
|
|
260
260
|
} | {
|
|
261
|
+
sessionUpdate: "ls_config";
|
|
261
262
|
options: {
|
|
262
263
|
values: {
|
|
263
264
|
value: string;
|
|
@@ -266,28 +267,27 @@ export declare const transcriptTimes: WeakMap<{
|
|
|
266
267
|
}[];
|
|
267
268
|
name: string;
|
|
268
269
|
id: string;
|
|
269
|
-
category: "
|
|
270
|
+
category: "other" | "model" | "mode" | "effort";
|
|
270
271
|
current: string;
|
|
271
272
|
}[];
|
|
272
|
-
sessionUpdate: "ls_config";
|
|
273
273
|
} | {
|
|
274
274
|
sessionUpdate: "ls_notice";
|
|
275
275
|
title: string;
|
|
276
276
|
level: "info" | "warning";
|
|
277
277
|
detail?: string | undefined;
|
|
278
278
|
} | {
|
|
279
|
-
state: "started" | "ended";
|
|
280
279
|
sessionUpdate: "ls_turn";
|
|
280
|
+
state: "started" | "ended";
|
|
281
281
|
parentToolCallId?: string | undefined;
|
|
282
282
|
turnId?: string | undefined;
|
|
283
283
|
stopReason?: "end_turn" | "cancelled" | "error" | "max_tokens" | "refusal" | undefined;
|
|
284
284
|
} | {
|
|
285
|
+
sessionUpdate: "ls_permission";
|
|
285
286
|
options: {
|
|
286
287
|
name: string;
|
|
287
288
|
kind: "allow_once" | "allow_always" | "reject_once" | "reject_always";
|
|
288
289
|
optionId: string;
|
|
289
290
|
}[];
|
|
290
|
-
sessionUpdate: "ls_permission";
|
|
291
291
|
title: string;
|
|
292
292
|
requestId: string;
|
|
293
293
|
toolCallId?: string | undefined;
|
|
@@ -317,15 +317,15 @@ export declare const transcriptTimes: WeakMap<{
|
|
|
317
317
|
}[] | undefined;
|
|
318
318
|
} | {
|
|
319
319
|
sessionUpdate: "ls_driver";
|
|
320
|
-
driver: "
|
|
320
|
+
driver: "desktop" | "remote" | "none";
|
|
321
321
|
deviceId?: string | undefined;
|
|
322
322
|
} | {
|
|
323
|
-
state: "error" | "idle" | "running" | "waiting" | "offline";
|
|
324
323
|
sessionUpdate: "ls_status";
|
|
324
|
+
state: "error" | "idle" | "running" | "waiting" | "offline";
|
|
325
325
|
} | {
|
|
326
|
+
sessionUpdate: "ls_error";
|
|
326
327
|
code: string;
|
|
327
328
|
message: string;
|
|
328
|
-
sessionUpdate: "ls_error";
|
|
329
329
|
hint?: string | undefined;
|
|
330
330
|
}, number>;
|
|
331
331
|
/**
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const INPUT_HELPER_SOURCE: string;
|
|
@@ -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":["../../../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"}
|