@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.
- package/README.md +1590 -42
- package/dist/protocol-MBMvJpvV.mjs +2715 -0
- package/dist/protocol.d.mts +2 -473
- package/dist/protocol.mjs +3 -2
- package/dist/protocol.schema.json +5121 -514
- 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 -3880
- package/package.json +3 -3
- 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
|
+
}
|