@ulpi/browse 1.4.1 → 2.3.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/LICENSE +191 -21
- package/README.md +190 -12
- package/browse-ios-runner/BrowseRunnerApp/BrowseRunnerApp.swift +382 -0
- package/browse-ios-runner/BrowseRunnerApp/RunnerStatusStore.swift +135 -0
- package/browse-ios-runner/BrowseRunnerUITests/ActionHandler.swift +277 -0
- package/browse-ios-runner/BrowseRunnerUITests/BrowseRunnerUITests.swift +60 -0
- package/browse-ios-runner/BrowseRunnerUITests/Models.swift +78 -0
- package/browse-ios-runner/BrowseRunnerUITests/RunnerServer.swift +246 -0
- package/browse-ios-runner/BrowseRunnerUITests/ScreenshotHandler.swift +35 -0
- package/browse-ios-runner/BrowseRunnerUITests/StateHandler.swift +82 -0
- package/browse-ios-runner/BrowseRunnerUITests/TreeBuilder.swift +323 -0
- package/browse-ios-runner/README.md +194 -0
- package/browse-ios-runner/build.sh +81 -0
- package/browse-ios-runner/project.yml +47 -0
- package/dist/browse.cjs +25691 -13301
- package/package.json +16 -5
- package/skill/SKILL.md +33 -0
- package/skill/references/commands.md +18 -0
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
import SwiftUI
|
|
2
|
+
|
|
3
|
+
@main
|
|
4
|
+
struct BrowseRunnerApp: App {
|
|
5
|
+
var body: some Scene {
|
|
6
|
+
WindowGroup {
|
|
7
|
+
ContentView()
|
|
8
|
+
.preferredColorScheme(.dark)
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// MARK: - Theme
|
|
14
|
+
|
|
15
|
+
private enum Theme {
|
|
16
|
+
static let background = Color.black
|
|
17
|
+
static let cardFill = Color(white: 0.067)
|
|
18
|
+
static let cardBorder = Color(white: 0.133)
|
|
19
|
+
static let divider = Color(white: 0.118)
|
|
20
|
+
static let primaryText = Color.white
|
|
21
|
+
static let secondaryText = Color.white.opacity(0.4)
|
|
22
|
+
static let tertiaryText = Color.white.opacity(0.25)
|
|
23
|
+
static let connectedGreen = Color(red: 0.133, green: 0.773, blue: 0.369)
|
|
24
|
+
static let disconnectedRed = Color(red: 0.937, green: 0.267, blue: 0.267)
|
|
25
|
+
static let staleOpacity: Double = 0.45
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// MARK: - Content View
|
|
29
|
+
|
|
30
|
+
struct ContentView: View {
|
|
31
|
+
@StateObject private var store = RunnerStatusStore()
|
|
32
|
+
@Environment(\.scenePhase) private var scenePhase
|
|
33
|
+
|
|
34
|
+
var body: some View {
|
|
35
|
+
ZStack {
|
|
36
|
+
Theme.background.ignoresSafeArea()
|
|
37
|
+
|
|
38
|
+
VStack(spacing: 0) {
|
|
39
|
+
HeaderView(store: store)
|
|
40
|
+
|
|
41
|
+
Theme.divider.frame(height: 1)
|
|
42
|
+
.padding(.horizontal, 24)
|
|
43
|
+
.padding(.top, 12)
|
|
44
|
+
|
|
45
|
+
TargetAppCard(store: store)
|
|
46
|
+
.padding(.top, 16)
|
|
47
|
+
|
|
48
|
+
StateGrid(store: store)
|
|
49
|
+
.padding(.top, 16)
|
|
50
|
+
|
|
51
|
+
Spacer(minLength: 0)
|
|
52
|
+
|
|
53
|
+
FooterView()
|
|
54
|
+
.padding(.top, 24)
|
|
55
|
+
.padding(.bottom, 16)
|
|
56
|
+
}
|
|
57
|
+
.padding(.horizontal, 24)
|
|
58
|
+
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
|
59
|
+
}
|
|
60
|
+
.onChange(of: scenePhase) { newPhase in
|
|
61
|
+
switch newPhase {
|
|
62
|
+
case .active:
|
|
63
|
+
store.start()
|
|
64
|
+
case .background, .inactive:
|
|
65
|
+
store.stop()
|
|
66
|
+
@unknown default:
|
|
67
|
+
break
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// MARK: - Header
|
|
74
|
+
|
|
75
|
+
private struct HeaderView: View {
|
|
76
|
+
@ObservedObject var store: RunnerStatusStore
|
|
77
|
+
|
|
78
|
+
var body: some View {
|
|
79
|
+
VStack(alignment: .leading, spacing: 12) {
|
|
80
|
+
HStack(alignment: .center, spacing: 0) {
|
|
81
|
+
HStack(spacing: 14) {
|
|
82
|
+
AppIconView(isConnected: store.isConnected)
|
|
83
|
+
VStack(alignment: .leading, spacing: 3) {
|
|
84
|
+
Text("BrowseRunner")
|
|
85
|
+
.font(.system(size: 20, weight: .bold))
|
|
86
|
+
.foregroundStyle(Theme.primaryText)
|
|
87
|
+
.fixedSize()
|
|
88
|
+
Text("v\(store.version)")
|
|
89
|
+
.font(.system(size: 12, design: .monospaced))
|
|
90
|
+
.foregroundStyle(Theme.secondaryText)
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
.layoutPriority(1)
|
|
94
|
+
|
|
95
|
+
Spacer(minLength: 8)
|
|
96
|
+
|
|
97
|
+
VStack(alignment: .trailing, spacing: 4) {
|
|
98
|
+
ConnectionBadge(isConnected: store.isConnected)
|
|
99
|
+
PortBadge(port: store.port)
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
Text("iOS automation runner")
|
|
104
|
+
.font(.system(size: 12, weight: .medium))
|
|
105
|
+
.foregroundStyle(Color.white.opacity(0.3))
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// MARK: - App Icon
|
|
111
|
+
|
|
112
|
+
private struct AppIconView: View {
|
|
113
|
+
let isConnected: Bool
|
|
114
|
+
private var accent: Color { isConnected ? Theme.connectedGreen : Theme.disconnectedRed }
|
|
115
|
+
|
|
116
|
+
var body: some View {
|
|
117
|
+
Text(">_")
|
|
118
|
+
.font(.system(size: 15, weight: .bold, design: .monospaced))
|
|
119
|
+
.foregroundStyle(accent)
|
|
120
|
+
.frame(width: 42, height: 42)
|
|
121
|
+
.background(
|
|
122
|
+
RoundedRectangle(cornerRadius: 11)
|
|
123
|
+
.fill(Theme.cardFill)
|
|
124
|
+
.overlay(
|
|
125
|
+
RoundedRectangle(cornerRadius: 11)
|
|
126
|
+
.strokeBorder(accent.opacity(0.3), lineWidth: 1)
|
|
127
|
+
)
|
|
128
|
+
)
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// MARK: - Connection Badge
|
|
133
|
+
|
|
134
|
+
private struct ConnectionBadge: View {
|
|
135
|
+
let isConnected: Bool
|
|
136
|
+
@State private var pulse = false
|
|
137
|
+
private var color: Color { isConnected ? Theme.connectedGreen : Theme.disconnectedRed }
|
|
138
|
+
|
|
139
|
+
var body: some View {
|
|
140
|
+
HStack(spacing: 6) {
|
|
141
|
+
Circle()
|
|
142
|
+
.fill(color)
|
|
143
|
+
.frame(width: 6, height: 6)
|
|
144
|
+
.shadow(color: isConnected ? color.opacity(pulse ? 0.5 : 0.1) : .clear,
|
|
145
|
+
radius: isConnected ? (pulse ? 6 : 2) : 0)
|
|
146
|
+
.animation(isConnected ? .easeInOut(duration: 2).repeatForever(autoreverses: true) : .default,
|
|
147
|
+
value: pulse)
|
|
148
|
+
.onAppear { pulse = true }
|
|
149
|
+
|
|
150
|
+
Text(isConnected ? "Connected" : "Disconnected")
|
|
151
|
+
.font(.system(size: 11, weight: .semibold, design: .monospaced))
|
|
152
|
+
.foregroundStyle(color)
|
|
153
|
+
.fixedSize()
|
|
154
|
+
}
|
|
155
|
+
.padding(.horizontal, 10)
|
|
156
|
+
.padding(.vertical, 5)
|
|
157
|
+
.background(
|
|
158
|
+
RoundedRectangle(cornerRadius: 8)
|
|
159
|
+
.fill(color.opacity(0.1))
|
|
160
|
+
.overlay(RoundedRectangle(cornerRadius: 8).strokeBorder(color.opacity(0.25), lineWidth: 1))
|
|
161
|
+
)
|
|
162
|
+
.fixedSize()
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// MARK: - Port Badge
|
|
167
|
+
|
|
168
|
+
private struct PortBadge: View {
|
|
169
|
+
let port: Int
|
|
170
|
+
|
|
171
|
+
var body: some View {
|
|
172
|
+
Text(":\(String(port))")
|
|
173
|
+
.font(.system(size: 11, weight: .medium, design: .monospaced))
|
|
174
|
+
.foregroundStyle(Color.white.opacity(0.5))
|
|
175
|
+
.padding(.horizontal, 9)
|
|
176
|
+
.padding(.vertical, 4)
|
|
177
|
+
.background(
|
|
178
|
+
RoundedRectangle(cornerRadius: 6)
|
|
179
|
+
.fill(Theme.cardFill)
|
|
180
|
+
.overlay(RoundedRectangle(cornerRadius: 6).strokeBorder(Theme.cardBorder, lineWidth: 1))
|
|
181
|
+
)
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// MARK: - Target App Card
|
|
186
|
+
|
|
187
|
+
private struct TargetAppCard: View {
|
|
188
|
+
@ObservedObject var store: RunnerStatusStore
|
|
189
|
+
private var stale: Bool { !store.isConnected && store.hasEverConnected }
|
|
190
|
+
private var empty: Bool { !store.isConnected && !store.hasEverConnected }
|
|
191
|
+
|
|
192
|
+
var body: some View {
|
|
193
|
+
VStack(alignment: .leading, spacing: 10) {
|
|
194
|
+
SectionLabel(title: "TARGET APP")
|
|
195
|
+
|
|
196
|
+
VStack(alignment: .leading, spacing: 14) {
|
|
197
|
+
Text(store.bundleId ?? "No target")
|
|
198
|
+
.font(.system(size: 15, weight: .medium, design: .monospaced))
|
|
199
|
+
.foregroundStyle(Theme.primaryText)
|
|
200
|
+
|
|
201
|
+
VStack(spacing: 8) {
|
|
202
|
+
DetailRow(label: "Current Screen", value: store.screenTitle ?? (empty ? "Unavailable" : "--"))
|
|
203
|
+
DetailRow(label: "Orientation", value: displayOrientation)
|
|
204
|
+
DetailRow(label: "Last Sync", value: formattedSync, mono: true)
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
.padding(16)
|
|
208
|
+
.background(
|
|
209
|
+
RoundedRectangle(cornerRadius: 14)
|
|
210
|
+
.fill(Theme.cardFill)
|
|
211
|
+
.overlay(RoundedRectangle(cornerRadius: 14).strokeBorder(Theme.cardBorder, lineWidth: 1))
|
|
212
|
+
)
|
|
213
|
+
.opacity(stale ? Theme.staleOpacity : 1)
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
private var displayOrientation: String {
|
|
218
|
+
guard let o = store.orientation else { return "--" }
|
|
219
|
+
return o.prefix(1).uppercased() + o.dropFirst()
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
private var formattedSync: String {
|
|
223
|
+
guard let date = store.lastSync else { return "--" }
|
|
224
|
+
let fmt = DateFormatter()
|
|
225
|
+
fmt.dateFormat = "H:mm:ss"
|
|
226
|
+
return fmt.string(from: date)
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// MARK: - Detail Row
|
|
231
|
+
|
|
232
|
+
private struct DetailRow: View {
|
|
233
|
+
let label: String
|
|
234
|
+
let value: String
|
|
235
|
+
var mono: Bool = false
|
|
236
|
+
|
|
237
|
+
var body: some View {
|
|
238
|
+
HStack {
|
|
239
|
+
Text(label)
|
|
240
|
+
.font(.system(size: 12, weight: .medium))
|
|
241
|
+
.foregroundStyle(Theme.secondaryText)
|
|
242
|
+
Spacer()
|
|
243
|
+
Text(value)
|
|
244
|
+
.font(.system(size: 12, weight: .medium, design: mono ? .monospaced : .default))
|
|
245
|
+
.foregroundStyle(Theme.primaryText)
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// MARK: - State Grid
|
|
251
|
+
|
|
252
|
+
private struct StateGrid: View {
|
|
253
|
+
@ObservedObject var store: RunnerStatusStore
|
|
254
|
+
private var stale: Bool { !store.isConnected && store.hasEverConnected }
|
|
255
|
+
private var empty: Bool { !store.isConnected && !store.hasEverConnected }
|
|
256
|
+
|
|
257
|
+
private let columns = [
|
|
258
|
+
GridItem(.flexible(), spacing: 8),
|
|
259
|
+
GridItem(.flexible(), spacing: 8),
|
|
260
|
+
]
|
|
261
|
+
|
|
262
|
+
var body: some View {
|
|
263
|
+
VStack(alignment: .leading, spacing: 10) {
|
|
264
|
+
SectionLabel(title: "STATE")
|
|
265
|
+
|
|
266
|
+
LazyVGrid(columns: columns, spacing: 8) {
|
|
267
|
+
MetricCard(label: "ELEMENTS") {
|
|
268
|
+
Text(store.elementCount.map(String.init) ?? "--")
|
|
269
|
+
.font(.system(size: 26, weight: .bold, design: .monospaced))
|
|
270
|
+
.foregroundStyle(Theme.primaryText)
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
MetricCard(label: "UPTIME") {
|
|
274
|
+
TimelineView(.periodic(from: .now, by: 1)) { ctx in
|
|
275
|
+
Text(formatUptime(ctx.date.timeIntervalSince(store.launchTime)))
|
|
276
|
+
.font(.system(size: 26, weight: .bold, design: .monospaced))
|
|
277
|
+
.foregroundStyle(Theme.primaryText)
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
MetricCard(label: "KEYBOARD") {
|
|
282
|
+
StatusDot(label: displayKeyboard, active: store.keyboardVisible == true)
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
MetricCard(label: "ALERT") {
|
|
286
|
+
StatusDot(label: displayAlert, active: store.alertPresent == true)
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
.opacity(stale ? Theme.staleOpacity : 1)
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
private var displayKeyboard: String {
|
|
294
|
+
guard let v = store.keyboardVisible else { return "--" }
|
|
295
|
+
return v ? "Visible" : "Hidden"
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
private var displayAlert: String {
|
|
299
|
+
guard let v = store.alertPresent else { return "--" }
|
|
300
|
+
return v ? "Present" : "None"
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
private func formatUptime(_ interval: TimeInterval) -> String {
|
|
304
|
+
let total = Int(max(0, interval))
|
|
305
|
+
let h = total / 3600, m = (total % 3600) / 60, s = total % 60
|
|
306
|
+
return h > 0 ? "\(h)h \(m)m" : "\(m)m \(s)s"
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// MARK: - Metric Card
|
|
311
|
+
|
|
312
|
+
private struct MetricCard<Content: View>: View {
|
|
313
|
+
let label: String
|
|
314
|
+
@ViewBuilder let content: Content
|
|
315
|
+
|
|
316
|
+
var body: some View {
|
|
317
|
+
VStack(alignment: .leading, spacing: 8) {
|
|
318
|
+
Text(label)
|
|
319
|
+
.font(.system(size: 10, weight: .semibold))
|
|
320
|
+
.tracking(0.5)
|
|
321
|
+
.foregroundStyle(Theme.secondaryText)
|
|
322
|
+
.textCase(.uppercase)
|
|
323
|
+
content
|
|
324
|
+
}
|
|
325
|
+
.frame(maxWidth: .infinity, alignment: .leading)
|
|
326
|
+
.padding(14)
|
|
327
|
+
.background(
|
|
328
|
+
RoundedRectangle(cornerRadius: 12)
|
|
329
|
+
.fill(Theme.cardFill)
|
|
330
|
+
.overlay(RoundedRectangle(cornerRadius: 12).strokeBorder(Theme.cardBorder, lineWidth: 1))
|
|
331
|
+
)
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// MARK: - Status Dot
|
|
336
|
+
|
|
337
|
+
private struct StatusDot: View {
|
|
338
|
+
let label: String
|
|
339
|
+
var active: Bool = false
|
|
340
|
+
|
|
341
|
+
var body: some View {
|
|
342
|
+
HStack(spacing: 8) {
|
|
343
|
+
Circle()
|
|
344
|
+
.fill(Color.white.opacity(active ? 0.6 : 0.25))
|
|
345
|
+
.frame(width: 6, height: 6)
|
|
346
|
+
Text(label)
|
|
347
|
+
.font(.system(size: 14, weight: .medium))
|
|
348
|
+
.foregroundStyle(Color.white.opacity(0.5))
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// MARK: - Section Label
|
|
354
|
+
|
|
355
|
+
private struct SectionLabel: View {
|
|
356
|
+
let title: String
|
|
357
|
+
|
|
358
|
+
var body: some View {
|
|
359
|
+
Text(title)
|
|
360
|
+
.font(.system(size: 10, weight: .semibold))
|
|
361
|
+
.tracking(1)
|
|
362
|
+
.foregroundStyle(Theme.tertiaryText)
|
|
363
|
+
.textCase(.uppercase)
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// MARK: - Footer
|
|
368
|
+
|
|
369
|
+
private struct FooterView: View {
|
|
370
|
+
var body: some View {
|
|
371
|
+
VStack(spacing: 4) {
|
|
372
|
+
Text("Commands are executed from browse CLI")
|
|
373
|
+
.font(.system(size: 11))
|
|
374
|
+
.foregroundStyle(Color.white.opacity(0.3))
|
|
375
|
+
Text("This app shows runner health and current target state")
|
|
376
|
+
.font(.system(size: 11))
|
|
377
|
+
.foregroundStyle(Color.white.opacity(0.2))
|
|
378
|
+
}
|
|
379
|
+
.frame(maxWidth: .infinity)
|
|
380
|
+
.multilineTextAlignment(.center)
|
|
381
|
+
}
|
|
382
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import SwiftUI
|
|
3
|
+
|
|
4
|
+
// MARK: - Decode Models
|
|
5
|
+
|
|
6
|
+
struct RunnerEnvelope<T: Decodable>: Decodable {
|
|
7
|
+
let success: Bool
|
|
8
|
+
let data: T?
|
|
9
|
+
let error: String?
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
struct RunnerHealthData: Decodable {
|
|
13
|
+
let status: String
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
struct RunnerStateData: Decodable {
|
|
17
|
+
let bundleId: String
|
|
18
|
+
let screenTitle: String
|
|
19
|
+
let elementCount: Int
|
|
20
|
+
let alertPresent: Bool
|
|
21
|
+
let keyboardVisible: Bool
|
|
22
|
+
let orientation: String
|
|
23
|
+
let statusBarTime: String
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// MARK: - RunnerStatusStore
|
|
27
|
+
|
|
28
|
+
@MainActor
|
|
29
|
+
final class RunnerStatusStore: ObservableObject {
|
|
30
|
+
|
|
31
|
+
// MARK: Connection
|
|
32
|
+
|
|
33
|
+
@Published var isConnected: Bool = false
|
|
34
|
+
@Published var hasEverConnected: Bool = false
|
|
35
|
+
@Published var isLoading: Bool = false
|
|
36
|
+
@Published var lastError: String?
|
|
37
|
+
|
|
38
|
+
// MARK: State fields
|
|
39
|
+
|
|
40
|
+
@Published var bundleId: String? = nil
|
|
41
|
+
@Published var screenTitle: String? = nil
|
|
42
|
+
@Published var elementCount: Int? = nil
|
|
43
|
+
@Published var orientation: String? = nil
|
|
44
|
+
@Published var keyboardVisible: Bool? = nil
|
|
45
|
+
@Published var alertPresent: Bool? = nil
|
|
46
|
+
@Published var lastSync: Date? = nil
|
|
47
|
+
|
|
48
|
+
// MARK: Server info
|
|
49
|
+
|
|
50
|
+
let port: Int = 9820
|
|
51
|
+
let version: String = {
|
|
52
|
+
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "dev"
|
|
53
|
+
}()
|
|
54
|
+
let launchTime: Date = Date()
|
|
55
|
+
|
|
56
|
+
// MARK: Private
|
|
57
|
+
|
|
58
|
+
private var pollingTask: Task<Void, Never>?
|
|
59
|
+
private let session: URLSession = {
|
|
60
|
+
let config = URLSessionConfiguration.ephemeral
|
|
61
|
+
config.timeoutIntervalForRequest = 4
|
|
62
|
+
config.timeoutIntervalForResource = 5
|
|
63
|
+
config.waitsForConnectivity = false
|
|
64
|
+
return URLSession(configuration: config)
|
|
65
|
+
}()
|
|
66
|
+
|
|
67
|
+
private let baseURL = "http://127.0.0.1:9820"
|
|
68
|
+
|
|
69
|
+
// MARK: Lifecycle
|
|
70
|
+
|
|
71
|
+
func start() {
|
|
72
|
+
guard pollingTask == nil else { return }
|
|
73
|
+
pollingTask = Task { [weak self] in
|
|
74
|
+
guard let self else { return }
|
|
75
|
+
await self.refresh()
|
|
76
|
+
while !Task.isCancelled {
|
|
77
|
+
try? await Task.sleep(for: .seconds(2))
|
|
78
|
+
guard !Task.isCancelled else { break }
|
|
79
|
+
await self.refresh()
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
func stop() {
|
|
85
|
+
pollingTask?.cancel()
|
|
86
|
+
pollingTask = nil
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
func refresh() async {
|
|
90
|
+
isLoading = true
|
|
91
|
+
defer { isLoading = false }
|
|
92
|
+
|
|
93
|
+
do {
|
|
94
|
+
let healthURL = URL(string: "\(baseURL)/health")!
|
|
95
|
+
let (healthData, _) = try await session.data(from: healthURL)
|
|
96
|
+
let envelope = try JSONDecoder().decode(RunnerEnvelope<RunnerHealthData>.self, from: healthData)
|
|
97
|
+
|
|
98
|
+
guard envelope.success, envelope.data?.status == "healthy" else {
|
|
99
|
+
isConnected = false
|
|
100
|
+
lastError = envelope.error ?? "Unhealthy"
|
|
101
|
+
return
|
|
102
|
+
}
|
|
103
|
+
} catch {
|
|
104
|
+
isConnected = false
|
|
105
|
+
lastError = error.localizedDescription
|
|
106
|
+
return
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
do {
|
|
110
|
+
let stateURL = URL(string: "\(baseURL)/state")!
|
|
111
|
+
let (stateData, _) = try await session.data(from: stateURL)
|
|
112
|
+
let envelope = try JSONDecoder().decode(RunnerEnvelope<RunnerStateData>.self, from: stateData)
|
|
113
|
+
|
|
114
|
+
guard envelope.success, let state = envelope.data else {
|
|
115
|
+
isConnected = false
|
|
116
|
+
lastError = envelope.error ?? "No state data"
|
|
117
|
+
return
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
isConnected = true
|
|
121
|
+
hasEverConnected = true
|
|
122
|
+
lastError = nil
|
|
123
|
+
bundleId = state.bundleId
|
|
124
|
+
screenTitle = state.screenTitle
|
|
125
|
+
elementCount = state.elementCount
|
|
126
|
+
orientation = state.orientation
|
|
127
|
+
keyboardVisible = state.keyboardVisible
|
|
128
|
+
alertPresent = state.alertPresent
|
|
129
|
+
lastSync = Date()
|
|
130
|
+
} catch {
|
|
131
|
+
isConnected = false
|
|
132
|
+
lastError = error.localizedDescription
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|