@stim-cli/server 1.13.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.
Files changed (52) hide show
  1. package/README.md +1590 -42
  2. package/dist/protocol-MBMvJpvV.mjs +2715 -0
  3. package/dist/protocol.d.mts +2 -473
  4. package/dist/protocol.mjs +3 -2
  5. package/dist/protocol.schema.json +5121 -514
  6. package/dist/scrcpy/LICENSE +204 -0
  7. package/dist/scrcpy/NOTICE +22 -0
  8. package/dist/scrcpy/scrcpy-server +0 -0
  9. package/dist/stats-read.d.mts +1 -0
  10. package/dist/stats-read.mjs +46 -0
  11. package/dist/stim-frames/AndroidDeviceStream.swift +284 -0
  12. package/dist/stim-frames/DevToolsClient.swift +180 -0
  13. package/dist/stim-frames/DeviceFrameGeometry.swift +41 -0
  14. package/dist/stim-frames/DeviceFrameView.swift +167 -0
  15. package/dist/stim-frames/DuoCaptureSource.swift +132 -0
  16. package/dist/stim-frames/DuoDeviceProfile.swift +37 -0
  17. package/dist/stim-frames/DuoFrameRenderer.swift +111 -0
  18. package/dist/stim-frames/DuoHingeMonitor.swift +89 -0
  19. package/dist/stim-frames/DuoMesh.swift +100 -0
  20. package/dist/stim-frames/DuoModelView.swift +275 -0
  21. package/dist/stim-frames/DuoPoseClip.swift +140 -0
  22. package/dist/stim-frames/DuoScreenHitMesh.swift +200 -0
  23. package/dist/stim-frames/EmulatorFrameArtwork.swift +113 -0
  24. package/dist/stim-frames/EmulatorInput.swift +13 -2
  25. package/dist/stim-frames/EmulatorPosture.swift +2 -2
  26. package/dist/stim-frames/FrameArtwork.swift +37 -0
  27. package/dist/stim-frames/H264Decoder.swift +109 -0
  28. package/dist/stim-frames/InputMessages.swift +27 -6
  29. package/dist/stim-frames/KeyboardLayoutMap.swift +70 -0
  30. package/dist/stim-frames/MacosSource.swift +677 -0
  31. package/dist/stim-frames/OwnedAppWindowReader.swift +87 -0
  32. package/dist/stim-frames/OwnedAppWindows.swift +108 -0
  33. package/dist/stim-frames/PhoneSource.swift +315 -0
  34. package/dist/stim-frames/ProcessStream.swift +155 -0
  35. package/dist/stim-frames/ScrcpyProtocol.swift +167 -0
  36. package/dist/stim-frames/SimulatorDevelopmentOptions.swift +81 -0
  37. package/dist/stim-frames/SimulatorFrameArtwork.swift +118 -0
  38. package/dist/stim-frames/SimulatorHID.swift +70 -29
  39. package/dist/stim-frames/SimulatorHingeAngle.swift +56 -0
  40. package/dist/stim-frames/SimulatorPosture.swift +277 -0
  41. package/dist/stim-frames/SimulatorRotation.swift +28 -8
  42. package/dist/stim-frames/Siniulator-LICENSE.txt +21 -0
  43. package/dist/stim-frames/StoragePaths.swift +42 -0
  44. package/dist/stim-frames/VideoEncoder.swift +8 -6
  45. package/dist/stim-frames/WebPage.swift +301 -0
  46. package/dist/stim-frames/main.swift +651 -22
  47. package/dist/stim-host/Info.plist +28 -0
  48. package/dist/stim-host/stim-host.c +153 -0
  49. package/dist/stim-server-OiJI9xLU.mjs +13336 -0
  50. package/dist/stim-server.mjs +6 -3880
  51. package/package.json +3 -3
  52. package/dist/protocol-rC-5KT1O.mjs +0 -822
