@astur-mobile/ios 0.1.0-beta.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.
@@ -0,0 +1,144 @@
1
+ import Foundation
2
+
3
+ final class AsturAgentBridgeClient {
4
+ private let agent: AsturAgent
5
+ private let bridgeUrl: URL
6
+
7
+ /// Long-poll timeout: the agent holds the GET /command request open for up to
8
+ /// this duration on the server side, eliminating the 100ms polling gap.
9
+ /// Falls back to a short retry on network errors so the loop stays resilient.
10
+ private let longPollTimeout: TimeInterval = 30
11
+
12
+ init(agent: AsturAgent, bridgeUrl: URL) {
13
+ self.agent = agent
14
+ self.bridgeUrl = bridgeUrl
15
+ }
16
+
17
+ func run() {
18
+ register()
19
+
20
+ while true {
21
+ autoreleasepool {
22
+ if let command = fetchCommand() {
23
+ postResult(agent.dispatch(command))
24
+ }
25
+ // No sleep — fetchCommand blocks via long-poll or returns immediately
26
+ // on error. A tiny back-off only on consecutive transport failures.
27
+ }
28
+ }
29
+ }
30
+
31
+ private func register() {
32
+ let info = agent.dispatch(AsturCommand(id: "register", method: "agent.ping", params: [:])).result ?? [:]
33
+ _ = request(
34
+ path: "/register",
35
+ method: "POST",
36
+ body: [
37
+ "id": "register",
38
+ "ok": true,
39
+ "result": info,
40
+ "data": info
41
+ ],
42
+ timeout: 5
43
+ )
44
+ }
45
+
46
+ private func fetchCommand() -> AsturCommand? {
47
+ // Long-poll: server holds the connection open until a command is queued
48
+ // or the timeout elapses (returns 204). This removes the old 100ms
49
+ // polling gap and delivers commands with near-zero latency.
50
+ let response = request(path: "/command", method: "GET", timeout: longPollTimeout + 2)
51
+ guard response.status == 200, let data = response.data else {
52
+ // On 204 (no command yet) or transient error, return nil and
53
+ // immediately re-enter the loop — no Thread.sleep needed.
54
+ return nil
55
+ }
56
+
57
+ guard
58
+ let raw = try? JSONSerialization.jsonObject(with: data),
59
+ let json = raw as? [String: Any]
60
+ else {
61
+ return nil
62
+ }
63
+
64
+ let id = (json["id"] as? String)?.nonEmpty ?? "unknown"
65
+ let method = (json["method"] as? String)?.nonEmpty
66
+ ?? (json["command"] as? String)?.nonEmpty
67
+ ?? "unknown"
68
+ let params = (json["params"] as? [String: Any])
69
+ ?? (json["payload"] as? [String: Any])
70
+ ?? [:]
71
+
72
+ return AsturCommand(id: id, method: method, params: params)
73
+ }
74
+
75
+ private func postResult(_ result: AsturCommandResult) {
76
+ _ = request(path: "/response", method: "POST", body: resultJson(result), timeout: 5)
77
+ }
78
+
79
+ private func resultJson(_ result: AsturCommandResult) -> [String: Any] {
80
+ var body: [String: Any] = [
81
+ "id": result.id,
82
+ "ok": result.ok,
83
+ "timing": [
84
+ "totalMs": 0,
85
+ "agentMs": 0,
86
+ "hostRoundTrips": 1,
87
+ "usedSnapshot": false,
88
+ "usedLegacyFallback": false
89
+ ]
90
+ ]
91
+
92
+ if result.ok {
93
+ body["result"] = result.result ?? NSNull()
94
+ body["data"] = result.result ?? NSNull()
95
+ } else {
96
+ var error: [String: Any] = [
97
+ "code": result.error?.code ?? "UNKNOWN",
98
+ "message": result.error?.message ?? "Unknown iOS agent error."
99
+ ]
100
+ if let details = result.error?.details {
101
+ error["details"] = details
102
+ if let detailMap = details as? [String: Any] {
103
+ if let diagnostics = detailMap["diagnostics"] {
104
+ body["diagnostics"] = diagnostics
105
+ }
106
+ }
107
+ }
108
+ body["error"] = error
109
+ }
110
+
111
+ return body
112
+ }
113
+
114
+ private func request(path: String, method: String, body: [String: Any]? = nil, timeout: TimeInterval = 5) -> (status: Int, data: Data?) {
115
+ let url = bridgeUrl.appendingPathComponent(path.trimmingCharacters(in: CharacterSet(charactersIn: "/")))
116
+ var request = URLRequest(url: url)
117
+ request.httpMethod = method
118
+ request.timeoutInterval = timeout
119
+
120
+ if let body {
121
+ request.setValue("application/json", forHTTPHeaderField: "content-type")
122
+ request.httpBody = try? JSONSerialization.data(withJSONObject: body)
123
+ }
124
+
125
+ let semaphore = DispatchSemaphore(value: 0)
126
+ var status = 0
127
+ var data: Data?
128
+
129
+ URLSession.shared.dataTask(with: request) { responseData, response, _ in
130
+ status = (response as? HTTPURLResponse)?.statusCode ?? 0
131
+ data = responseData
132
+ semaphore.signal()
133
+ }.resume()
134
+
135
+ _ = semaphore.wait(timeout: .now() + timeout + 1)
136
+ return (status, data)
137
+ }
138
+ }
139
+
140
+ private extension String {
141
+ var nonEmpty: String? {
142
+ isEmpty ? nil : self
143
+ }
144
+ }
@@ -0,0 +1,249 @@
1
+ import Foundation
2
+ import Network
3
+
4
+ final class AsturAgentServer {
5
+ private let port: UInt16
6
+ private let agent: AsturAgent
7
+ private let queue = DispatchQueue(label: "dev.astur.ios-agent.server")
8
+ private var listener: NWListener?
9
+
10
+ init(port: UInt16, agent: AsturAgent) {
11
+ self.port = port
12
+ self.agent = agent
13
+ }
14
+
15
+ func start() throws {
16
+ let listener = try NWListener(using: .tcp, on: NWEndpoint.Port(rawValue: port)!)
17
+ listener.stateUpdateHandler = { state in
18
+ print("Astur iOS agent listener state: \(state)")
19
+ }
20
+ listener.newConnectionHandler = { [weak self] connection in
21
+ self?.handle(connection)
22
+ }
23
+ listener.start(queue: queue)
24
+ self.listener = listener
25
+ }
26
+
27
+ func stop() {
28
+ listener?.cancel()
29
+ listener = nil
30
+ }
31
+
32
+ private func handle(_ connection: NWConnection) {
33
+ connection.start(queue: queue)
34
+ read(connection, buffer: Data())
35
+ }
36
+
37
+ private func read(_ connection: NWConnection, buffer: Data) {
38
+ connection.receive(minimumIncompleteLength: 1, maximumLength: 64 * 1024) { [weak self] data, _, isComplete, error in
39
+ guard let self else {
40
+ connection.cancel()
41
+ return
42
+ }
43
+
44
+ if error != nil {
45
+ connection.cancel()
46
+ return
47
+ }
48
+
49
+ var next = buffer
50
+ if let data {
51
+ next.append(data)
52
+ }
53
+
54
+ if let request = HttpRequest(data: next) {
55
+ self.respond(to: request, connection: connection)
56
+ return
57
+ }
58
+
59
+ if isComplete {
60
+ self.write(
61
+ connection,
62
+ status: 400,
63
+ body: self.errorJson(id: "unknown", code: "BAD_REQUEST", message: "Incomplete Astur HTTP command.")
64
+ )
65
+ return
66
+ }
67
+
68
+ self.read(connection, buffer: next)
69
+ }
70
+ }
71
+
72
+ private func respond(to request: HttpRequest, connection: NWConnection) {
73
+ do {
74
+ let body: [String: Any]
75
+ if request.method == "GET" && request.path == "/health" {
76
+ let startedAt = Date()
77
+ body = resultJson(
78
+ agent.dispatch(AsturCommand(id: "health", method: "agent.ping", params: [:])),
79
+ elapsedMs: elapsedMs(startedAt)
80
+ )
81
+ } else if request.method == "POST" {
82
+ let command = try parseCommand(request.body)
83
+ let startedAt = Date()
84
+ body = resultJson(agent.dispatch(command), elapsedMs: elapsedMs(startedAt))
85
+ } else {
86
+ body = errorJson(
87
+ id: "unknown",
88
+ code: "UNSUPPORTED_HTTP_METHOD",
89
+ message: "Astur agent expects POST commands."
90
+ )
91
+ }
92
+
93
+ write(connection, status: 200, body: body)
94
+ } catch {
95
+ write(
96
+ connection,
97
+ status: 500,
98
+ body: errorJson(
99
+ id: "unknown",
100
+ code: "SERVER_ERROR",
101
+ message: String(describing: error)
102
+ )
103
+ )
104
+ }
105
+ }
106
+
107
+ private func parseCommand(_ body: Data) throws -> AsturCommand {
108
+ let raw = try JSONSerialization.jsonObject(with: body)
109
+ guard let json = raw as? [String: Any] else {
110
+ throw ServerFailure("Astur command must be a JSON object.")
111
+ }
112
+
113
+ let id = (json["id"] as? String)?.nonEmpty ?? "unknown"
114
+ let method = (json["method"] as? String)?.nonEmpty
115
+ ?? (json["command"] as? String)?.nonEmpty
116
+
117
+ guard let method else {
118
+ throw ServerFailure("Astur command must include method or command.")
119
+ }
120
+
121
+ let params = (json["params"] as? [String: Any])
122
+ ?? (json["payload"] as? [String: Any])
123
+ ?? [:]
124
+
125
+ return AsturCommand(id: id, method: method, params: params)
126
+ }
127
+
128
+ private func resultJson(_ result: AsturCommandResult, elapsedMs: Int) -> [String: Any] {
129
+ var body: [String: Any] = [
130
+ "id": result.id,
131
+ "ok": result.ok,
132
+ "timing": [
133
+ "totalMs": elapsedMs,
134
+ "agentMs": elapsedMs,
135
+ "hostRoundTrips": 1,
136
+ "usedSnapshot": false,
137
+ "usedLegacyFallback": false
138
+ ]
139
+ ]
140
+
141
+ if result.ok {
142
+ body["result"] = result.result ?? NSNull()
143
+ body["data"] = result.result ?? NSNull()
144
+ } else {
145
+ var error: [String: Any] = [
146
+ "code": result.error?.code ?? "UNKNOWN",
147
+ "message": result.error?.message ?? "Unknown iOS agent error."
148
+ ]
149
+ if let details = result.error?.details {
150
+ error["details"] = details
151
+ if let detailMap = details as? [String: Any] {
152
+ body["diagnostics"] = detailMap["diagnostics"] ?? detailMap
153
+ }
154
+ }
155
+ body["error"] = error
156
+ }
157
+
158
+ return body
159
+ }
160
+
161
+ private func errorJson(id: String, code: String, message: String) -> [String: Any] {
162
+ [
163
+ "id": id,
164
+ "ok": false,
165
+ "error": [
166
+ "code": code,
167
+ "message": message
168
+ ],
169
+ "timing": [
170
+ "totalMs": 0,
171
+ "agentMs": 0,
172
+ "hostRoundTrips": 1
173
+ ]
174
+ ]
175
+ }
176
+
177
+ private func write(_ connection: NWConnection, status: Int, body: [String: Any]) {
178
+ let payload = (try? JSONSerialization.data(withJSONObject: body)) ?? Data()
179
+ var response = Data()
180
+ let header = """
181
+ HTTP/1.1 \(status) \(status == 200 ? "OK" : "Error")\r
182
+ content-type: application/json\r
183
+ content-length: \(payload.count)\r
184
+ connection: close\r
185
+ \r
186
+
187
+ """
188
+ response.append(header.data(using: .utf8)!)
189
+ response.append(payload)
190
+ connection.send(content: response, completion: .contentProcessed { _ in
191
+ connection.cancel()
192
+ })
193
+ }
194
+
195
+ private func elapsedMs(_ startedAt: Date) -> Int {
196
+ max(0, Int(Date().timeIntervalSince(startedAt) * 1_000))
197
+ }
198
+ }
199
+
200
+ private struct HttpRequest {
201
+ let method: String
202
+ let path: String
203
+ let body: Data
204
+
205
+ init?(data: Data) {
206
+ guard let separator = data.range(of: Data("\r\n\r\n".utf8)) else {
207
+ return nil
208
+ }
209
+
210
+ let headerData = data[..<separator.lowerBound]
211
+ guard let header = String(data: headerData, encoding: .utf8) else {
212
+ return nil
213
+ }
214
+
215
+ let lines = header.components(separatedBy: "\r\n")
216
+ let requestLine = lines.first?.split(separator: " ").map(String.init) ?? []
217
+ guard requestLine.count >= 2 else {
218
+ return nil
219
+ }
220
+
221
+ let contentLength = lines
222
+ .first { $0.lowercased().hasPrefix("content-length:") }
223
+ .flatMap { Int($0.split(separator: ":", maxSplits: 1).last?.trimmingCharacters(in: .whitespaces) ?? "") }
224
+ ?? 0
225
+
226
+ let bodyStart = separator.upperBound
227
+ guard data.count - bodyStart >= contentLength else {
228
+ return nil
229
+ }
230
+
231
+ self.method = requestLine[0]
232
+ self.path = requestLine[1]
233
+ self.body = data[bodyStart..<(bodyStart + contentLength)]
234
+ }
235
+ }
236
+
237
+ private struct ServerFailure: Error, CustomStringConvertible {
238
+ let description: String
239
+
240
+ init(_ description: String) {
241
+ self.description = description
242
+ }
243
+ }
244
+
245
+ private extension String {
246
+ var nonEmpty: String? {
247
+ isEmpty ? nil : self
248
+ }
249
+ }
@@ -0,0 +1,30 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3
+ <plist version="1.0">
4
+ <dict>
5
+ <key>ASTUR_AUT_BUNDLE_ID</key>
6
+ <string>$(ASTUR_AUT_BUNDLE_ID)</string>
7
+ <key>ASTUR_AUT_LAUNCH</key>
8
+ <string>$(ASTUR_AUT_LAUNCH)</string>
9
+ <key>ASTUR_IOS_AGENT_BRIDGE_URL</key>
10
+ <string>$(ASTUR_IOS_AGENT_BRIDGE_URL)</string>
11
+ <key>ASTUR_IOS_AGENT_PORT</key>
12
+ <string>$(ASTUR_IOS_AGENT_PORT)</string>
13
+ <key>CFBundleDevelopmentRegion</key>
14
+ <string>$(DEVELOPMENT_LANGUAGE)</string>
15
+ <key>CFBundleExecutable</key>
16
+ <string>$(EXECUTABLE_NAME)</string>
17
+ <key>CFBundleIdentifier</key>
18
+ <string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
19
+ <key>CFBundleInfoDictionaryVersion</key>
20
+ <string>6.0</string>
21
+ <key>CFBundleName</key>
22
+ <string>$(PRODUCT_NAME)</string>
23
+ <key>CFBundlePackageType</key>
24
+ <string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
25
+ <key>CFBundleShortVersionString</key>
26
+ <string>1.0</string>
27
+ <key>CFBundleVersion</key>
28
+ <string>1</string>
29
+ </dict>
30
+ </plist>
@@ -0,0 +1,55 @@
1
+ import XCTest
2
+
3
+ final class AsturAgentUITests: XCTestCase {
4
+ private var server: AsturAgentServer?
5
+
6
+ override func tearDown() {
7
+ server?.stop()
8
+ server = nil
9
+ super.tearDown()
10
+ }
11
+
12
+ func testAgentServer() throws {
13
+ let environment = ProcessInfo.processInfo.environment
14
+ let bundleIdentifier = runtimeValue("ASTUR_AUT_BUNDLE_ID", environment: environment) ?? "com.astur.demo"
15
+ let port = UInt16(runtimeValue("ASTUR_IOS_AGENT_PORT", environment: environment) ?? "8788") ?? 8788
16
+ let shouldLaunch = runtimeValue("ASTUR_AUT_LAUNCH", environment: environment) != "0"
17
+ let bridgeUrl = runtimeValue("ASTUR_IOS_AGENT_BRIDGE_URL", environment: environment).flatMap(URL.init(string:))
18
+
19
+ let agent = AsturAgent(bundleIdentifier: bundleIdentifier)
20
+ if shouldLaunch {
21
+ agent.launchIfNeeded()
22
+ }
23
+
24
+ if let bridgeUrl {
25
+ AsturAgentBridgeClient(agent: agent, bridgeUrl: bridgeUrl).run()
26
+ return
27
+ }
28
+
29
+ let server = AsturAgentServer(port: port, agent: agent)
30
+ try server.start()
31
+ self.server = server
32
+
33
+ RunLoop.current.run()
34
+ }
35
+
36
+ private func runtimeValue(_ key: String, environment: [String: String]) -> String? {
37
+ if let value = environment[key]?.nonEmpty, !value.hasPrefix("$(") {
38
+ return value
39
+ }
40
+
41
+ if let value = Bundle(for: AsturAgentUITests.self).object(forInfoDictionaryKey: key) as? String,
42
+ let nonEmpty = value.nonEmpty,
43
+ !nonEmpty.hasPrefix("$(") {
44
+ return nonEmpty
45
+ }
46
+
47
+ return nil
48
+ }
49
+ }
50
+
51
+ private extension String {
52
+ var nonEmpty: String? {
53
+ isEmpty ? nil : self
54
+ }
55
+ }