@jian-ai/remote-control 0.7.2 → 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.
- package/README.md +3 -1
- package/bin/computer-macos.mjs +545 -0
- package/bin/jian-remote.mjs +55 -6
- package/macos/build-helper.sh +46 -0
- package/macos/computer-helper +0 -0
- package/macos/computer-helper.swift +736 -0
- package/package.json +2 -1
|
@@ -0,0 +1,736 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Jian Remote Control — macOS computer-use helper
|
|
3
|
+
|
|
4
|
+
Long-lived AppKit process: overlay pointer + synthetic mouse/keyboard via CoreGraphics.
|
|
5
|
+
Protocol: newline-delimited JSON on stdin → newline-delimited JSON on stdout (flush each).
|
|
6
|
+
Errors go to stderr in English only.
|
|
7
|
+
|
|
8
|
+
Published npm packages ship a prebuilt universal binary (Publish CI). From source on a Mac:
|
|
9
|
+
./build-helper.sh
|
|
10
|
+
# or: swiftc -O -framework AppKit -framework Carbon -o computer-helper computer-helper.swift
|
|
11
|
+
|
|
12
|
+
Run as a child of the Node worker; keep stdin/stdout pipes open for the session.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import AppKit
|
|
16
|
+
import Carbon.HIToolbox
|
|
17
|
+
import CoreGraphics
|
|
18
|
+
import Foundation
|
|
19
|
+
|
|
20
|
+
// MARK: - JSON I/O
|
|
21
|
+
|
|
22
|
+
private func writeResponse(_ object: [String: Any]) {
|
|
23
|
+
guard JSONSerialization.isValidJSONObject(object),
|
|
24
|
+
let data = try? JSONSerialization.data(withJSONObject: object, options: []),
|
|
25
|
+
var line = String(data: data, encoding: .utf8)
|
|
26
|
+
else {
|
|
27
|
+
fputs("computer-helper: failed to encode response JSON\n", stderr)
|
|
28
|
+
fflush(stderr)
|
|
29
|
+
return
|
|
30
|
+
}
|
|
31
|
+
line.append("\n")
|
|
32
|
+
FileHandle.standardOutput.write(Data(line.utf8))
|
|
33
|
+
fflush(stdout)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
private func writeOk(_ extra: [String: Any] = [:]) {
|
|
37
|
+
var body: [String: Any] = ["ok": true]
|
|
38
|
+
for (key, value) in extra {
|
|
39
|
+
body[key] = value
|
|
40
|
+
}
|
|
41
|
+
writeResponse(body)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
private func writeError(_ message: String) {
|
|
45
|
+
writeResponse(["ok": false, "error": message])
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
private func logError(_ message: String) {
|
|
49
|
+
fputs("computer-helper: \(message)\n", stderr)
|
|
50
|
+
fflush(stderr)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
private func parseJSONLine(_ line: String) -> [String: Any]? {
|
|
54
|
+
let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
55
|
+
guard !trimmed.isEmpty, let data = trimmed.data(using: .utf8) else { return nil }
|
|
56
|
+
guard let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
|
57
|
+
return nil
|
|
58
|
+
}
|
|
59
|
+
return object
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
private func doubleValue(_ object: [String: Any], _ key: String) -> Double? {
|
|
63
|
+
if let n = object[key] as? Double { return n }
|
|
64
|
+
if let n = object[key] as? Int { return Double(n) }
|
|
65
|
+
if let n = object[key] as? NSNumber { return n.doubleValue }
|
|
66
|
+
return nil
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
private func intValue(_ object: [String: Any], _ key: String) -> Int? {
|
|
70
|
+
if let n = object[key] as? Int { return n }
|
|
71
|
+
if let n = object[key] as? Double { return Int(n) }
|
|
72
|
+
if let n = object[key] as? NSNumber { return n.intValue }
|
|
73
|
+
return nil
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// MARK: - Colors (Jian UI)
|
|
77
|
+
|
|
78
|
+
private let jianCream = NSColor(srgbRed: 0xF5 / 255, green: 0xF5 / 255, blue: 0xF3 / 255, alpha: 1)
|
|
79
|
+
private let jianInk = NSColor(srgbRed: 0x17 / 255, green: 0x17 / 255, blue: 0x16 / 255, alpha: 1)
|
|
80
|
+
|
|
81
|
+
private let sessionAlpha: CGFloat = 0.92
|
|
82
|
+
private let idleAlpha: CGFloat = 0.18
|
|
83
|
+
private let pointerSize: CGFloat = 32
|
|
84
|
+
/// Tip of the wing relative to the view's top-left (hotspot for cursor placement).
|
|
85
|
+
private let hotspot = CGPoint(x: 4, y: 4)
|
|
86
|
+
|
|
87
|
+
// MARK: - Overlay view
|
|
88
|
+
|
|
89
|
+
private final class JianPointerView: NSView {
|
|
90
|
+
private var rippleProgress: CGFloat = 0
|
|
91
|
+
private var rippleVisible = false
|
|
92
|
+
private var fillPulse: CGFloat = 0
|
|
93
|
+
private var rippleTimer: Timer?
|
|
94
|
+
|
|
95
|
+
override var isFlipped: Bool { true }
|
|
96
|
+
|
|
97
|
+
override func draw(_ dirtyRect: NSRect) {
|
|
98
|
+
guard let ctx = NSGraphicsContext.current?.cgContext else { return }
|
|
99
|
+
|
|
100
|
+
if rippleVisible {
|
|
101
|
+
let maxRadius = pointerSize * 0.95
|
|
102
|
+
let radius = 4 + maxRadius * rippleProgress
|
|
103
|
+
let alpha = max(0, 1 - rippleProgress)
|
|
104
|
+
ctx.saveGState()
|
|
105
|
+
ctx.setStrokeColor(jianInk.withAlphaComponent(0.55 * alpha).cgColor)
|
|
106
|
+
ctx.setLineWidth(1.5)
|
|
107
|
+
ctx.strokeEllipse(in: CGRect(
|
|
108
|
+
x: hotspot.x - radius,
|
|
109
|
+
y: hotspot.y - radius,
|
|
110
|
+
width: radius * 2,
|
|
111
|
+
height: radius * 2
|
|
112
|
+
))
|
|
113
|
+
ctx.restoreGState()
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
let path = Self.wingPath(in: bounds)
|
|
117
|
+
ctx.saveGState()
|
|
118
|
+
ctx.setShadow(offset: CGSize(width: 0, height: 1.5), blur: 4, color: NSColor.black.withAlphaComponent(0.28).cgColor)
|
|
119
|
+
let fillAlpha = 1 - fillPulse * 0.35
|
|
120
|
+
jianCream.withAlphaComponent(fillAlpha).setFill()
|
|
121
|
+
path.fill()
|
|
122
|
+
ctx.restoreGState()
|
|
123
|
+
|
|
124
|
+
jianInk.setStroke()
|
|
125
|
+
path.lineWidth = 2
|
|
126
|
+
path.lineJoinStyle = .round
|
|
127
|
+
path.lineCapStyle = .round
|
|
128
|
+
path.stroke()
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/// Ascending wing mark inspired by the Jian brand path (favicon / mark.tsx).
|
|
132
|
+
private static func wingPath(in bounds: NSRect) -> NSBezierPath {
|
|
133
|
+
let s = min(bounds.width, bounds.height)
|
|
134
|
+
let path = NSBezierPath()
|
|
135
|
+
// Tip near top-left (hotspot), body sweeping down-right then curling back — one ascending wing.
|
|
136
|
+
path.move(to: CGPoint(x: s * 0.12, y: s * 0.10))
|
|
137
|
+
path.curve(
|
|
138
|
+
to: CGPoint(x: s * 0.72, y: s * 0.18),
|
|
139
|
+
controlPoint1: CGPoint(x: s * 0.28, y: s * 0.02),
|
|
140
|
+
controlPoint2: CGPoint(x: s * 0.52, y: s * 0.06)
|
|
141
|
+
)
|
|
142
|
+
path.curve(
|
|
143
|
+
to: CGPoint(x: s * 0.88, y: s * 0.55),
|
|
144
|
+
controlPoint1: CGPoint(x: s * 0.88, y: s * 0.28),
|
|
145
|
+
controlPoint2: CGPoint(x: s * 0.94, y: s * 0.42)
|
|
146
|
+
)
|
|
147
|
+
path.curve(
|
|
148
|
+
to: CGPoint(x: s * 0.42, y: s * 0.88),
|
|
149
|
+
controlPoint1: CGPoint(x: s * 0.80, y: s * 0.72),
|
|
150
|
+
controlPoint2: CGPoint(x: s * 0.58, y: s * 0.90)
|
|
151
|
+
)
|
|
152
|
+
path.curve(
|
|
153
|
+
to: CGPoint(x: s * 0.18, y: s * 0.48),
|
|
154
|
+
controlPoint1: CGPoint(x: s * 0.26, y: s * 0.86),
|
|
155
|
+
controlPoint2: CGPoint(x: s * 0.12, y: s * 0.68)
|
|
156
|
+
)
|
|
157
|
+
path.curve(
|
|
158
|
+
to: CGPoint(x: s * 0.12, y: s * 0.10),
|
|
159
|
+
controlPoint1: CGPoint(x: s * 0.22, y: s * 0.32),
|
|
160
|
+
controlPoint2: CGPoint(x: s * 0.08, y: s * 0.20)
|
|
161
|
+
)
|
|
162
|
+
path.close()
|
|
163
|
+
return path
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
func flashClick(duration: TimeInterval = 0.2) {
|
|
167
|
+
rippleTimer?.invalidate()
|
|
168
|
+
rippleVisible = true
|
|
169
|
+
rippleProgress = 0
|
|
170
|
+
fillPulse = 1
|
|
171
|
+
needsDisplay = true
|
|
172
|
+
|
|
173
|
+
let start = Date()
|
|
174
|
+
rippleTimer = Timer.scheduledTimer(withTimeInterval: 1.0 / 60.0, repeats: true) { [weak self] timer in
|
|
175
|
+
guard let self else {
|
|
176
|
+
timer.invalidate()
|
|
177
|
+
return
|
|
178
|
+
}
|
|
179
|
+
let t = min(1, Date().timeIntervalSince(start) / duration)
|
|
180
|
+
self.rippleProgress = CGFloat(t)
|
|
181
|
+
self.fillPulse = CGFloat(1 - t)
|
|
182
|
+
self.needsDisplay = true
|
|
183
|
+
if t >= 1 {
|
|
184
|
+
timer.invalidate()
|
|
185
|
+
self.rippleVisible = false
|
|
186
|
+
self.fillPulse = 0
|
|
187
|
+
self.needsDisplay = true
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
if let rippleTimer {
|
|
191
|
+
RunLoop.main.add(rippleTimer, forMode: .common)
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// MARK: - Overlay window
|
|
197
|
+
|
|
198
|
+
private final class NonActivatingWindow: NSWindow {
|
|
199
|
+
override var canBecomeKey: Bool { false }
|
|
200
|
+
override var canBecomeMain: Bool { false }
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
private final class OverlayController {
|
|
204
|
+
static let shared = OverlayController()
|
|
205
|
+
|
|
206
|
+
private var window: NonActivatingWindow?
|
|
207
|
+
private var pointerView: JianPointerView?
|
|
208
|
+
/// Current tip/hotspot in global top-left Quartz coordinates.
|
|
209
|
+
private var tipPoint = CGPoint.zero
|
|
210
|
+
|
|
211
|
+
private init() {}
|
|
212
|
+
|
|
213
|
+
private func ensureWindow() {
|
|
214
|
+
if window != nil { return }
|
|
215
|
+
|
|
216
|
+
let view = JianPointerView(frame: NSRect(x: 0, y: 0, width: pointerSize, height: pointerSize))
|
|
217
|
+
view.wantsLayer = true
|
|
218
|
+
|
|
219
|
+
let win = NonActivatingWindow(
|
|
220
|
+
contentRect: NSRect(x: 0, y: 0, width: pointerSize, height: pointerSize),
|
|
221
|
+
styleMask: .borderless,
|
|
222
|
+
backing: .buffered,
|
|
223
|
+
defer: false
|
|
224
|
+
)
|
|
225
|
+
win.isOpaque = false
|
|
226
|
+
win.backgroundColor = .clear
|
|
227
|
+
win.hasShadow = false
|
|
228
|
+
win.level = .statusBar
|
|
229
|
+
win.ignoresMouseEvents = true
|
|
230
|
+
win.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary]
|
|
231
|
+
win.isReleasedWhenClosed = false
|
|
232
|
+
win.contentView = view
|
|
233
|
+
win.alphaValue = 0
|
|
234
|
+
win.orderOut(nil)
|
|
235
|
+
|
|
236
|
+
pointerView = view
|
|
237
|
+
window = win
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
func ensureSession() {
|
|
241
|
+
ensureWindow()
|
|
242
|
+
guard let window else { return }
|
|
243
|
+
window.alphaValue = sessionAlpha
|
|
244
|
+
positionWindow()
|
|
245
|
+
window.orderFrontRegardless()
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
func setIdle() {
|
|
249
|
+
ensureWindow()
|
|
250
|
+
guard let window else { return }
|
|
251
|
+
window.alphaValue = idleAlpha
|
|
252
|
+
positionWindow()
|
|
253
|
+
window.orderFrontRegardless()
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
func endSession() {
|
|
257
|
+
guard let window else { return }
|
|
258
|
+
window.orderOut(nil)
|
|
259
|
+
window.alphaValue = 0
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
func moveTip(to globalTopLeft: CGPoint) {
|
|
263
|
+
tipPoint = globalTopLeft
|
|
264
|
+
ensureWindow()
|
|
265
|
+
positionWindow()
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
func flashClick() {
|
|
269
|
+
ensureWindow()
|
|
270
|
+
pointerView?.flashClick()
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
func currentTip() -> CGPoint { tipPoint }
|
|
274
|
+
|
|
275
|
+
private func positionWindow() {
|
|
276
|
+
guard let window else { return }
|
|
277
|
+
// Quartz global: origin top-left of primary, Y down. Cocoa: origin bottom-left, Y up.
|
|
278
|
+
let mainHeight = NSScreen.screens.first?.frame.height ?? NSScreen.main?.frame.height ?? 0
|
|
279
|
+
let tipCocoaY = mainHeight - tipPoint.y
|
|
280
|
+
// Window origin is bottom-left; hotspot is hotspot.y down from the top of the view.
|
|
281
|
+
let origin = NSPoint(
|
|
282
|
+
x: tipPoint.x - hotspot.x,
|
|
283
|
+
y: tipCocoaY - (pointerSize - hotspot.y)
|
|
284
|
+
)
|
|
285
|
+
window.setFrameOrigin(origin)
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// MARK: - Coordinate helpers
|
|
290
|
+
|
|
291
|
+
private func cgPointFromTopLeft(_ x: Double, _ y: Double) -> CGPoint {
|
|
292
|
+
CGPoint(x: x, y: y)
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// MARK: - Mouse / keyboard
|
|
296
|
+
|
|
297
|
+
private func postMouseMove(to point: CGPoint) {
|
|
298
|
+
OverlayController.shared.moveTip(to: point)
|
|
299
|
+
guard let event = CGEvent(
|
|
300
|
+
mouseEventSource: nil,
|
|
301
|
+
mouseType: .mouseMoved,
|
|
302
|
+
mouseCursorPosition: point,
|
|
303
|
+
mouseButton: .left
|
|
304
|
+
) else {
|
|
305
|
+
logError("could not create mouseMoved event")
|
|
306
|
+
return
|
|
307
|
+
}
|
|
308
|
+
event.post(tap: .cghidEventTap)
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
private func postMouseClick(button: CGMouseButton, count: Int64) {
|
|
312
|
+
OverlayController.shared.flashClick()
|
|
313
|
+
let point = OverlayController.shared.currentTip()
|
|
314
|
+
let downType: CGEventType = button == .right ? .rightMouseDown : .leftMouseDown
|
|
315
|
+
let upType: CGEventType = button == .right ? .rightMouseUp : .leftMouseUp
|
|
316
|
+
|
|
317
|
+
for clickIndex in 1...max(1, count) {
|
|
318
|
+
if let down = CGEvent(
|
|
319
|
+
mouseEventSource: nil,
|
|
320
|
+
mouseType: downType,
|
|
321
|
+
mouseCursorPosition: point,
|
|
322
|
+
mouseButton: button
|
|
323
|
+
) {
|
|
324
|
+
down.setIntegerValueField(.mouseEventClickState, value: clickIndex)
|
|
325
|
+
down.post(tap: .cghidEventTap)
|
|
326
|
+
}
|
|
327
|
+
if let up = CGEvent(
|
|
328
|
+
mouseEventSource: nil,
|
|
329
|
+
mouseType: upType,
|
|
330
|
+
mouseCursorPosition: point,
|
|
331
|
+
mouseButton: button
|
|
332
|
+
) {
|
|
333
|
+
up.setIntegerValueField(.mouseEventClickState, value: clickIndex)
|
|
334
|
+
up.post(tap: .cghidEventTap)
|
|
335
|
+
}
|
|
336
|
+
if clickIndex < count {
|
|
337
|
+
Thread.sleep(forTimeInterval: 0.05)
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
private func postDrag(from: CGPoint, to: CGPoint) {
|
|
343
|
+
postMouseMove(to: from)
|
|
344
|
+
OverlayController.shared.flashClick()
|
|
345
|
+
Thread.sleep(forTimeInterval: 0.02)
|
|
346
|
+
|
|
347
|
+
if let down = CGEvent(
|
|
348
|
+
mouseEventSource: nil,
|
|
349
|
+
mouseType: .leftMouseDown,
|
|
350
|
+
mouseCursorPosition: from,
|
|
351
|
+
mouseButton: .left
|
|
352
|
+
) {
|
|
353
|
+
down.post(tap: .cghidEventTap)
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
let steps = 12
|
|
357
|
+
for i in 1...steps {
|
|
358
|
+
let t = CGFloat(i) / CGFloat(steps)
|
|
359
|
+
let p = CGPoint(
|
|
360
|
+
x: from.x + (to.x - from.x) * t,
|
|
361
|
+
y: from.y + (to.y - from.y) * t
|
|
362
|
+
)
|
|
363
|
+
OverlayController.shared.moveTip(to: p)
|
|
364
|
+
if let drag = CGEvent(
|
|
365
|
+
mouseEventSource: nil,
|
|
366
|
+
mouseType: .leftMouseDragged,
|
|
367
|
+
mouseCursorPosition: p,
|
|
368
|
+
mouseButton: .left
|
|
369
|
+
) {
|
|
370
|
+
drag.post(tap: .cghidEventTap)
|
|
371
|
+
}
|
|
372
|
+
Thread.sleep(forTimeInterval: 0.008)
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
if let up = CGEvent(
|
|
376
|
+
mouseEventSource: nil,
|
|
377
|
+
mouseType: .leftMouseUp,
|
|
378
|
+
mouseCursorPosition: to,
|
|
379
|
+
mouseButton: .left
|
|
380
|
+
) {
|
|
381
|
+
up.post(tap: .cghidEventTap)
|
|
382
|
+
}
|
|
383
|
+
OverlayController.shared.moveTip(to: to)
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
private func postScroll(at point: CGPoint, direction: String, amount: Int) {
|
|
387
|
+
postMouseMove(to: point)
|
|
388
|
+
let n = max(1, amount)
|
|
389
|
+
var dx = 0
|
|
390
|
+
var dy = 0
|
|
391
|
+
switch direction {
|
|
392
|
+
case "up": dy = 1
|
|
393
|
+
case "down": dy = -1
|
|
394
|
+
case "left": dx = 1
|
|
395
|
+
case "right": dx = -1
|
|
396
|
+
default:
|
|
397
|
+
logError("unknown scroll direction: \(direction)")
|
|
398
|
+
return
|
|
399
|
+
}
|
|
400
|
+
for _ in 0..<n {
|
|
401
|
+
if let event = CGEvent(
|
|
402
|
+
scrollWheelEvent2Source: nil,
|
|
403
|
+
units: .line,
|
|
404
|
+
wheelCount: 2,
|
|
405
|
+
wheel1: Int32(dy),
|
|
406
|
+
wheel2: Int32(dx),
|
|
407
|
+
wheel3: 0
|
|
408
|
+
) {
|
|
409
|
+
event.post(tap: .cghidEventTap)
|
|
410
|
+
}
|
|
411
|
+
Thread.sleep(forTimeInterval: 0.02)
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// MARK: - Keycodes (US ANSI)
|
|
416
|
+
|
|
417
|
+
private let letterKeyCodes: [Character: CGKeyCode] = [
|
|
418
|
+
"a": CGKeyCode(kVK_ANSI_A), "b": CGKeyCode(kVK_ANSI_B), "c": CGKeyCode(kVK_ANSI_C),
|
|
419
|
+
"d": CGKeyCode(kVK_ANSI_D), "e": CGKeyCode(kVK_ANSI_E), "f": CGKeyCode(kVK_ANSI_F),
|
|
420
|
+
"g": CGKeyCode(kVK_ANSI_G), "h": CGKeyCode(kVK_ANSI_H), "i": CGKeyCode(kVK_ANSI_I),
|
|
421
|
+
"j": CGKeyCode(kVK_ANSI_J), "k": CGKeyCode(kVK_ANSI_K), "l": CGKeyCode(kVK_ANSI_L),
|
|
422
|
+
"m": CGKeyCode(kVK_ANSI_M), "n": CGKeyCode(kVK_ANSI_N), "o": CGKeyCode(kVK_ANSI_O),
|
|
423
|
+
"p": CGKeyCode(kVK_ANSI_P), "q": CGKeyCode(kVK_ANSI_Q), "r": CGKeyCode(kVK_ANSI_R),
|
|
424
|
+
"s": CGKeyCode(kVK_ANSI_S), "t": CGKeyCode(kVK_ANSI_T), "u": CGKeyCode(kVK_ANSI_U),
|
|
425
|
+
"v": CGKeyCode(kVK_ANSI_V), "w": CGKeyCode(kVK_ANSI_W), "x": CGKeyCode(kVK_ANSI_X),
|
|
426
|
+
"y": CGKeyCode(kVK_ANSI_Y), "z": CGKeyCode(kVK_ANSI_Z),
|
|
427
|
+
]
|
|
428
|
+
|
|
429
|
+
private let digitKeyCodes: [Character: CGKeyCode] = [
|
|
430
|
+
"0": CGKeyCode(kVK_ANSI_0), "1": CGKeyCode(kVK_ANSI_1), "2": CGKeyCode(kVK_ANSI_2),
|
|
431
|
+
"3": CGKeyCode(kVK_ANSI_3), "4": CGKeyCode(kVK_ANSI_4), "5": CGKeyCode(kVK_ANSI_5),
|
|
432
|
+
"6": CGKeyCode(kVK_ANSI_6), "7": CGKeyCode(kVK_ANSI_7), "8": CGKeyCode(kVK_ANSI_8),
|
|
433
|
+
"9": CGKeyCode(kVK_ANSI_9),
|
|
434
|
+
]
|
|
435
|
+
|
|
436
|
+
private let namedKeyCodes: [String: CGKeyCode] = [
|
|
437
|
+
"return": CGKeyCode(kVK_Return),
|
|
438
|
+
"enter": CGKeyCode(kVK_Return),
|
|
439
|
+
"tab": CGKeyCode(kVK_Tab),
|
|
440
|
+
"escape": CGKeyCode(kVK_Escape),
|
|
441
|
+
"esc": CGKeyCode(kVK_Escape),
|
|
442
|
+
"delete": CGKeyCode(kVK_ForwardDelete),
|
|
443
|
+
"backspace": CGKeyCode(kVK_Delete),
|
|
444
|
+
"space": CGKeyCode(kVK_Space),
|
|
445
|
+
"up": CGKeyCode(kVK_UpArrow),
|
|
446
|
+
"down": CGKeyCode(kVK_DownArrow),
|
|
447
|
+
"left": CGKeyCode(kVK_LeftArrow),
|
|
448
|
+
"right": CGKeyCode(kVK_RightArrow),
|
|
449
|
+
"home": CGKeyCode(kVK_Home),
|
|
450
|
+
"end": CGKeyCode(kVK_End),
|
|
451
|
+
"pageup": CGKeyCode(kVK_PageUp),
|
|
452
|
+
"pagedown": CGKeyCode(kVK_PageDown),
|
|
453
|
+
]
|
|
454
|
+
|
|
455
|
+
private func postKeyCode(_ keyCode: CGKeyCode, flags: CGEventFlags = []) {
|
|
456
|
+
guard let down = CGEvent(keyboardEventSource: nil, virtualKey: keyCode, keyDown: true),
|
|
457
|
+
let up = CGEvent(keyboardEventSource: nil, virtualKey: keyCode, keyDown: false)
|
|
458
|
+
else {
|
|
459
|
+
logError("could not create keyboard event for keyCode \(keyCode)")
|
|
460
|
+
return
|
|
461
|
+
}
|
|
462
|
+
down.flags = flags
|
|
463
|
+
up.flags = flags
|
|
464
|
+
down.post(tap: .cghidEventTap)
|
|
465
|
+
up.post(tap: .cghidEventTap)
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
private func postUnicodeChar(_ scalar: UniChar) {
|
|
469
|
+
var chars = [scalar]
|
|
470
|
+
guard let down = CGEvent(keyboardEventSource: nil, virtualKey: 0, keyDown: true),
|
|
471
|
+
let up = CGEvent(keyboardEventSource: nil, virtualKey: 0, keyDown: false)
|
|
472
|
+
else {
|
|
473
|
+
logError("could not create unicode keyboard event")
|
|
474
|
+
return
|
|
475
|
+
}
|
|
476
|
+
down.keyboardSetUnicodeString(stringLength: 1, unicodeString: &chars)
|
|
477
|
+
up.keyboardSetUnicodeString(stringLength: 1, unicodeString: &chars)
|
|
478
|
+
down.post(tap: .cghidEventTap)
|
|
479
|
+
up.post(tap: .cghidEventTap)
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
private func typeText(_ text: String) {
|
|
483
|
+
// Iterate UTF-16 code units; map Return/Tab to virtual keys when present as characters.
|
|
484
|
+
let utf16 = Array(text.utf16)
|
|
485
|
+
var i = 0
|
|
486
|
+
while i < utf16.count {
|
|
487
|
+
let unit = utf16[i]
|
|
488
|
+
if unit == 0x000A || unit == 0x000D {
|
|
489
|
+
postKeyCode(CGKeyCode(kVK_Return))
|
|
490
|
+
if unit == 0x000D, i + 1 < utf16.count, utf16[i + 1] == 0x000A {
|
|
491
|
+
i += 1
|
|
492
|
+
}
|
|
493
|
+
} else if unit == 0x0009 {
|
|
494
|
+
postKeyCode(CGKeyCode(kVK_Tab))
|
|
495
|
+
} else {
|
|
496
|
+
postUnicodeChar(unit)
|
|
497
|
+
}
|
|
498
|
+
i += 1
|
|
499
|
+
Thread.sleep(forTimeInterval: 0.005)
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
private func parseAndPostKey(_ keySpec: String) -> Bool {
|
|
504
|
+
let parts = keySpec.split(separator: "+").map { $0.trimmingCharacters(in: .whitespaces).lowercased() }
|
|
505
|
+
guard !parts.isEmpty else { return false }
|
|
506
|
+
|
|
507
|
+
var flags: CGEventFlags = []
|
|
508
|
+
var keyToken: String?
|
|
509
|
+
|
|
510
|
+
for part in parts {
|
|
511
|
+
switch part {
|
|
512
|
+
case "cmd", "command", "meta", "super":
|
|
513
|
+
flags.insert(.maskCommand)
|
|
514
|
+
case "ctrl", "control":
|
|
515
|
+
flags.insert(.maskControl)
|
|
516
|
+
case "alt", "option", "opt":
|
|
517
|
+
flags.insert(.maskAlternate)
|
|
518
|
+
case "shift":
|
|
519
|
+
flags.insert(.maskShift)
|
|
520
|
+
default:
|
|
521
|
+
if keyToken != nil {
|
|
522
|
+
logError("ambiguous key chord: \(keySpec)")
|
|
523
|
+
return false
|
|
524
|
+
}
|
|
525
|
+
keyToken = part
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
guard let token = keyToken else {
|
|
530
|
+
logError("key chord has no key: \(keySpec)")
|
|
531
|
+
return false
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
if let named = namedKeyCodes[token] {
|
|
535
|
+
postKeyCode(named, flags: flags)
|
|
536
|
+
return true
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
if token.count == 1, let ch = token.first {
|
|
540
|
+
if let code = letterKeyCodes[ch] {
|
|
541
|
+
postKeyCode(code, flags: flags)
|
|
542
|
+
return true
|
|
543
|
+
}
|
|
544
|
+
if let code = digitKeyCodes[ch] {
|
|
545
|
+
postKeyCode(code, flags: flags)
|
|
546
|
+
return true
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
// Function keys F1–F12
|
|
551
|
+
if token.hasPrefix("f"), let n = Int(token.dropFirst()), (1...12).contains(n) {
|
|
552
|
+
let base = kVK_F1
|
|
553
|
+
postKeyCode(CGKeyCode(base + (n - 1)), flags: flags)
|
|
554
|
+
return true
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
logError("unknown key: \(keySpec)")
|
|
558
|
+
return false
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
// MARK: - Geometry
|
|
562
|
+
|
|
563
|
+
private func geometryResponse() -> [String: Any] {
|
|
564
|
+
let screen = NSScreen.screens.first ?? NSScreen.main
|
|
565
|
+
guard let screen else {
|
|
566
|
+
return ["ok": false, "error": "no display available"]
|
|
567
|
+
}
|
|
568
|
+
let frame = screen.frame
|
|
569
|
+
let scale = screen.backingScaleFactor
|
|
570
|
+
// Primary display: Cocoa origin is bottom-left of main screen; Quartz global top-left origin is (0,0).
|
|
571
|
+
return [
|
|
572
|
+
"ok": true,
|
|
573
|
+
"originX": 0,
|
|
574
|
+
"originY": 0,
|
|
575
|
+
"pointWidth": frame.width,
|
|
576
|
+
"pointHeight": frame.height,
|
|
577
|
+
"pixelWidth": frame.width * scale,
|
|
578
|
+
"pixelHeight": frame.height * scale,
|
|
579
|
+
"scaleFactor": scale,
|
|
580
|
+
]
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
// MARK: - Command dispatch
|
|
584
|
+
|
|
585
|
+
private func handleCommand(_ object: [String: Any]) {
|
|
586
|
+
guard let cmd = object["cmd"] as? String else {
|
|
587
|
+
writeError("missing cmd")
|
|
588
|
+
return
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
switch cmd {
|
|
592
|
+
case "ping":
|
|
593
|
+
writeOk()
|
|
594
|
+
|
|
595
|
+
case "geometry":
|
|
596
|
+
writeResponse(geometryResponse())
|
|
597
|
+
|
|
598
|
+
case "ensureSession":
|
|
599
|
+
OverlayController.shared.ensureSession()
|
|
600
|
+
writeOk()
|
|
601
|
+
|
|
602
|
+
case "setIdle":
|
|
603
|
+
OverlayController.shared.setIdle()
|
|
604
|
+
writeOk()
|
|
605
|
+
|
|
606
|
+
case "endSession":
|
|
607
|
+
OverlayController.shared.endSession()
|
|
608
|
+
writeOk()
|
|
609
|
+
|
|
610
|
+
case "move":
|
|
611
|
+
guard let x = doubleValue(object, "pointX"), let y = doubleValue(object, "pointY") else {
|
|
612
|
+
writeError("move requires pointX and pointY")
|
|
613
|
+
return
|
|
614
|
+
}
|
|
615
|
+
postMouseMove(to: cgPointFromTopLeft(x, y))
|
|
616
|
+
writeOk()
|
|
617
|
+
|
|
618
|
+
case "click":
|
|
619
|
+
let buttonName = (object["button"] as? String ?? "left").lowercased()
|
|
620
|
+
let count = Int64(intValue(object, "count") ?? 1)
|
|
621
|
+
let button: CGMouseButton
|
|
622
|
+
switch buttonName {
|
|
623
|
+
case "left": button = .left
|
|
624
|
+
case "right": button = .right
|
|
625
|
+
default:
|
|
626
|
+
writeError("button must be left or right")
|
|
627
|
+
return
|
|
628
|
+
}
|
|
629
|
+
if count != 1 && count != 2 {
|
|
630
|
+
writeError("count must be 1 or 2")
|
|
631
|
+
return
|
|
632
|
+
}
|
|
633
|
+
postMouseClick(button: button, count: count)
|
|
634
|
+
writeOk()
|
|
635
|
+
|
|
636
|
+
case "drag":
|
|
637
|
+
guard let fromX = doubleValue(object, "fromX"),
|
|
638
|
+
let fromY = doubleValue(object, "fromY"),
|
|
639
|
+
let toX = doubleValue(object, "toX"),
|
|
640
|
+
let toY = doubleValue(object, "toY")
|
|
641
|
+
else {
|
|
642
|
+
writeError("drag requires fromX, fromY, toX, toY")
|
|
643
|
+
return
|
|
644
|
+
}
|
|
645
|
+
postDrag(from: cgPointFromTopLeft(fromX, fromY), to: cgPointFromTopLeft(toX, toY))
|
|
646
|
+
writeOk()
|
|
647
|
+
|
|
648
|
+
case "type":
|
|
649
|
+
guard let text = object["text"] as? String else {
|
|
650
|
+
writeError("type requires text")
|
|
651
|
+
return
|
|
652
|
+
}
|
|
653
|
+
typeText(text)
|
|
654
|
+
writeOk()
|
|
655
|
+
|
|
656
|
+
case "key":
|
|
657
|
+
guard let key = object["key"] as? String else {
|
|
658
|
+
writeError("key requires key")
|
|
659
|
+
return
|
|
660
|
+
}
|
|
661
|
+
if parseAndPostKey(key) {
|
|
662
|
+
writeOk()
|
|
663
|
+
} else {
|
|
664
|
+
writeError("unrecognized key: \(key)")
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
case "scroll":
|
|
668
|
+
guard let x = doubleValue(object, "pointX"),
|
|
669
|
+
let y = doubleValue(object, "pointY"),
|
|
670
|
+
let direction = object["direction"] as? String,
|
|
671
|
+
let amount = intValue(object, "amount")
|
|
672
|
+
else {
|
|
673
|
+
writeError("scroll requires pointX, pointY, direction, amount")
|
|
674
|
+
return
|
|
675
|
+
}
|
|
676
|
+
let dir = direction.lowercased()
|
|
677
|
+
guard ["up", "down", "left", "right"].contains(dir) else {
|
|
678
|
+
writeError("direction must be up, down, left, or right")
|
|
679
|
+
return
|
|
680
|
+
}
|
|
681
|
+
postScroll(at: cgPointFromTopLeft(x, y), direction: dir, amount: amount)
|
|
682
|
+
writeOk()
|
|
683
|
+
|
|
684
|
+
default:
|
|
685
|
+
writeError("unknown cmd: \(cmd)")
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
// MARK: - Stdin reader + main
|
|
690
|
+
|
|
691
|
+
private final class StdinReader {
|
|
692
|
+
private let queue = DispatchQueue(label: "com.jian.computer-helper.stdin")
|
|
693
|
+
|
|
694
|
+
func start() {
|
|
695
|
+
queue.async {
|
|
696
|
+
let handle = FileHandle.standardInput
|
|
697
|
+
var buffer = Data()
|
|
698
|
+
while true {
|
|
699
|
+
let chunk = handle.availableData
|
|
700
|
+
if chunk.isEmpty {
|
|
701
|
+
// EOF — exit the process cleanly.
|
|
702
|
+
DispatchQueue.main.async {
|
|
703
|
+
NSApp.terminate(nil)
|
|
704
|
+
}
|
|
705
|
+
return
|
|
706
|
+
}
|
|
707
|
+
buffer.append(chunk)
|
|
708
|
+
while let range = buffer.range(of: Data([0x0A])) {
|
|
709
|
+
let lineData = buffer.subdata(in: buffer.startIndex..<range.lowerBound)
|
|
710
|
+
buffer.removeSubrange(buffer.startIndex...range.lowerBound)
|
|
711
|
+
if let line = String(data: lineData, encoding: .utf8) {
|
|
712
|
+
let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
713
|
+
if trimmed.isEmpty { continue }
|
|
714
|
+
DispatchQueue.main.async {
|
|
715
|
+
if let object = parseJSONLine(trimmed) {
|
|
716
|
+
handleCommand(object)
|
|
717
|
+
} else {
|
|
718
|
+
logError("invalid JSON line")
|
|
719
|
+
writeError("invalid JSON")
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
private let stdinReader = StdinReader()
|
|
730
|
+
|
|
731
|
+
autoreleasepool {
|
|
732
|
+
let app = NSApplication.shared
|
|
733
|
+
app.setActivationPolicy(.accessory)
|
|
734
|
+
stdinReader.start()
|
|
735
|
+
app.run()
|
|
736
|
+
}
|