@stim-cli/server 1.14.0 → 1.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1584 -89
- package/dist/protocol-MBMvJpvV.mjs +2715 -0
- package/dist/protocol.d.mts +2 -496
- package/dist/protocol.mjs +3 -2
- package/dist/protocol.schema.json +5074 -549
- package/dist/scrcpy/LICENSE +204 -0
- package/dist/scrcpy/NOTICE +22 -0
- package/dist/scrcpy/scrcpy-server +0 -0
- package/dist/stats-read.d.mts +1 -0
- package/dist/stats-read.mjs +46 -0
- package/dist/stim-frames/AndroidDeviceStream.swift +284 -0
- package/dist/stim-frames/DevToolsClient.swift +180 -0
- package/dist/stim-frames/DeviceFrameGeometry.swift +41 -0
- package/dist/stim-frames/DeviceFrameView.swift +167 -0
- package/dist/stim-frames/DuoCaptureSource.swift +132 -0
- package/dist/stim-frames/DuoDeviceProfile.swift +37 -0
- package/dist/stim-frames/DuoFrameRenderer.swift +111 -0
- package/dist/stim-frames/DuoHingeMonitor.swift +89 -0
- package/dist/stim-frames/DuoMesh.swift +100 -0
- package/dist/stim-frames/DuoModelView.swift +275 -0
- package/dist/stim-frames/DuoPoseClip.swift +140 -0
- package/dist/stim-frames/DuoScreenHitMesh.swift +200 -0
- package/dist/stim-frames/EmulatorFrameArtwork.swift +113 -0
- package/dist/stim-frames/EmulatorInput.swift +13 -2
- package/dist/stim-frames/EmulatorPosture.swift +2 -2
- package/dist/stim-frames/FrameArtwork.swift +37 -0
- package/dist/stim-frames/H264Decoder.swift +109 -0
- package/dist/stim-frames/InputMessages.swift +27 -6
- package/dist/stim-frames/KeyboardLayoutMap.swift +70 -0
- package/dist/stim-frames/MacosSource.swift +677 -0
- package/dist/stim-frames/OwnedAppWindowReader.swift +87 -0
- package/dist/stim-frames/OwnedAppWindows.swift +108 -0
- package/dist/stim-frames/PhoneSource.swift +315 -0
- package/dist/stim-frames/ProcessStream.swift +155 -0
- package/dist/stim-frames/ScrcpyProtocol.swift +167 -0
- package/dist/stim-frames/SimulatorDevelopmentOptions.swift +81 -0
- package/dist/stim-frames/SimulatorFrameArtwork.swift +118 -0
- package/dist/stim-frames/SimulatorHID.swift +70 -29
- package/dist/stim-frames/SimulatorHingeAngle.swift +56 -0
- package/dist/stim-frames/SimulatorPosture.swift +277 -0
- package/dist/stim-frames/SimulatorRotation.swift +28 -8
- package/dist/stim-frames/Siniulator-LICENSE.txt +21 -0
- package/dist/stim-frames/StoragePaths.swift +42 -0
- package/dist/stim-frames/VideoEncoder.swift +8 -6
- package/dist/stim-frames/WebPage.swift +301 -0
- package/dist/stim-frames/main.swift +651 -22
- package/dist/stim-host/Info.plist +28 -0
- package/dist/stim-host/stim-host.c +153 -0
- package/dist/stim-server-OiJI9xLU.mjs +13336 -0
- package/dist/stim-server.mjs +6 -4421
- package/package.json +3 -3
- package/dist/protocol-IroIg8Td.mjs +0 -865
|
@@ -0,0 +1,677 @@
|
|
|
1
|
+
import AppKit
|
|
2
|
+
import CoreImage
|
|
3
|
+
import Darwin
|
|
4
|
+
import ScreenCaptureKit
|
|
5
|
+
|
|
6
|
+
final class MacosSource: NSObject, Source, SCStreamDelegate, SCStreamOutput {
|
|
7
|
+
struct OwnedApp: Decodable {
|
|
8
|
+
struct Process: Decodable {
|
|
9
|
+
let pid: Int32
|
|
10
|
+
let startedAtMicros: UInt64
|
|
11
|
+
}
|
|
12
|
+
let bundle: String
|
|
13
|
+
let bundleId: String
|
|
14
|
+
let executable: String
|
|
15
|
+
let app: Process
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
private typealias WindowLocationSetter = @convention(c) (CGEvent, CGPoint) -> Void
|
|
19
|
+
private static let windowLocation: WindowLocationSetter? = {
|
|
20
|
+
guard let symbol = dlsym(UnsafeMutableRawPointer(bitPattern: -2), "CGEventSetWindowLocation") else { return nil }
|
|
21
|
+
return unsafeBitCast(symbol, to: WindowLocationSetter.self)
|
|
22
|
+
}()
|
|
23
|
+
|
|
24
|
+
private static let screenPermission =
|
|
25
|
+
ProcessInfo.processInfo.operatingSystemVersion.majorVersion >= 15
|
|
26
|
+
? "Screen & System Audio Recording" : "Screen Recording"
|
|
27
|
+
private static let controlPermission =
|
|
28
|
+
ProcessInfo.processInfo.operatingSystemVersion.majorVersion >= 27
|
|
29
|
+
? "Device Control and Data Access" : "Accessibility"
|
|
30
|
+
|
|
31
|
+
private let app: OwnedApp
|
|
32
|
+
private let pacer: Pacer
|
|
33
|
+
private let video = videoEncoder()
|
|
34
|
+
private let jpegGate = JpegGate()
|
|
35
|
+
private let keyboardLayouts = KeyboardLayoutCache()
|
|
36
|
+
private var window: SCWindow?
|
|
37
|
+
private var reported: (selection: OwnedAppWindows.Selection?, pinned: Bool)?
|
|
38
|
+
private var pinned: (window: UInt32, session: String)?
|
|
39
|
+
private var switching = false
|
|
40
|
+
private var followTimer: DispatchSourceTimer?
|
|
41
|
+
private let followQueue = DispatchQueue(label: "stim.frames.macos.follow")
|
|
42
|
+
private let inputQueue = DispatchQueue(label: "stim.frames.macos.input")
|
|
43
|
+
private var controlSession: String?
|
|
44
|
+
private var heldMouse:
|
|
45
|
+
(session: String, window: (windowID: CGWindowID, frame: CGRect), accessible: AXUIElement, position: CGPoint)?
|
|
46
|
+
private var lastInput: Task<Void, Never>?
|
|
47
|
+
private var stream: SCStream?
|
|
48
|
+
private var pixels: CVPixelBuffer?
|
|
49
|
+
private var ownershipTimer: DispatchSourceTimer?
|
|
50
|
+
|
|
51
|
+
init(app: OwnedApp) {
|
|
52
|
+
self.app = app
|
|
53
|
+
var render: (Config) -> Void = { _ in }
|
|
54
|
+
pacer = Pacer { render($0) }
|
|
55
|
+
super.init()
|
|
56
|
+
render = { [unowned self] config in self.render(config) }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
func start() {
|
|
60
|
+
guard matches() else { fail("The owned macOS app process changed or exited.") }
|
|
61
|
+
guard CGPreflightScreenCaptureAccess() else {
|
|
62
|
+
if let host = ProcessInfo.processInfo.environment["STIM_CAPTURE_HOST"] {
|
|
63
|
+
fail(
|
|
64
|
+
"\(Self.screenPermission) access is unavailable for stim-frames. Allow \(host) in System Settings > Privacy & Security > \(Self.screenPermission) on this Mac, or run `stim-server service install` again on this Mac to show the request, then reconnect. The server never requests or resets permissions; status and logs remain available."
|
|
65
|
+
)
|
|
66
|
+
}
|
|
67
|
+
fail(
|
|
68
|
+
"\(Self.screenPermission) access is unavailable for stim-frames. Allow the existing capture host in System Settings > Privacy & Security > \(Self.screenPermission), then reconnect. Open Permissions in Stim Desktop on this Mac to set up access, then reconnect. The server never requests or resets permissions; status and logs remain available."
|
|
69
|
+
)
|
|
70
|
+
}
|
|
71
|
+
Task { [self] in
|
|
72
|
+
do {
|
|
73
|
+
var found: OwnedAppWindows.Selection?
|
|
74
|
+
for attempt in 1... {
|
|
75
|
+
do {
|
|
76
|
+
found = try self.currentWindows()
|
|
77
|
+
break
|
|
78
|
+
} catch {
|
|
79
|
+
if attempt == 4 { throw error }
|
|
80
|
+
try await Task.sleep(for: .milliseconds(250))
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
guard let selection = found else {
|
|
84
|
+
fail(
|
|
85
|
+
AXIsProcessTrusted()
|
|
86
|
+
? "Open a window in the owned macOS app to view it. Stim does not capture the desktop or other apps."
|
|
87
|
+
: "Open one visible window in the owned macOS app to view it. Without \(Self.controlPermission) permission Stim cannot tell which app window is in front, so it views only an app with one window."
|
|
88
|
+
)
|
|
89
|
+
}
|
|
90
|
+
guard try await self.capture(selection) else { fail("The owned macOS app window closed before capture started.") }
|
|
91
|
+
let timer = DispatchSource.makeTimerSource(queue: self.pacer.queue)
|
|
92
|
+
timer.schedule(deadline: .now() + 1, repeating: 1)
|
|
93
|
+
timer.setEventHandler { [weak self] in
|
|
94
|
+
guard let self else { return }
|
|
95
|
+
if !self.matches() { fail("The owned macOS app process changed or exited.") }
|
|
96
|
+
}
|
|
97
|
+
self.ownershipTimer = timer
|
|
98
|
+
timer.resume()
|
|
99
|
+
let follow = DispatchSource.makeTimerSource(queue: self.followQueue)
|
|
100
|
+
follow.schedule(deadline: .now() + .milliseconds(250), repeating: .milliseconds(250))
|
|
101
|
+
follow.setEventHandler { [weak self] in self?.follow() }
|
|
102
|
+
self.followTimer = follow
|
|
103
|
+
follow.resume()
|
|
104
|
+
} catch { fail("Owned macOS window capture failed: \(error.localizedDescription)") }
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
private func capture(_ selection: OwnedAppWindows.Selection) async throws -> Bool {
|
|
109
|
+
guard let window = try await shareable(selection.current.id) else { return false }
|
|
110
|
+
let filter = SCContentFilter(desktopIndependentWindow: window)
|
|
111
|
+
let stream = SCStream(filter: filter, configuration: Self.configuration(filter), delegate: self)
|
|
112
|
+
try stream.addStreamOutput(self, type: .screen, sampleHandlerQueue: pacer.queue)
|
|
113
|
+
try await stream.startCapture()
|
|
114
|
+
followQueue.sync {
|
|
115
|
+
self.stream = stream
|
|
116
|
+
self.window = window
|
|
117
|
+
report(selection)
|
|
118
|
+
}
|
|
119
|
+
return true
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
private static func configuration(_ filter: SCContentFilter) -> SCStreamConfiguration {
|
|
123
|
+
let configuration = SCStreamConfiguration()
|
|
124
|
+
configuration.width = Int((filter.contentRect.width * CGFloat(filter.pointPixelScale)).rounded(.up))
|
|
125
|
+
configuration.height = Int((filter.contentRect.height * CGFloat(filter.pointPixelScale)).rounded(.up))
|
|
126
|
+
configuration.ignoreShadowsSingleWindow = true
|
|
127
|
+
configuration.minimumFrameInterval = CMTime(value: 1, timescale: 30)
|
|
128
|
+
configuration.queueDepth = 3
|
|
129
|
+
configuration.showsCursor = false
|
|
130
|
+
return configuration
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
private func shareable(_ id: CGWindowID) async throws -> SCWindow? {
|
|
134
|
+
let content = try await SCShareableContent.excludingDesktopWindows(true, onScreenWindowsOnly: true)
|
|
135
|
+
guard matches() else { fail("The owned macOS app process changed or exited.") }
|
|
136
|
+
return content.windows.first {
|
|
137
|
+
$0.windowID == id && $0.owningApplication?.processID == app.app.pid
|
|
138
|
+
&& $0.owningApplication?.bundleIdentifier == app.bundleId && $0.windowLayer == 0
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
private func currentWindows() throws -> OwnedAppWindows.Selection? {
|
|
143
|
+
do { return try OwnedAppWindowReader.selection(pid: app.app.pid) } catch {
|
|
144
|
+
throw refusal("The owned app's current window inventory is unavailable.")
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
private func isOpen(_ id: CGWindowID) -> Bool {
|
|
149
|
+
OwnedAppWindowReader.screen(pid: app.app.pid)?.contains { $0.id == id } == true
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
private func applyPin(_ selection: OwnedAppWindows.Selection, screen: [OwnedAppWindows.Screen]?) -> OwnedAppWindows.Selection {
|
|
153
|
+
guard pinned != nil else { return selection }
|
|
154
|
+
guard let screen = screen ?? OwnedAppWindowReader.screen(pid: app.app.pid) else { return selection }
|
|
155
|
+
let result = OwnedAppWindows.pin(selection, to: pinned?.window, screen: screen)
|
|
156
|
+
if result.pinned == nil { pinned = nil }
|
|
157
|
+
return result.selection
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
private func follow() {
|
|
161
|
+
guard !switching, matches() else { return }
|
|
162
|
+
let found: OwnedAppWindows.Selection?
|
|
163
|
+
do { found = try currentWindows() } catch { return }
|
|
164
|
+
guard let found else {
|
|
165
|
+
pinned = nil
|
|
166
|
+
return report(nil)
|
|
167
|
+
}
|
|
168
|
+
let selection = applyPin(found, screen: nil)
|
|
169
|
+
guard let stream, let captured = window else {
|
|
170
|
+
switching = true
|
|
171
|
+
Task {
|
|
172
|
+
do {
|
|
173
|
+
_ = try await self.capture(selection)
|
|
174
|
+
} catch {
|
|
175
|
+
if self.isOpen(selection.current.id) { fail("Owned macOS window capture failed: \(error.localizedDescription)") }
|
|
176
|
+
}
|
|
177
|
+
self.followQueue.sync { self.switching = false }
|
|
178
|
+
}
|
|
179
|
+
return
|
|
180
|
+
}
|
|
181
|
+
guard selection.current.id != captured.windowID || selection.current.frame.size != captured.frame.size else {
|
|
182
|
+
return report(selection)
|
|
183
|
+
}
|
|
184
|
+
switching = true
|
|
185
|
+
Task {
|
|
186
|
+
var next: SCWindow?
|
|
187
|
+
do {
|
|
188
|
+
next = try await self.shareable(selection.current.id)
|
|
189
|
+
if let next {
|
|
190
|
+
let filter = SCContentFilter(desktopIndependentWindow: next)
|
|
191
|
+
try await stream.updateContentFilter(filter)
|
|
192
|
+
try await stream.updateConfiguration(Self.configuration(filter))
|
|
193
|
+
}
|
|
194
|
+
} catch {
|
|
195
|
+
next = nil
|
|
196
|
+
try? await stream.stopCapture()
|
|
197
|
+
self.followQueue.sync { if self.stream === stream { self.stream = nil } }
|
|
198
|
+
}
|
|
199
|
+
self.followQueue.sync {
|
|
200
|
+
self.switching = false
|
|
201
|
+
guard let next else { return }
|
|
202
|
+
self.window = next
|
|
203
|
+
self.report(selection)
|
|
204
|
+
}
|
|
205
|
+
if next != nil { self.keyframe() }
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
private func report(_ found: OwnedAppWindows.Selection?) {
|
|
210
|
+
let shown: (OwnedAppWindows.Window) -> OwnedAppWindows.Window = { .init(id: $0.id, title: $0.title, frame: $0.frame, accessible: nil) }
|
|
211
|
+
let selection = found.map { OwnedAppWindows.Selection(current: shown($0.current), windows: $0.windows.map(shown)) }
|
|
212
|
+
let state = (selection: selection, pinned: pinned != nil)
|
|
213
|
+
if let reported, reported.selection == state.selection, reported.pinned == state.pinned { return }
|
|
214
|
+
if reported?.selection == nil || selection == nil {
|
|
215
|
+
let reason =
|
|
216
|
+
AXIsProcessTrusted()
|
|
217
|
+
? "The owned macOS app has no open window."
|
|
218
|
+
: "Without \(Self.controlPermission) permission Stim views only an owned app whose one window contains the others."
|
|
219
|
+
Output.notice(["stalled": selection == nil ? reason : NSNull()])
|
|
220
|
+
}
|
|
221
|
+
reported = state
|
|
222
|
+
let json: (OwnedAppWindows.Window) -> [String: Any] = {
|
|
223
|
+
[
|
|
224
|
+
"id": Int($0.id), "title": $0.title,
|
|
225
|
+
"frame": ["x": $0.frame.minX, "y": $0.frame.minY, "width": $0.frame.width, "height": $0.frame.height],
|
|
226
|
+
]
|
|
227
|
+
}
|
|
228
|
+
Output.notice([
|
|
229
|
+
"macosWindows": [
|
|
230
|
+
"current": selection.map { json($0.current) as Any } ?? NSNull(), "windows": (selection?.windows ?? []).map(json),
|
|
231
|
+
"pinned": state.pinned,
|
|
232
|
+
]
|
|
233
|
+
])
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
func configure(_ config: Config) {
|
|
237
|
+
video.configure(enabled: config.video, maxEdge: config.maxEdge, fps: Int(config.fps), bitrate: config.bitrate)
|
|
238
|
+
pacer.queue.async {
|
|
239
|
+
self.pacer.config = config
|
|
240
|
+
self.pacer.changed()
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
func keyframe() {
|
|
245
|
+
video.requestKeyframe()
|
|
246
|
+
pacer.changed()
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
func recordKeyframe() {}
|
|
250
|
+
|
|
251
|
+
func stop() {
|
|
252
|
+
inputQueue.sync {
|
|
253
|
+
if let held = heldMouse { releaseMouse(held.session) }
|
|
254
|
+
controlSession = nil
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
func input(_ command: Command) {
|
|
259
|
+
inputQueue.async {
|
|
260
|
+
switch command {
|
|
261
|
+
case .control(let session, let enabled):
|
|
262
|
+
if enabled {
|
|
263
|
+
if let held = self.heldMouse { self.releaseMouse(held.session) }
|
|
264
|
+
self.controlSession = session
|
|
265
|
+
self.followQueue.async { if self.pinned?.session != session { self.pinned = nil } }
|
|
266
|
+
} else if self.controlSession == session {
|
|
267
|
+
self.releaseMouse(session)
|
|
268
|
+
self.controlSession = nil
|
|
269
|
+
self.unpin(session)
|
|
270
|
+
}
|
|
271
|
+
case .scoped(let session, let action):
|
|
272
|
+
let previous = self.lastInput
|
|
273
|
+
self.lastInput = Task {
|
|
274
|
+
await previous?.value
|
|
275
|
+
guard self.isActive(session) else { return }
|
|
276
|
+
do { try await self.apply(action, session: session) } catch is CancellationError {
|
|
277
|
+
} catch let changed as Dropped {
|
|
278
|
+
Output.notice(["inputError": changed.localizedDescription])
|
|
279
|
+
} catch {
|
|
280
|
+
if self.endControl(session) { Output.notice(["inputError": error.localizedDescription, "controlSession": session]) }
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
default: break
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
private func isActive(_ session: String) -> Bool {
|
|
289
|
+
inputQueue.sync { controlSession == session }
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
private func endControl(_ session: String) -> Bool {
|
|
293
|
+
inputQueue.sync {
|
|
294
|
+
guard controlSession == session else { return false }
|
|
295
|
+
releaseMouse(session)
|
|
296
|
+
controlSession = nil
|
|
297
|
+
unpin(session)
|
|
298
|
+
return true
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/// A pin lasts only as long as the Control session that chose it.
|
|
303
|
+
private func unpin(_ session: String) {
|
|
304
|
+
followQueue.async { if self.pinned?.session == session { self.pinned = nil } }
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
private func releaseMouse(_ session: String) {
|
|
308
|
+
guard let held = heldMouse, held.session == session else { return }
|
|
309
|
+
heldMouse = nil
|
|
310
|
+
guard matches(), AXIsProcessTrusted(), CGPreflightPostEventAccess(),
|
|
311
|
+
let entries = CGWindowListCopyWindowInfo(.optionIncludingWindow, held.window.windowID) as? [[String: Any]],
|
|
312
|
+
entries.count == 1, let entry = entries.first,
|
|
313
|
+
entry[kCGWindowOwnerPID as String] as? Int == Int(app.app.pid),
|
|
314
|
+
entry[kCGWindowLayer as String] as? Int == 0,
|
|
315
|
+
let bounds = entry[kCGWindowBounds as String] as? [String: Any],
|
|
316
|
+
let frame = CGRect(dictionaryRepresentation: bounds as CFDictionary), frame == held.window.frame
|
|
317
|
+
else { return }
|
|
318
|
+
var focused: CFTypeRef?
|
|
319
|
+
guard
|
|
320
|
+
AXUIElementCopyAttributeValue(
|
|
321
|
+
AXUIElementCreateApplication(app.app.pid), kAXFocusedWindowAttribute as CFString, &focused) == .success,
|
|
322
|
+
let focused, CFEqual(focused, held.accessible),
|
|
323
|
+
let native = NSEvent.mouseEvent(
|
|
324
|
+
with: .leftMouseUp,
|
|
325
|
+
location: CGPoint(x: held.position.x - held.window.frame.minX, y: held.window.frame.maxY - held.position.y),
|
|
326
|
+
modifierFlags: [], timestamp: ProcessInfo.processInfo.systemUptime, windowNumber: Int(held.window.windowID),
|
|
327
|
+
context: nil, eventNumber: 0, clickCount: 1, pressure: 0), let event = native.cgEvent,
|
|
328
|
+
let setWindowLocation = Self.windowLocation,
|
|
329
|
+
matches()
|
|
330
|
+
else { return }
|
|
331
|
+
event.location = held.position
|
|
332
|
+
setWindowLocation(event, CGPoint(x: held.position.x - held.window.frame.minX, y: held.position.y - held.window.frame.minY))
|
|
333
|
+
event.setIntegerValueField(.mouseEventWindowUnderMousePointer, value: Int64(held.window.windowID))
|
|
334
|
+
event.setIntegerValueField(.mouseEventWindowUnderMousePointerThatCanHandleThisEvent, value: Int64(held.window.windowID))
|
|
335
|
+
event.postToPid(app.app.pid)
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// NSRunningApplication(processIdentifier:) returns nil for 1-2 ms at a time on macOS 27 while the process runs.
|
|
339
|
+
private static func runningApplication(_ pid: pid_t) -> NSRunningApplication? {
|
|
340
|
+
for attempt in 0..<4 {
|
|
341
|
+
if attempt > 0 { usleep(5_000) }
|
|
342
|
+
if let running = NSRunningApplication(processIdentifier: pid) { return running }
|
|
343
|
+
}
|
|
344
|
+
return nil
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
private struct Dropped: LocalizedError {
|
|
348
|
+
var errorDescription: String? = "The owned app's window changed before input, so the input was not sent."
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
private func refusal(_ message: String) -> NSError {
|
|
352
|
+
NSError(domain: "StimFrames", code: 1, userInfo: [NSLocalizedDescriptionKey: message])
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
private func inputWindow() throws -> ((windowID: CGWindowID, frame: CGRect), AXUIElement) {
|
|
356
|
+
guard matches(), let captured = followQueue.sync(execute: { window }) else {
|
|
357
|
+
throw refusal("The captured owned macOS app window is unavailable.")
|
|
358
|
+
}
|
|
359
|
+
guard AXIsProcessTrusted(), CGPreflightPostEventAccess() else {
|
|
360
|
+
let permission = Self.controlPermission
|
|
361
|
+
if let host = ProcessInfo.processInfo.environment["STIM_CAPTURE_HOST"] {
|
|
362
|
+
throw refusal(
|
|
363
|
+
"Control needs \(permission) permission. Allow \(host) in System Settings > Privacy & Security > \(permission) on this Mac, or run `stim-server service install` again on this Mac to show the request, then reconnect. The server never requests or resets permissions; viewing and logs remain available."
|
|
364
|
+
)
|
|
365
|
+
}
|
|
366
|
+
throw refusal(
|
|
367
|
+
"Control needs \(permission) permission for the capture host. Open Permissions in Stim Desktop on this Mac, or allow the host in System Settings > Privacy & Security > \(permission), then reconnect. The server never requests or resets permissions; viewing and logs remain available."
|
|
368
|
+
)
|
|
369
|
+
}
|
|
370
|
+
guard let accessible = OwnedAppWindowReader.accessible(pid: app.app.pid) else {
|
|
371
|
+
throw refusal("The owned app's accessible windows are unavailable.")
|
|
372
|
+
}
|
|
373
|
+
guard !accessible.modal else { throw refusal("Control needs accessible owned app windows without a modal dialog.") }
|
|
374
|
+
guard let screen = OwnedAppWindowReader.screen(pid: app.app.pid) else {
|
|
375
|
+
throw refusal("The owned app's current window inventory is unavailable.")
|
|
376
|
+
}
|
|
377
|
+
guard let found = OwnedAppWindows.select(screen: screen, accessible: accessible.windows), matches() else {
|
|
378
|
+
throw Dropped()
|
|
379
|
+
}
|
|
380
|
+
let selection = followQueue.sync { applyPin(found, screen: screen) }
|
|
381
|
+
guard selection.current.id == captured.windowID,
|
|
382
|
+
selection.current.frame.size == captured.frame.size, let index = selection.current.accessible
|
|
383
|
+
else { throw Dropped() }
|
|
384
|
+
return ((selection.current.id, selection.current.frame), accessible.elements[index])
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
private func attachedSheets(_ own: AXUIElement) -> [AXUIElement] {
|
|
388
|
+
var value: CFTypeRef?
|
|
389
|
+
guard AXUIElementCopyAttributeValue(own, kAXChildrenAttribute as CFString, &value) == .success,
|
|
390
|
+
let children = value as? [AXUIElement]
|
|
391
|
+
else { return [] }
|
|
392
|
+
var sheets: [AXUIElement] = []
|
|
393
|
+
for child in children {
|
|
394
|
+
var role: CFTypeRef?
|
|
395
|
+
if AXUIElementCopyAttributeValue(child, kAXRoleAttribute as CFString, &role) == .success,
|
|
396
|
+
role as? String == kAXSheetRole
|
|
397
|
+
{
|
|
398
|
+
sheets.append(child)
|
|
399
|
+
sheets.append(contentsOf: attachedSheets(child))
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
return sheets
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
private func isFocused(_ own: AXUIElement, application: AXUIElement) -> Bool {
|
|
406
|
+
var focused: CFTypeRef?
|
|
407
|
+
return AXUIElementCopyAttributeValue(application, kAXFocusedWindowAttribute as CFString, &focused) == .success
|
|
408
|
+
&& focused.map { value in ([own] + attachedSheets(own)).contains { CFEqual(value, $0) } } == true
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/// Pins capture and Control to the app's window `id`, raising it, or with nil follows the front window again.
|
|
412
|
+
private func select(_ id: UInt32?, session: String) throws {
|
|
413
|
+
guard let id else {
|
|
414
|
+
followQueue.sync { pinned = nil }
|
|
415
|
+
return
|
|
416
|
+
}
|
|
417
|
+
guard matches(), AXIsProcessTrusted(), let accessible = OwnedAppWindowReader.accessible(pid: app.app.pid),
|
|
418
|
+
let screen = OwnedAppWindowReader.screen(pid: app.app.pid)
|
|
419
|
+
else { throw Dropped(errorDescription: "The owned app's windows are unavailable, so the window was not chosen.") }
|
|
420
|
+
guard !accessible.modal else {
|
|
421
|
+
throw Dropped(errorDescription: "The owned app shows a modal dialog, so the window was not chosen.")
|
|
422
|
+
}
|
|
423
|
+
guard let selection = OwnedAppWindows.select(screen: screen, accessible: accessible.windows),
|
|
424
|
+
let index = selection.windows.first(where: { $0.id == id })?.accessible, matches()
|
|
425
|
+
else { throw Dropped() }
|
|
426
|
+
let element = accessible.elements[index]
|
|
427
|
+
_ = AXUIElementSetAttributeValue(element, kAXMainAttribute as CFString, kCFBooleanTrue)
|
|
428
|
+
_ = AXUIElementPerformAction(element, kAXRaiseAction as CFString)
|
|
429
|
+
guard isActive(session) else { throw CancellationError() }
|
|
430
|
+
followQueue.sync {
|
|
431
|
+
pinned = (id, session)
|
|
432
|
+
follow()
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
private func apply(_ command: Command, session: String) async throws {
|
|
437
|
+
if case .window(let id) = command { return try select(id, session: session) }
|
|
438
|
+
guard let setWindowLocation = Self.windowLocation else {
|
|
439
|
+
throw refusal("Native window input is unavailable on this macOS version. Viewing and logs remain available.")
|
|
440
|
+
}
|
|
441
|
+
let (window, own) = try inputWindow()
|
|
442
|
+
guard isActive(session) else { throw CancellationError() }
|
|
443
|
+
let application = AXUIElementCreateApplication(app.app.pid)
|
|
444
|
+
if !isFocused(own, application: application) {
|
|
445
|
+
guard let running = Self.runningApplication(app.app.pid)
|
|
446
|
+
else { throw refusal("The captured owned window could not be focused for Control.") }
|
|
447
|
+
Output.notice([
|
|
448
|
+
"controlActivated": "The captured window was not the app's key window, so Stim raised it and activated the app to deliver input."
|
|
449
|
+
])
|
|
450
|
+
let raise = AXUIElementPerformAction(own, kAXRaiseAction as CFString)
|
|
451
|
+
let focus = AXUIElementSetAttributeValue(application, kAXFocusedWindowAttribute as CFString, own)
|
|
452
|
+
let activated = running.activate(options: [])
|
|
453
|
+
let deadline = ContinuousClock.now.advanced(by: .seconds(1))
|
|
454
|
+
while !(running.isActive && isFocused(own, application: application)) {
|
|
455
|
+
guard isActive(session), matches() else { throw CancellationError() }
|
|
456
|
+
guard activated, ContinuousClock.now < deadline else {
|
|
457
|
+
throw refusal(
|
|
458
|
+
"The captured owned window could not be focused for Control. (raise \(raise.rawValue), focus \(focus.rawValue), activate \(activated))"
|
|
459
|
+
)
|
|
460
|
+
}
|
|
461
|
+
try await Task.sleep(for: .milliseconds(10))
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
var focusedValue: CFTypeRef?
|
|
465
|
+
guard AXUIElementCopyAttributeValue(application, kAXFocusedWindowAttribute as CFString, &focusedValue) == .success,
|
|
466
|
+
let focusedValue, let focused = ([own] + attachedSheets(own)).first(where: { CFEqual(focusedValue, $0) }), matches()
|
|
467
|
+
else { throw refusal("The captured owned window lost focus before input.") }
|
|
468
|
+
var sheetWindow: (windowID: CGWindowID, frame: CGRect)?
|
|
469
|
+
if !CFEqual(focused, own) {
|
|
470
|
+
var positionValue: CFTypeRef?
|
|
471
|
+
var sizeValue: CFTypeRef?
|
|
472
|
+
var position = CGPoint.zero
|
|
473
|
+
var size = CGSize.zero
|
|
474
|
+
guard AXUIElementCopyAttributeValue(focused, kAXPositionAttribute as CFString, &positionValue) == .success,
|
|
475
|
+
AXUIElementCopyAttributeValue(focused, kAXSizeAttribute as CFString, &sizeValue) == .success,
|
|
476
|
+
let positionValue, let sizeValue, CFGetTypeID(positionValue) == AXValueGetTypeID(),
|
|
477
|
+
CFGetTypeID(sizeValue) == AXValueGetTypeID(),
|
|
478
|
+
AXValueGetValue(unsafeDowncast(positionValue, to: AXValue.self), .cgPoint, &position),
|
|
479
|
+
AXValueGetValue(unsafeDowncast(sizeValue, to: AXValue.self), .cgSize, &size),
|
|
480
|
+
let entries = CGWindowListCopyWindowInfo([.optionOnScreenOnly, .excludeDesktopElements], kCGNullWindowID)
|
|
481
|
+
as? [[String: Any]]
|
|
482
|
+
else { throw refusal("The focused owned sheet's window metadata is unavailable.") }
|
|
483
|
+
let frame = CGRect(origin: position, size: size)
|
|
484
|
+
guard
|
|
485
|
+
let entry = entries.first(where: {
|
|
486
|
+
guard $0[kCGWindowOwnerPID as String] as? Int == Int(app.app.pid),
|
|
487
|
+
$0[kCGWindowLayer as String] as? Int == 0,
|
|
488
|
+
let id = $0[kCGWindowNumber as String] as? UInt32, id != window.windowID,
|
|
489
|
+
let bounds = $0[kCGWindowBounds as String] as? [String: Any]
|
|
490
|
+
else { return false }
|
|
491
|
+
return CGRect(dictionaryRepresentation: bounds as CFDictionary) == frame
|
|
492
|
+
}), let id = entry[kCGWindowNumber as String] as? UInt32
|
|
493
|
+
else { throw refusal("The focused owned sheet does not match an on-screen app window.") }
|
|
494
|
+
sheetWindow = (id, frame)
|
|
495
|
+
}
|
|
496
|
+
let location: (CGPoint) -> CGPoint = {
|
|
497
|
+
CGPoint(x: window.frame.minX + $0.x * (window.frame.width - 1), y: window.frame.minY + $0.y * (window.frame.height - 1))
|
|
498
|
+
}
|
|
499
|
+
let pointerWindow: (CGPoint) -> (windowID: CGWindowID, frame: CGRect) = { point in
|
|
500
|
+
if let sheetWindow, sheetWindow.frame.contains(point) { return sheetWindow }
|
|
501
|
+
return window
|
|
502
|
+
}
|
|
503
|
+
let post: ([CGEvent], (windowID: CGWindowID, frame: CGRect)) throws -> Void = { events, target in
|
|
504
|
+
try self.inputQueue.sync {
|
|
505
|
+
guard self.controlSession == session else { throw CancellationError() }
|
|
506
|
+
var focusedValue: CFTypeRef?
|
|
507
|
+
guard self.matches(),
|
|
508
|
+
AXUIElementCopyAttributeValue(application, kAXFocusedWindowAttribute as CFString, &focusedValue) == .success,
|
|
509
|
+
let focusedValue, CFEqual(focusedValue, focused)
|
|
510
|
+
else { throw self.refusal("The captured owned window changed before input.") }
|
|
511
|
+
for event in events {
|
|
512
|
+
guard self.matches() else { throw self.refusal("The owned macOS app process changed before input.") }
|
|
513
|
+
event.setIntegerValueField(.mouseEventWindowUnderMousePointer, value: Int64(target.windowID))
|
|
514
|
+
event.setIntegerValueField(.mouseEventWindowUnderMousePointerThatCanHandleThisEvent, value: Int64(target.windowID))
|
|
515
|
+
// AppKit PID-targeted pointer events need CoreGraphics' private window-local annotation.
|
|
516
|
+
if [.leftMouseDown, .leftMouseUp, .leftMouseDragged, .scrollWheel].contains(event.type) {
|
|
517
|
+
setWindowLocation(event, CGPoint(x: event.location.x - target.frame.minX, y: event.location.y - target.frame.minY))
|
|
518
|
+
}
|
|
519
|
+
event.postToPid(self.app.app.pid)
|
|
520
|
+
switch event.type {
|
|
521
|
+
case .leftMouseDown:
|
|
522
|
+
self.heldMouse = (session, target, focused, event.location)
|
|
523
|
+
case .leftMouseDragged:
|
|
524
|
+
if self.heldMouse?.session == session { self.heldMouse?.position = event.location }
|
|
525
|
+
case .leftMouseUp:
|
|
526
|
+
if self.heldMouse?.session == session { self.heldMouse = nil }
|
|
527
|
+
default: break
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
let key: (CGKeyCode, CGEventFlags, String?) throws -> Void = { code, flags, text in
|
|
533
|
+
var events: [CGEvent] = []
|
|
534
|
+
for down in [true, false] {
|
|
535
|
+
guard let event = CGEvent(keyboardEventSource: nil, virtualKey: code, keyDown: down) else {
|
|
536
|
+
throw self.refusal("The native keyboard event could not be created.")
|
|
537
|
+
}
|
|
538
|
+
event.flags = flags
|
|
539
|
+
if let text {
|
|
540
|
+
let utf16 = Array(text.utf16)
|
|
541
|
+
event.keyboardSetUnicodeString(stringLength: utf16.count, unicodeString: utf16)
|
|
542
|
+
}
|
|
543
|
+
events.append(event)
|
|
544
|
+
}
|
|
545
|
+
try post(events, window)
|
|
546
|
+
}
|
|
547
|
+
switch command {
|
|
548
|
+
case .touch(let phase, let point, _):
|
|
549
|
+
let type: NSEvent.EventType = phase == .down ? .leftMouseDown : phase == .up ? .leftMouseUp : .leftMouseDragged
|
|
550
|
+
let global = location(point)
|
|
551
|
+
let target = inputQueue.sync {
|
|
552
|
+
if phase != .down, let held = heldMouse, held.session == session { return held.window }
|
|
553
|
+
return pointerWindow(global)
|
|
554
|
+
}
|
|
555
|
+
guard
|
|
556
|
+
let native = NSEvent.mouseEvent(
|
|
557
|
+
with: type, location: CGPoint(x: global.x - target.frame.minX, y: target.frame.maxY - global.y),
|
|
558
|
+
modifierFlags: [], timestamp: ProcessInfo.processInfo.systemUptime, windowNumber: Int(target.windowID),
|
|
559
|
+
context: nil, eventNumber: 0, clickCount: 1, pressure: phase == .up ? 0 : 1), let event = native.cgEvent
|
|
560
|
+
else { throw refusal("The native mouse event could not be created.") }
|
|
561
|
+
event.location = global
|
|
562
|
+
try post([event], target)
|
|
563
|
+
case .scroll(let point, let delta):
|
|
564
|
+
let global = location(point)
|
|
565
|
+
let target = pointerWindow(global)
|
|
566
|
+
guard
|
|
567
|
+
let native = NSEvent.mouseEvent(
|
|
568
|
+
with: .mouseMoved, location: CGPoint(x: global.x - target.frame.minX, y: target.frame.maxY - global.y),
|
|
569
|
+
modifierFlags: [], timestamp: ProcessInfo.processInfo.systemUptime, windowNumber: Int(target.windowID),
|
|
570
|
+
context: nil, eventNumber: 0, clickCount: 0, pressure: 0), let event = native.cgEvent,
|
|
571
|
+
let wheel = CGEvent(
|
|
572
|
+
scrollWheelEvent2Source: nil, units: .pixel, wheelCount: 2,
|
|
573
|
+
wheel1: Int32(delta.y.rounded()), wheel2: Int32(delta.x.rounded()), wheel3: 0)
|
|
574
|
+
else { throw refusal("The native scroll event could not be created.") }
|
|
575
|
+
event.type = .scrollWheel
|
|
576
|
+
for field: CGEventField in [
|
|
577
|
+
.scrollWheelEventDeltaAxis1, .scrollWheelEventDeltaAxis2,
|
|
578
|
+
.scrollWheelEventPointDeltaAxis1, .scrollWheelEventPointDeltaAxis2, .scrollWheelEventIsContinuous,
|
|
579
|
+
] {
|
|
580
|
+
event.setIntegerValueField(field, value: wheel.getIntegerValueField(field))
|
|
581
|
+
}
|
|
582
|
+
for field: CGEventField in [.scrollWheelEventFixedPtDeltaAxis1, .scrollWheelEventFixedPtDeltaAxis2] {
|
|
583
|
+
event.setDoubleValueField(field, value: wheel.getDoubleValueField(field))
|
|
584
|
+
}
|
|
585
|
+
event.location = global
|
|
586
|
+
try post([event], target)
|
|
587
|
+
case .text(let text):
|
|
588
|
+
for character in text {
|
|
589
|
+
let special: [Character: CGKeyCode] = ["\n": 36, "\t": 48, "\u{0008}": 51]
|
|
590
|
+
try key(special[character] ?? 0, [], special[character] == nil ? String(character) : nil)
|
|
591
|
+
}
|
|
592
|
+
case .key(let name, let modifiers):
|
|
593
|
+
let codes: [String: CGKeyCode] = [
|
|
594
|
+
"escape": 53, "tab": 48, "return": 36, "backspace": 51,
|
|
595
|
+
"left": 123, "right": 124, "down": 125, "up": 126,
|
|
596
|
+
]
|
|
597
|
+
let flags: [String: CGEventFlags] = [
|
|
598
|
+
"command": .maskCommand, "shift": .maskShift, "option": .maskAlternate, "control": .maskControl,
|
|
599
|
+
]
|
|
600
|
+
guard modifiers.allSatisfy({ flags[$0] != nil }) else { throw refusal("Unsupported native key.") }
|
|
601
|
+
let code: CGKeyCode
|
|
602
|
+
if name.count == 1 {
|
|
603
|
+
guard let character = name.first, "abcdefghijklmnopqrstuvwxyz0123456789".contains(character) else {
|
|
604
|
+
throw refusal("Unsupported native key.")
|
|
605
|
+
}
|
|
606
|
+
let layout = try DispatchQueue.main.sync {
|
|
607
|
+
guard isActive(session), matches() else {
|
|
608
|
+
throw refusal("The Control session or owned app changed before reading its keyboard layout.")
|
|
609
|
+
}
|
|
610
|
+
return keyboardLayouts.current()
|
|
611
|
+
}
|
|
612
|
+
guard let layout else {
|
|
613
|
+
throw refusal(
|
|
614
|
+
"The Mac's keyboard layout could not be read, so native letter and digit shortcuts cannot be sent. Ordinary typing and navigation do not require it."
|
|
615
|
+
)
|
|
616
|
+
}
|
|
617
|
+
let command = modifiers.contains("command")
|
|
618
|
+
let control = modifiers.contains("control")
|
|
619
|
+
guard let resolved = layout.map.keyCode(for: character, command: command, control: control) else {
|
|
620
|
+
let layer = control ? (command ? " with Command and Control" : " with Control") : (command ? " with Command" : "")
|
|
621
|
+
throw refusal(
|
|
622
|
+
"The Mac's current keyboard layout (\(layout.id)) has no key that types \"\(character)\"\(layer), so the shortcut was not sent. Select an input source that has it and start Control again. Ordinary typing and navigation do not require it."
|
|
623
|
+
)
|
|
624
|
+
}
|
|
625
|
+
code = resolved
|
|
626
|
+
} else {
|
|
627
|
+
guard let navigation = codes[name] else { throw refusal("Unsupported native key.") }
|
|
628
|
+
code = navigation
|
|
629
|
+
}
|
|
630
|
+
try key(code, modifiers.reduce(CGEventFlags()) { $0.union(flags[$1]!) }, nil)
|
|
631
|
+
default: throw refusal("Unsupported native macOS input.")
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
func stream(_ stream: SCStream, didStopWithError error: Error) {
|
|
636
|
+
followQueue.asyncAfter(deadline: .now() + .milliseconds(500)) {
|
|
637
|
+
guard self.stream === stream else { return }
|
|
638
|
+
if let id = self.window?.windowID, self.isOpen(id) {
|
|
639
|
+
fail("Owned macOS window capture stopped: \(error.localizedDescription)")
|
|
640
|
+
}
|
|
641
|
+
self.stream = nil
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
func stream(_ stream: SCStream, didOutputSampleBuffer sampleBuffer: CMSampleBuffer, of type: SCStreamOutputType) {
|
|
646
|
+
guard type == .screen, let buffer = sampleBuffer.imageBuffer else { return }
|
|
647
|
+
guard matches() else { fail("The owned macOS app process changed or exited.") }
|
|
648
|
+
pixels = buffer
|
|
649
|
+
pacer.changed()
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
private func render(_ config: Config) {
|
|
653
|
+
guard matches() else { fail("The owned macOS app process changed or exited.") }
|
|
654
|
+
guard let pixels else { return }
|
|
655
|
+
if config.video { video.encode(pixels, quarterTurns: 0, capturedAt: now()) }
|
|
656
|
+
if config.jpeg, jpegGate.admit(config, pacer: pacer),
|
|
657
|
+
let (data, width, height) = jpeg(CIImage(cvPixelBuffer: pixels), config: config)
|
|
658
|
+
{
|
|
659
|
+
Output.frame(jpeg: data, width: width, height: height)
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
private func matches() -> Bool {
|
|
664
|
+
var info = proc_bsdinfo()
|
|
665
|
+
guard app.app.pid > 0,
|
|
666
|
+
proc_pidinfo(app.app.pid, PROC_PIDTBSDINFO, 0, &info, Int32(MemoryLayout<proc_bsdinfo>.size))
|
|
667
|
+
== MemoryLayout<proc_bsdinfo>.size,
|
|
668
|
+
info.pbi_start_tvsec * 1_000_000 + info.pbi_start_tvusec == app.app.startedAtMicros,
|
|
669
|
+
let running = Self.runningApplication(app.app.pid),
|
|
670
|
+
running.bundleIdentifier == app.bundleId,
|
|
671
|
+
running.executableURL?.resolvingSymlinksInPath().path
|
|
672
|
+
== URL(fileURLWithPath: app.executable).resolvingSymlinksInPath().path,
|
|
673
|
+
running.bundleURL?.resolvingSymlinksInPath().path == URL(fileURLWithPath: app.bundle).resolvingSymlinksInPath().path
|
|
674
|
+
else { return false }
|
|
675
|
+
return true
|
|
676
|
+
}
|
|
677
|
+
}
|