@@ -0,0 +1,180 @@
1
+ import Foundation
2
+
3
+ public struct DevToolsError: Error, CustomStringConvertible {
4
+ public let description: String
5
+
6
+ public init(_ description: String) {
7
+ self.description = description
8
+ }
9
+ }
10
+
11
+ /// One JSON message of the DevTools protocol: the reply to a command, or an event.
12
+ enum DevToolsMessage {
13
+ case reply(id: Int, Result<[String: Any], DevToolsError>)
14
+ case event(DevToolsClient.Event)
15
+
16
+ /// Nil when `data` is not a JSON object with an `id` or a `method`. A message with an `id` is a reply.
17
+ init?(_ data: Data) {
18
+ guard let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil }
19
+ if let id = object["id"] as? Int {
20
+ if let error = object["error"] as? [String: Any] {
21
+ self = .reply(id: id, .failure(DevToolsError(error["message"] as? String ?? "DevTools command failed.")))
22
+ } else {
23
+ self = .reply(id: id, .success(object["result"] as? [String: Any] ?? [:]))
24
+ }
25
+ } else if let method = object["method"] as? String {
26
+ self = .event((method, object["params"] as? [String: Any] ?? [:], object["sessionId"] as? String))
27
+ } else {
28
+ return nil
29
+ }
30
+ }
31
+ }
32
+
33
+ /// A Chrome DevTools Protocol connection to the browser endpoint of a Chrome Stim owns. `connect` refuses an
34
+ /// endpoint whose browser process is not `chromePid`, the pid `stim status` reports, the same rule the CLI's
35
+ /// `connectOwnedBrowser` applies: the loopback port alone does not prove which Chrome answers it.
36
+ public final class DevToolsClient: @unchecked Sendable {
37
+ public typealias Event = (method: String, params: [String: Any], sessionId: String?)
38
+
39
+ private let task: URLSessionWebSocketTask
40
+ private let session: URLSession
41
+ private let queue = DispatchQueue(label: "stim.web.devtools")
42
+ private var pending: [Int: (Result<[String: Any], DevToolsError>) -> Void] = [:]
43
+ private var nextId = 1
44
+ private var closed = false
45
+ private var eventHandler: ((Event) -> Void)?
46
+ private var closeHandlers: [() -> Void] = []
47
+
48
+ private init(url: URL) {
49
+ session = URLSession(configuration: .ephemeral)
50
+ task = session.webSocketTask(with: url)
51
+ task.maximumMessageSize = 64 << 20
52
+ }
53
+
54
+ /// `endpoint` is `web.cdpEndpoint` from `stim status`, `http://127.0.0.1:<port>`.
55
+ public static func connect(
56
+ endpoint: URL, chromePid: Int32, completion: @escaping (Result<DevToolsClient, DevToolsError>) -> Void
57
+ ) {
58
+ guard endpoint.host == "127.0.0.1", let port = endpoint.port else {
59
+ return completion(.failure(DevToolsError("\(endpoint) is not a loopback DevTools endpoint.")))
60
+ }
61
+ var request = URLRequest(url: endpoint.appendingPathComponent("json/version"))
62
+ request.timeoutInterval = 3
63
+ URLSession.shared.dataTask(with: request) { data, _, error in
64
+ guard let data, let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
65
+ let socket = object["webSocketDebuggerUrl"] as? String, socket.hasPrefix("ws://127.0.0.1:\(port)/"),
66
+ let url = URL(string: socket)
67
+ else {
68
+ let reason = error?.localizedDescription ?? "it does not serve a browser DevTools endpoint"
69
+ return completion(.failure(DevToolsError("Port \(port): \(reason).")))
70
+ }
71
+ let client = DevToolsClient(url: url)
72
+ client.start()
73
+ client.send("SystemInfo.getProcessInfo") { result in
74
+ switch result {
75
+ case .failure(let failure):
76
+ client.close()
77
+ completion(.failure(failure))
78
+ case .success(let reply):
79
+ let processes = reply["processInfo"] as? [[String: Any]] ?? []
80
+ let browser = processes.first { $0["type"] as? String == "browser" }?["id"] as? Int
81
+ guard browser == Int(chromePid) else {
82
+ client.close()
83
+ let found = browser.map(String.init) ?? "unknown"
84
+ return completion(
85
+ .failure(DevToolsError("Port \(port) is served by browser pid \(found), not the owned Chrome \(chromePid).")))
86
+ }
87
+ completion(.success(client))
88
+ }
89
+ }
90
+ }.resume()
91
+ }
92
+
93
+ private func start() {
94
+ task.resume()
95
+ receive()
96
+ }
97
+
98
+ /// Events arrive on the client's private queue, in order.
99
+ public func onEvent(_ handler: @escaping (Event) -> Void) {
100
+ queue.async { self.eventHandler = handler }
101
+ }
102
+
103
+ public func onClose(_ handler: @escaping () -> Void) {
104
+ queue.async {
105
+ if self.closed { handler() } else { self.closeHandlers.append(handler) }
106
+ }
107
+ }
108
+
109
+ public func send(
110
+ _ method: String, _ params: [String: Any] = [:], sessionId: String? = nil,
111
+ reply: ((Result<[String: Any], DevToolsError>) -> Void)? = nil
112
+ ) {
113
+ queue.async {
114
+ guard !self.closed else {
115
+ reply?(.failure(DevToolsError("The DevTools connection is closed.")))
116
+ return
117
+ }
118
+ let id = self.nextId
119
+ self.nextId += 1
120
+ var message: [String: Any] = ["id": id, "method": method, "params": params]
121
+ if let sessionId { message["sessionId"] = sessionId }
122
+ guard let data = try? JSONSerialization.data(withJSONObject: message),
123
+ let text = String(data: data, encoding: .utf8)
124
+ else {
125
+ reply?(.failure(DevToolsError("\(method) has parameters that are not JSON.")))
126
+ return
127
+ }
128
+ if let reply { self.pending[id] = reply }
129
+ self.task.send(.string(text)) { error in
130
+ guard let error else { return }
131
+ self.queue.async { self.pending.removeValue(forKey: id)?(.failure(DevToolsError(error.localizedDescription))) }
132
+ }
133
+ }
134
+ }
135
+
136
+ public func close() {
137
+ queue.async { self.finish() }
138
+ }
139
+
140
+ private func receive() {
141
+ task.receive { [weak self] result in
142
+ guard let self else { return }
143
+ self.queue.async {
144
+ switch result {
145
+ case .failure:
146
+ self.finish()
147
+ case .success(let message):
148
+ switch message {
149
+ case .string(let text): self.handle(Data(text.utf8))
150
+ case .data(let data): self.handle(data)
151
+ @unknown default: break
152
+ }
153
+ if !self.closed { self.receive() }
154
+ }
155
+ }
156
+ }
157
+ }
158
+
159
+ private func handle(_ data: Data) {
160
+ switch DevToolsMessage(data) {
161
+ case .reply(let id, let result): pending.removeValue(forKey: id)?(result)
162
+ case .event(let event): eventHandler?(event)
163
+ case nil: break
164
+ }
165
+ }
166
+
167
+ private func finish() {
168
+ guard !closed else { return }
169
+ closed = true
170
+ eventHandler = nil
171
+ task.cancel(with: .normalClosure, reason: nil)
172
+ session.invalidateAndCancel()
173
+ let replies = pending.values
174
+ pending = [:]
175
+ for reply in replies { reply(.failure(DevToolsError("The DevTools connection closed."))) }
176
+ let handlers = closeHandlers
177
+ closeHandlers = []
178
+ for handler in handlers { handler() }
179
+ }
180
+ }
@@ -0,0 +1,41 @@
1
+ import CoreGraphics
2
+
3
+ /// Installed frame coordinates use a top-left origin, with the screen kept at its native aspect ratio.
4
+ public struct DeviceFrameGeometry: Equatable, Sendable {
5
+ public var size: CGSize
6
+ public var aperture: CGRect
7
+
8
+ public init(size: CGSize, aperture: CGRect) {
9
+ self.size = size
10
+ self.aperture = aperture
11
+ }
12
+
13
+ public func rotated(quarterTurns: Int) -> Self {
14
+ switch (quarterTurns % 4 + 4) % 4 {
15
+ case 1:
16
+ return Self(
17
+ size: CGSize(width: size.height, height: size.width),
18
+ aperture: CGRect(x: size.height - aperture.maxY, y: aperture.minX, width: aperture.height, height: aperture.width))
19
+ case 2:
20
+ return Self(
21
+ size: size,
22
+ aperture: CGRect(
23
+ x: size.width - aperture.maxX, y: size.height - aperture.maxY, width: aperture.width, height: aperture.height))
24
+ case 3:
25
+ return Self(
26
+ size: CGSize(width: size.height, height: size.width),
27
+ aperture: CGRect(x: aperture.minY, y: size.width - aperture.maxX, width: aperture.height, height: aperture.width))
28
+ default: return self
29
+ }
30
+ }
31
+
32
+ public func fitted(in bounds: CGRect, scale: CGFloat? = nil) -> Self {
33
+ let scale = scale ?? min(bounds.width / size.width, bounds.height / size.height)
34
+ let origin = CGPoint(x: bounds.midX - size.width * scale / 2, y: bounds.midY - size.height * scale / 2)
35
+ return Self(
36
+ size: CGSize(width: size.width * scale, height: size.height * scale),
37
+ aperture: CGRect(
38
+ x: origin.x + aperture.minX * scale, y: origin.y + aperture.minY * scale,
39
+ width: aperture.width * scale, height: aperture.height * scale))
40
+ }
41
+ }
@@ -0,0 +1,167 @@
1
+ import AppKit
2
+
3
+ /// Artwork is loaded from the user's installed developer tools, never bundled with Stim.
4
+ @MainActor
5
+ public final class DeviceFrameArtwork {
6
+ public let geometry: DeviceFrameGeometry
7
+ public let cornerRadius: CGFloat
8
+ let background: (CGRect) -> Void
9
+ let foreground: (CGRect) -> Void
10
+
11
+ public init(
12
+ geometry: DeviceFrameGeometry, cornerRadius: CGFloat = 0,
13
+ background: @escaping (CGRect) -> Void, foreground: @escaping (CGRect) -> Void = { _ in }
14
+ ) {
15
+ self.geometry = geometry
16
+ self.cornerRadius = cornerRadius
17
+ self.background = background
18
+ self.foreground = foreground
19
+ }
20
+
21
+ /// Rasterizes installed artwork in upright frame coordinates, keeping screen pixels out of both PNG layers.
22
+ public func pngLayers(quarterTurns: Int) -> (background: Data, foreground: Data)? {
23
+ let size = geometry.rotated(quarterTurns: quarterTurns).size
24
+ let scale = min(1, 2048 / max(size.width, size.height))
25
+ let bounds = CGRect(x: 0, y: 0, width: ceil(size.width * scale), height: ceil(size.height * scale))
26
+ func layer(foreground: Bool) -> Data? {
27
+ guard
28
+ let bitmap = NSBitmapImageRep(
29
+ bitmapDataPlanes: nil, pixelsWide: Int(bounds.width), pixelsHigh: Int(bounds.height),
30
+ bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, isPlanar: false,
31
+ colorSpaceName: .deviceRGB, bytesPerRow: 0, bitsPerPixel: 0),
32
+ let context = NSGraphicsContext(bitmapImageRep: bitmap)
33
+ else { return nil }
34
+ NSGraphicsContext.saveGraphicsState()
35
+ defer { NSGraphicsContext.restoreGraphicsState() }
36
+ context.cgContext.clear(bounds)
37
+ context.cgContext.translateBy(x: 0, y: bounds.height)
38
+ context.cgContext.scaleBy(x: 1, y: -1)
39
+ NSGraphicsContext.current = NSGraphicsContext(cgContext: context.cgContext, flipped: true)
40
+ drawFrameArtwork(self, quarterTurns: quarterTurns, in: bounds, scale: nil, offset: .zero, foreground: foreground)
41
+ return bitmap.representation(using: .png, properties: [:])
42
+ }
43
+ guard let background = layer(foreground: false), let foreground = layer(foreground: true) else { return nil }
44
+ return (background, foreground)
45
+ }
46
+ }
47
+
48
+ /// Keeps the existing display/input view inside the installed artwork's screen aperture.
49
+ @MainActor
50
+ public final class DeviceFrameNSView: NSView {
51
+ public let screen: NSView
52
+ public var artwork: DeviceFrameArtwork? { didSet { updateFrame() } }
53
+ public var showsFrame = false { didSet { if oldValue != showsFrame { updateFrame() } } }
54
+ public var quarterTurns = 0 { didSet { if oldValue != quarterTurns { updateFrame() } } }
55
+ public var artworkScale: CGFloat? { didSet { if oldValue != artworkScale { updateFrame() } } }
56
+ public var accurateScreenSize: CGSize? { didSet { if oldValue != accurateScreenSize { updateFrame() } } }
57
+ public var onFrameSizeChange: (CGSize?) -> Void = { _ in }
58
+ private let overlay = FrameOverlay()
59
+ private var reportedSize: CGSize?
60
+ private var artworkOffset = CGPoint.zero
61
+ public override var isFlipped: Bool { true }
62
+
63
+ public init(screen: NSView) {
64
+ self.screen = screen
65
+ super.init(frame: .zero)
66
+ addSubview(screen)
67
+ addSubview(overlay)
68
+ }
69
+
70
+ required init?(coder: NSCoder) { nil }
71
+
72
+ private var activeArtwork: DeviceFrameArtwork? { showsFrame ? artwork : nil }
73
+
74
+ private func updateFrame() {
75
+ needsLayout = true
76
+ needsDisplay = true
77
+ overlay.needsDisplay = true
78
+ let size = artwork?.geometry.rotated(quarterTurns: quarterTurns).size
79
+ guard size != reportedSize else { return }
80
+ reportedSize = size
81
+ DispatchQueue.main.async { [weak self] in self?.onFrameSizeChange(size) }
82
+ }
83
+
84
+ public override func layout() {
85
+ super.layout()
86
+ overlay.frame = bounds
87
+ overlay.artwork = activeArtwork
88
+ overlay.quarterTurns = quarterTurns
89
+ overlay.scale = artworkScale
90
+ artworkOffset = .zero
91
+ var aperture = bounds
92
+ if let art = activeArtwork {
93
+ let geometry = art.geometry.rotated(quarterTurns: quarterTurns)
94
+ aperture = geometry.fitted(in: bounds, scale: artworkScale).aperture
95
+ } else if let size = accurateScreenSize {
96
+ aperture = CGRect(x: bounds.midX - size.width / 2, y: bounds.midY - size.height / 2, width: size.width, height: size.height)
97
+ }
98
+ if artworkScale != nil || accurateScreenSize != nil {
99
+ let backing = window?.backingScaleFactor ?? 1
100
+ let origin = convert(aperture.origin, to: nil)
101
+ let aligned = convert(
102
+ CGPoint(x: (origin.x * backing).rounded() / backing, y: (origin.y * backing).rounded() / backing), from: nil)
103
+ artworkOffset = CGPoint(x: aligned.x - aperture.minX, y: aligned.y - aperture.minY)
104
+ aperture.origin = aligned
105
+ }
106
+ screen.frame = aperture
107
+ if let art = activeArtwork {
108
+ let geometry = art.geometry.rotated(quarterTurns: quarterTurns)
109
+ screen.layer?.cornerRadius = art.cornerRadius * aperture.width / geometry.aperture.width
110
+ } else {
111
+ screen.layer?.cornerRadius = 0
112
+ }
113
+ screen.layer?.masksToBounds = activeArtwork != nil
114
+ overlay.offset = artworkOffset
115
+ needsDisplay = true
116
+ overlay.needsDisplay = true
117
+ }
118
+
119
+ public override func draw(_ dirtyRect: NSRect) {
120
+ drawFrameArtwork(
121
+ activeArtwork, quarterTurns: quarterTurns, in: bounds, scale: artworkScale, offset: artworkOffset, foreground: false)
122
+ }
123
+ }
124
+
125
+ @MainActor
126
+ private final class FrameOverlay: NSView {
127
+ var artwork: DeviceFrameArtwork?
128
+ var quarterTurns = 0
129
+ var scale: CGFloat?
130
+ var offset = CGPoint.zero
131
+ override var isFlipped: Bool { true }
132
+ override func hitTest(_ point: NSPoint) -> NSView? { nil }
133
+ override func draw(_ dirtyRect: NSRect) {
134
+ drawFrameArtwork(artwork, quarterTurns: quarterTurns, in: bounds, scale: scale, offset: offset, foreground: true)
135
+ }
136
+ }
137
+
138
+ @MainActor
139
+ private func drawFrameArtwork(
140
+ _ artwork: DeviceFrameArtwork?, quarterTurns: Int, in bounds: CGRect, scale: CGFloat?, offset: CGPoint, foreground: Bool
141
+ ) {
142
+ guard let artwork, let context = NSGraphicsContext.current?.cgContext else { return }
143
+ let turns = (quarterTurns % 4 + 4) % 4
144
+ let size = artwork.geometry.rotated(quarterTurns: turns).size
145
+ let scale = scale ?? min(bounds.width / size.width, bounds.height / size.height)
146
+ context.saveGState()
147
+ defer { context.restoreGState() }
148
+ context.translateBy(x: bounds.midX - size.width * scale / 2 + offset.x, y: bounds.midY - size.height * scale / 2 + offset.y)
149
+ context.scaleBy(x: scale, y: scale)
150
+ switch turns {
151
+ case 1:
152
+ context.translateBy(x: artwork.geometry.size.height, y: 0)
153
+ context.rotate(by: .pi / 2)
154
+ case 2:
155
+ context.translateBy(x: artwork.geometry.size.width, y: artwork.geometry.size.height)
156
+ context.rotate(by: .pi)
157
+ case 3:
158
+ context.translateBy(x: 0, y: artwork.geometry.size.width)
159
+ context.rotate(by: -.pi / 2)
160
+ default: break
161
+ }
162
+ if foreground {
163
+ artwork.foreground(CGRect(origin: .zero, size: artwork.geometry.size))
164
+ } else {
165
+ artwork.background(CGRect(origin: .zero, size: artwork.geometry.size))
166
+ }
167
+ }
@@ -0,0 +1,132 @@
1
+ import AppKit
2
+ import IOSurface
3
+
4
+ @MainActor
5
+ final class DuoCaptureSource {
6
+ private let udid: String
7
+ private let changed: () -> Void
8
+ private let renderer: DuoFrameRenderer?
9
+ private var requested: DuoCapture?
10
+ private var monitor: Process?
11
+ private var input: Pipe?
12
+ private var angle: Double?
13
+ private var available: Bool?
14
+
15
+ init(udid: String, changed: @escaping () -> Void) {
16
+ self.udid = udid
17
+ self.changed = changed
18
+ renderer = DuoFrameRenderer.load(udid: udid)
19
+ }
20
+
21
+ func configure(_ requested: DuoCapture?) -> DuoFrameRenderer.Touch? {
22
+ self.requested = requested
23
+ guard requested != nil, renderer != nil else {
24
+ input?.fileHandleForWriting.closeFile()
25
+ input = nil
26
+ angle = nil
27
+ publish(false)
28
+ return renderer?.release()
29
+ }
30
+ if monitor == nil { startMonitor() }
31
+ return nil
32
+ }
33
+
34
+ func render(
35
+ surfaces: [(screenID: UInt32, surface: IOSurface?)], activeID: UInt32,
36
+ orientation: UInt32, viewport: CGSize, config: Config
37
+ ) {
38
+ guard let requested, let renderer, let angle else { return publish(false) }
39
+ var composed = config
40
+ composed.maxEdge = requested.maxEdge
41
+ let scale = min(1, CGFloat(requested.maxEdge) / max(viewport.width, viewport.height))
42
+ let canvas = CGSize(width: viewport.width * scale, height: viewport.height * scale)
43
+ guard
44
+ let frame = renderer.render(
45
+ surfaces: surfaces, activeID: activeID, orientation: orientation,
46
+ angle: angle, viewport: canvas, config: composed
47
+ )
48
+ else { return publish(false) }
49
+ Output.duoFrame(frame)
50
+ publish(true)
51
+ }
52
+
53
+ func touch(_ phase: TouchPhase, point: CGPoint, revision: String) -> DuoFrameRenderer.Touch? {
54
+ guard requested != nil else { return nil }
55
+ return renderer?.touch(phase: phase, at: point, revision: revision)
56
+ }
57
+
58
+ func release() -> DuoFrameRenderer.Touch? { renderer?.release() }
59
+
60
+ private func publish(_ next: Bool) {
61
+ guard next != available else { return }
62
+ available = next
63
+ Output.notice(["duoAvailable": next])
64
+ }
65
+
66
+ private func startMonitor() {
67
+ guard requested != nil, renderer != nil, monitor == nil else { return }
68
+ let process = Process()
69
+ process.executableURL = URL(fileURLWithPath: CommandLine.arguments[0])
70
+ process.arguments = ["hinge-monitor", udid]
71
+ var environment = ProcessInfo.processInfo.environment
72
+ environment["DEVELOPER_DIR"] = CoreSimulator.developerDir
73
+ process.environment = environment
74
+ let hold = Pipe()
75
+ let output = Pipe()
76
+ process.standardInput = hold
77
+ process.standardOutput = output
78
+ process.standardError = FileHandle.nullDevice
79
+ let buffer = DuoMonitorLines()
80
+ output.fileHandleForReading.readabilityHandler = { [weak self] handle in
81
+ let bytes = handle.availableData
82
+ if bytes.isEmpty {
83
+ handle.readabilityHandler = nil
84
+ return
85
+ }
86
+ let samples = buffer.append(bytes).compactMap(SimulatorHingeAngle.parse)
87
+ guard let newest = samples.last else { return }
88
+ DispatchQueue.main.async {
89
+ MainActor.assumeIsolated {
90
+ guard let self, self.monitor === process else { return }
91
+ self.angle = newest
92
+ self.changed()
93
+ }
94
+ }
95
+ }
96
+ process.terminationHandler = { [weak self] _ in
97
+ output.fileHandleForReading.readabilityHandler = nil
98
+ DispatchQueue.main.async {
99
+ MainActor.assumeIsolated {
100
+ guard let self, self.monitor === process else { return }
101
+ self.monitor = nil
102
+ self.input = nil
103
+ self.angle = nil
104
+ self.publish(false)
105
+ self.changed()
106
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
107
+ MainActor.assumeIsolated { self.startMonitor() }
108
+ }
109
+ }
110
+ }
111
+ }
112
+ do {
113
+ try process.run()
114
+ monitor = process
115
+ input = hold
116
+ } catch {
117
+ output.fileHandleForReading.readabilityHandler = nil
118
+ publish(false)
119
+ }
120
+ }
121
+ }
122
+
123
+ private final class DuoMonitorLines: @unchecked Sendable {
124
+ private let lock = NSLock()
125
+ private var buffer = LineBuffer()
126
+
127
+ func append(_ data: Data) -> [String] {
128
+ lock.lock()
129
+ defer { lock.unlock() }
130
+ return buffer.append(data)
131
+ }
132
+ }
@@ -0,0 +1,37 @@
1
+ import Foundation
2
+
3
+ @MainActor
4
+ struct DuoDeviceProfile {
5
+ let inner: UInt32
6
+ let cover: UInt32
7
+ let turns: [UInt32: Int]
8
+
9
+ static func load(udid: String) -> DuoDeviceProfile? {
10
+ guard let resources = SimulatorFrameArtwork.resources(udid: udid),
11
+ Bundle(url: resources.deletingLastPathComponent().deletingLastPathComponent())?.bundleIdentifier
12
+ == "com.apple.CoreSimulator.SimDeviceType.iPhone-Duo",
13
+ let data = try? Data(contentsOf: resources.appendingPathComponent("capabilities.plist")),
14
+ let plist = try? PropertyListSerialization.propertyList(from: data, format: nil) as? [String: Any],
15
+ let capabilities = plist["capabilities"] as? [String: Any],
16
+ let all = capabilities["displays"] as? [[String: Any]]
17
+ else { return nil }
18
+ let integrated = all.filter { $0["displayType"] as? String == "integrated" }
19
+ guard integrated.count == 2 else { return nil }
20
+ let sorted = integrated.sorted { area($0) < area($1) }
21
+ guard let cover = sorted[0]["screenID"] as? NSNumber,
22
+ let inner = sorted[1]["screenID"] as? NSNumber, cover != inner
23
+ else { return nil }
24
+ var turns: [UInt32: Int] = [:]
25
+ for display in sorted {
26
+ guard let id = display["screenID"] as? NSNumber,
27
+ let rotation = display["nativeRotation"] as? NSNumber, rotation.intValue % 90 == 0
28
+ else { return nil }
29
+ turns[id.uint32Value] = (-rotation.intValue / 90 % 4 + 4) % 4
30
+ }
31
+ return DuoDeviceProfile(inner: inner.uint32Value, cover: cover.uint32Value, turns: turns)
32
+ }
33
+
34
+ private static func area(_ display: [String: Any]) -> Double {
35
+ ((display["width"] as? NSNumber)?.doubleValue ?? 0) * ((display["height"] as? NSNumber)?.doubleValue ?? 0)
36
+ }
37
+ }
@@ -0,0 +1,111 @@
1
+ import AppKit
2
+ import CoreImage
3
+ import IOSurface
4
+ import SceneKit
5
+
6
+ @MainActor
7
+ final class DuoFrameRenderer {
8
+ struct Frame {
9
+ let jpeg: Data
10
+ let width: Int
11
+ let height: Int
12
+ let revision: String
13
+ let screenID: UInt32
14
+ let angle: Double
15
+ let orientation: UInt32
16
+ }
17
+
18
+ struct Touch {
19
+ let point: CGPoint
20
+ let screenID: UInt32
21
+ }
22
+
23
+ private struct Pose: Equatable {
24
+ let angle: Double
25
+ let orientation: UInt32
26
+ let screenID: UInt32
27
+ let viewport: CGSize
28
+ }
29
+
30
+ private let model: DuoModelView
31
+ private let inputModel: DuoModelView
32
+ private var poses: [(revision: String, pose: Pose)] = []
33
+ private var gesture: (revision: String, pose: Pose, last: CGPoint)?
34
+ private var rendered: Pose?
35
+
36
+ static func load(udid: String) -> DuoFrameRenderer? {
37
+ guard let profile = DuoDeviceProfile.load(udid: udid),
38
+ let model = DuoModelView.load(innerID: profile.inner, coverID: profile.cover, nativeTurns: profile.turns),
39
+ let inputModel = DuoModelView.load(innerID: profile.inner, coverID: profile.cover, nativeTurns: profile.turns)
40
+ else { return nil }
41
+ return DuoFrameRenderer(model: model, inputModel: inputModel)
42
+ }
43
+
44
+ private init(model: DuoModelView, inputModel: DuoModelView) {
45
+ self.model = model
46
+ self.inputModel = inputModel
47
+ }
48
+
49
+ func render(
50
+ surfaces: [(screenID: UInt32, surface: IOSurface?)], activeID: UInt32,
51
+ orientation: UInt32, angle: Double, viewport: CGSize, config: Config
52
+ ) -> Frame? {
53
+ guard angle.isFinite, (0...180).contains(angle), viewport.width > 0, viewport.height > 0,
54
+ activeID == model.coverID || activeID == model.innerID
55
+ else { return nil }
56
+ model.frame = CGRect(origin: .zero, size: viewport)
57
+ model.layoutSubtreeIfNeeded()
58
+ model.setPose(angle: CGFloat(angle), orientation: orientation, activeID: activeID)
59
+ for surface in surfaces where surface.screenID == model.coverID || surface.screenID == model.innerID {
60
+ model.updateSurface(surface.surface, screenID: surface.screenID)
61
+ }
62
+ let pose = Pose(angle: angle, orientation: orientation, screenID: activeID, viewport: viewport)
63
+ if pose != rendered {
64
+ // A hidden SCNView can return the previous skinned frame after a pose change; flush and
65
+ // discard one snapshot first. https://github.com/kmagiera/Siniulator/pull/9
66
+ SCNTransaction.flush()
67
+ _ = model.snapshot()
68
+ rendered = pose
69
+ }
70
+ guard let image = model.snapshot().cgImage(forProposedRect: nil, context: nil, hints: nil),
71
+ let (data, width, height) = jpeg(CIImage(cgImage: image), config: config)
72
+ else { return nil }
73
+ model.preparePanelChange()
74
+ let revision: String
75
+ if let previous = poses.last, previous.pose == pose {
76
+ revision = previous.revision
77
+ } else {
78
+ revision = UUID().uuidString
79
+ poses.append((revision, pose))
80
+ if poses.count > 32 { poses.removeFirst() }
81
+ }
82
+ return Frame(
83
+ jpeg: data, width: width, height: height, revision: revision, screenID: activeID, angle: angle, orientation: orientation)
84
+ }
85
+
86
+ func touch(phase: TouchPhase, at point: CGPoint, revision: String) -> Touch? {
87
+ let pose: Pose
88
+ if phase == .down {
89
+ guard gesture == nil, let shown = poses.first(where: { $0.revision == revision }) else { return nil }
90
+ pose = shown.pose
91
+ } else {
92
+ guard let held = gesture, held.revision == revision else { return nil }
93
+ pose = held.pose
94
+ }
95
+ inputModel.frame = CGRect(origin: .zero, size: pose.viewport)
96
+ inputModel.layoutSubtreeIfNeeded()
97
+ inputModel.setPose(angle: CGFloat(pose.angle), orientation: pose.orientation, activeID: pose.screenID)
98
+ let location = CGPoint(x: point.x * pose.viewport.width, y: (1 - point.y) * pose.viewport.height)
99
+ guard let native = inputModel.nativeScreenPoint(location, clamped: phase != .down) else {
100
+ return phase == .up ? release() : nil
101
+ }
102
+ gesture = phase == .up ? nil : (revision, pose, native)
103
+ return Touch(point: native, screenID: pose.screenID)
104
+ }
105
+
106
+ func release() -> Touch? {
107
+ guard let held = gesture else { return nil }
108
+ gesture = nil
109
+ return Touch(point: held.last, screenID: held.pose.screenID)
110
+ }
111
+ }