@buoy-gg/bug-links 0.0.0-stage → 7.0.81

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 (100) hide show
  1. package/BuoyBugLinks.podspec +18 -0
  2. package/LICENSE +58 -0
  3. package/README.md +25 -2
  4. package/android/build.gradle +31 -0
  5. package/android/consumer-rules.pro +8 -0
  6. package/android/src/main/AndroidManifest.xml +1 -0
  7. package/android/src/main/java/gg/buoy/buglinks/frames/BuoyFramesModule.kt +53 -0
  8. package/android/src/main/java/gg/buoy/buglinks/frames/BuoyFramesPackage.kt +11 -0
  9. package/android/src/main/java/gg/buoy/buglinks/frames/FrameEngine.kt +707 -0
  10. package/android/src/main/java/gg/buoy/buglinks/frames/FrameWalker.kt +181 -0
  11. package/ios/BuoyFrames.m +17 -0
  12. package/ios/BuoyFrames.swift +640 -0
  13. package/ios/BuoyFramesWalk.swift +213 -0
  14. package/lib/commonjs/components/BugLinksBody.js +1 -0
  15. package/lib/commonjs/components/TeamBugs.js +1 -0
  16. package/lib/commonjs/components/focus.js +1 -0
  17. package/lib/commonjs/components/overlays.js +1 -0
  18. package/lib/commonjs/components/topBanners.js +1 -0
  19. package/lib/commonjs/engine/client.js +1 -0
  20. package/lib/commonjs/engine/credential.js +1 -0
  21. package/lib/commonjs/engine/linksStore.js +1 -0
  22. package/lib/commonjs/engine/loader.js +1 -0
  23. package/lib/commonjs/engine/opener.js +1 -0
  24. package/lib/commonjs/engine/otherTabs.js +1 -0
  25. package/lib/commonjs/engine/phoneCheck.js +1 -0
  26. package/lib/commonjs/engine/phoneCrypto.js +1 -0
  27. package/lib/commonjs/engine/randomBytes.js +1 -0
  28. package/lib/commonjs/engine/reporter.js +1 -0
  29. package/lib/commonjs/engine/sandbox.js +1 -0
  30. package/lib/commonjs/engine/settings.js +1 -0
  31. package/lib/commonjs/engine/tabStorage.js +1 -0
  32. package/lib/commonjs/engine/teamKeys.js +1 -0
  33. package/lib/commonjs/engine/teamList.js +1 -0
  34. package/lib/commonjs/index.js +1 -0
  35. package/lib/commonjs/media/BuoyMask.js +1 -0
  36. package/lib/commonjs/media/frames.js +1 -0
  37. package/lib/commonjs/media/nativeFrames.js +1 -0
  38. package/lib/commonjs/media/nativeFrames.web.js +1 -0
  39. package/lib/commonjs/media/webFrames.js +14 -0
  40. package/lib/commonjs/package.json +1 -0
  41. package/lib/commonjs/preset.js +1 -0
  42. package/lib/commonjs/sync/bugLinksSyncAdapter.js +1 -0
  43. package/lib/module/components/BugLinksBody.js +1 -0
  44. package/lib/module/components/TeamBugs.js +1 -0
  45. package/lib/module/components/focus.js +1 -0
  46. package/lib/module/components/overlays.js +1 -0
  47. package/lib/module/components/topBanners.js +1 -0
  48. package/lib/module/engine/client.js +1 -0
  49. package/lib/module/engine/credential.js +1 -0
  50. package/lib/module/engine/linksStore.js +1 -0
  51. package/lib/module/engine/loader.js +1 -0
  52. package/lib/module/engine/opener.js +1 -0
  53. package/lib/module/engine/otherTabs.js +1 -0
  54. package/lib/module/engine/phoneCheck.js +1 -0
  55. package/lib/module/engine/phoneCrypto.js +1 -0
  56. package/lib/module/engine/randomBytes.js +1 -0
  57. package/lib/module/engine/reporter.js +1 -0
  58. package/lib/module/engine/sandbox.js +1 -0
  59. package/lib/module/engine/settings.js +1 -0
  60. package/lib/module/engine/tabStorage.js +1 -0
  61. package/lib/module/engine/teamKeys.js +1 -0
  62. package/lib/module/engine/teamList.js +1 -0
  63. package/lib/module/index.js +1 -0
  64. package/lib/module/media/BuoyMask.js +1 -0
  65. package/lib/module/media/frames.js +1 -0
  66. package/lib/module/media/nativeFrames.js +1 -0
  67. package/lib/module/media/nativeFrames.web.js +1 -0
  68. package/lib/module/media/webFrames.js +14 -0
  69. package/lib/module/preset.js +1 -0
  70. package/lib/module/sync/bugLinksSyncAdapter.js +1 -0
  71. package/lib/typescript/components/BugLinksBody.d.ts +2 -0
  72. package/lib/typescript/components/TeamBugs.d.ts +8 -0
  73. package/lib/typescript/components/focus.d.ts +6 -0
  74. package/lib/typescript/components/overlays.d.ts +10 -0
  75. package/lib/typescript/components/topBanners.d.ts +35 -0
  76. package/lib/typescript/engine/client.d.ts +78 -0
  77. package/lib/typescript/engine/credential.d.ts +11 -0
  78. package/lib/typescript/engine/linksStore.d.ts +132 -0
  79. package/lib/typescript/engine/loader.d.ts +172 -0
  80. package/lib/typescript/engine/opener.d.ts +21 -0
  81. package/lib/typescript/engine/otherTabs.d.ts +52 -0
  82. package/lib/typescript/engine/phoneCheck.d.ts +8 -0
  83. package/lib/typescript/engine/phoneCrypto.d.ts +16 -0
  84. package/lib/typescript/engine/randomBytes.d.ts +7 -0
  85. package/lib/typescript/engine/reporter.d.ts +53 -0
  86. package/lib/typescript/engine/sandbox.d.ts +83 -0
  87. package/lib/typescript/engine/settings.d.ts +42 -0
  88. package/lib/typescript/engine/tabStorage.d.ts +6 -0
  89. package/lib/typescript/engine/teamKeys.d.ts +37 -0
  90. package/lib/typescript/engine/teamList.d.ts +63 -0
  91. package/lib/typescript/index.d.ts +23 -0
  92. package/lib/typescript/media/BuoyMask.d.ts +19 -0
  93. package/lib/typescript/media/frames.d.ts +34 -0
  94. package/lib/typescript/media/nativeFrames.d.ts +2 -0
  95. package/lib/typescript/media/nativeFrames.web.d.ts +2 -0
  96. package/lib/typescript/media/webFrames.d.ts +178 -0
  97. package/lib/typescript/preset.d.ts +50 -0
  98. package/lib/typescript/sync/bugLinksSyncAdapter.d.ts +41 -0
  99. package/lib/web/index.mjs +20 -0
  100. package/package.json +119 -3
@@ -0,0 +1,640 @@
1
+ // Bug Links pictures and video on iOS (research/bug-links-2026-10/media/SPEC.md).
2
+ //
3
+ // The way to take a frame won a race on a real iPhone (SPEC section 6, v23):
4
+ // UIGraphicsImageRenderer at 1x in its default format, drawHierarchy without
5
+ // waiting for a screen update, started right after a display tick. Boxes,
6
+ // color and JPEG happen on a background queue.
7
+ //
8
+ // Safety, from the 2026-10-26 compatibility review:
9
+ // - a frame is read three times (a tick before, right before, right after) and
10
+ // every place a part was gets a box (H13);
11
+ // - any doubt drops the frame and keeps a reason (H2, C5);
12
+ // - every job carries a run token, so a late job can't touch a newer run;
13
+ // - nothing here blocks the main thread waiting on another thread.
14
+ import UIKit
15
+ import UIKit.UIGestureRecognizerSubclass
16
+ import ImageIO
17
+ import UniformTypeIdentifiers
18
+
19
+ @available(iOSApplicationExtension, unavailable)
20
+ @objc(BuoyFrames)
21
+ final class BuoyFrames: NSObject {
22
+ @objc static func requiresMainQueueSetup() -> Bool { false }
23
+
24
+ private let engine = BuoyFrameEngine.shared
25
+
26
+ @objc(configure:)
27
+ func configure(_ options: NSDictionary) {
28
+ DispatchQueue.main.async { self.engine.configure(options) }
29
+ }
30
+
31
+ @objc(status:reject:)
32
+ func status(_ resolve: @escaping (Any?) -> Void, reject: @escaping (String?, String?, Error?) -> Void) {
33
+ DispatchQueue.main.async { resolve(self.engine.status()) }
34
+ }
35
+
36
+ @objc(takeMedia:reject:)
37
+ func takeMedia(_ resolve: @escaping (Any?) -> Void, reject: @escaping (String?, String?, Error?) -> Void) {
38
+ DispatchQueue.main.async { self.engine.takeMedia { resolve($0) } }
39
+ }
40
+
41
+ @objc(captureLast:reject:)
42
+ func captureLast(_ resolve: @escaping (Any?) -> Void, reject: @escaping (String?, String?, Error?) -> Void) {
43
+ DispatchQueue.main.async { self.engine.captureLast { resolve($0) } }
44
+ }
45
+
46
+ @objc func resume() {
47
+ DispatchQueue.main.async { self.engine.resume() }
48
+ }
49
+
50
+ @objc(startVideo:)
51
+ func startVideo(_ always: Bool) {
52
+ DispatchQueue.main.async { self.engine.startVideo(always: always) }
53
+ }
54
+
55
+ @objc func stopVideo() {
56
+ DispatchQueue.main.async { self.engine.stopVideo() }
57
+ }
58
+
59
+ @objc func mark() {
60
+ DispatchQueue.main.async { self.engine.mark() }
61
+ }
62
+
63
+ @objc func clear() {
64
+ DispatchQueue.main.async { self.engine.clear() }
65
+ }
66
+
67
+ /// Test hook for the mask zoo: take one frame now and give back its boxes and JPEG.
68
+ @objc(probe:reject:)
69
+ func probe(_ resolve: @escaping (Any?) -> Void, reject: @escaping (String?, String?, Error?) -> Void) {
70
+ DispatchQueue.main.async { self.engine.probe { resolve($0) } }
71
+ }
72
+
73
+ @objc func invalidate() {
74
+ DispatchQueue.main.async { self.engine.stopAll() }
75
+ }
76
+ }
77
+
78
+ struct BuoyFrame {
79
+ let t: Double // ms since 1970 (the case's touch clock)
80
+ let kind: String // "last" | "tap" | "video"
81
+ let w: Int
82
+ let h: Int
83
+ let jpeg: Data
84
+ let boxes: Int
85
+ let moved: Bool
86
+ }
87
+
88
+ @available(iOSApplicationExtension, unavailable)
89
+ final class BuoyFrameEngine: NSObject, UIGestureRecognizerDelegate {
90
+ static let shared = BuoyFrameEngine()
91
+
92
+ // Settings (S1, M1)
93
+ private var pictures = true
94
+ private var customer = false
95
+ private var masks = BuoyMaskSettings()
96
+ private var videoFps = 2.0
97
+ private var enabled = false
98
+
99
+ // Rings (F2, F3, F4). Main thread only.
100
+ private var taps: [BuoyFrame] = []
101
+ private var video: [BuoyFrame] = []
102
+ private var last: BuoyFrame?
103
+ private var marks: [Double] = []
104
+ private var dropped: [String: Int] = [:]
105
+ private var frozen = false
106
+ // F8: the time of the last frame taken when Buoy's button was touched, and
107
+ // whether the touch going on now started on Buoy (no tap frame for it).
108
+ private var buttonLastT: Double?
109
+ private var downOnBuoy = false
110
+
111
+ // One job at a time. `run` goes up on every stop, so late jobs are ignored.
112
+ private var run = 0
113
+ private var busy = false
114
+ private var slowInRow = 0
115
+ private var stoppedReason: String?
116
+
117
+ // Video (F3, F3a)
118
+ private var videoOn = false
119
+ private var videoAlways = false
120
+ private var videoUntil: CFTimeInterval = 0
121
+ private var nextVideoDue: CFTimeInterval = 0
122
+ private var videoLink: CADisplayLink?
123
+
124
+ // Taps (F2)
125
+ private var watchers: [BuoyTapWatcher] = []
126
+ private var visibleObserver: NSObjectProtocol?
127
+ private var tapTimer: Timer?
128
+
129
+ private let queue = DispatchQueue(label: "gg.buoy.frames", qos: .utility)
130
+ private static let maxLongSide: CGFloat = 1280
131
+ private static let maxVideoBytes = 6 * 1024 * 1024
132
+
133
+ // MARK: settings
134
+
135
+ func configure(_ o: NSDictionary) {
136
+ let wasCustomer = customer
137
+ let old = masks
138
+ pictures = (o["pictures"] as? Bool) ?? true
139
+ customer = (o["mode"] as? String) == "customer"
140
+ masks.text = (o["maskAllText"] as? Bool) ?? true
141
+ masks.images = (o["maskAllImages"] as? Bool) ?? true
142
+ masks.inputs = (o["maskAllInputs"] as? Bool) ?? true
143
+ // A hiding setting that turned on (or customer mode): frames already taken hid less, so they go.
144
+ // Here, not only in JS: this engine outlives a JS reload, and new JS brings new settings.
145
+ if (!old.text && masks.text) || (!old.images && masks.images) || (!old.inputs && masks.inputs) || (customer && !wasCustomer) { clear() }
146
+ if let fps = o["videoFps"] as? Double, fps > 0 { videoFps = min(fps, 10) }
147
+ enabled = true
148
+ attachWatcher()
149
+ let v = o["video"] as? String
150
+ if v == "always" && !customer { startVideo(always: true) } else if videoAlways { stopVideo() }
151
+ }
152
+
153
+ func status() -> [String: Any] {
154
+ ["available": true, "enabled": enabled, "mode": customer ? "customer" : "internal", "stopped": stoppedReason ?? NSNull()]
155
+ }
156
+
157
+ // MARK: taps
158
+
159
+ private func attachWatcher() {
160
+ // Every app window gets a watcher, so taps in an app's own extra window
161
+ // (an .alert-level one) get pictures too. Windows that show later are
162
+ // picked up when they become visible.
163
+ for w in Self.appWindows().all where !watchers.contains(where: { $0.view === w }) {
164
+ let g = BuoyTapWatcher(target: nil, action: nil)
165
+ g.cancelsTouchesInView = false
166
+ g.delaysTouchesBegan = false
167
+ g.delaysTouchesEnded = false
168
+ g.delegate = self
169
+ g.onBegan = { [weak self] view in self?.touchBegan(on: view) }
170
+ g.onEnded = { [weak self] in self?.tapped() }
171
+ w.addGestureRecognizer(g)
172
+ watchers.append(g)
173
+ }
174
+ watchers.removeAll { $0.view == nil }
175
+ if visibleObserver == nil {
176
+ visibleObserver = NotificationCenter.default.addObserver(forName: UIWindow.didBecomeVisibleNotification, object: nil, queue: .main) { [weak self] _ in
177
+ guard let self, self.enabled else { return }
178
+ self.attachWatcher()
179
+ }
180
+ }
181
+ }
182
+
183
+
184
+ func gestureRecognizer(_ g: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith other: UIGestureRecognizer) -> Bool { true }
185
+
186
+ /// A touch on Buoy's floating button: take the last frame now, before the dial draws (F8).
187
+ private func touchBegan(on view: UIView?) {
188
+ downOnBuoy = false
189
+ guard var v = view else { return }
190
+ while true {
191
+ if BuoyWalker.nativeID(v) == BuoyWalker.buoyRootID {
192
+ downOnBuoy = true
193
+ if enabled, !frozen, !busy {
194
+ take(kind: "last", attempts: 1) { [weak self] ok in
195
+ if ok { self?.buttonLastT = self?.last?.t }
196
+ }
197
+ }
198
+ return
199
+ }
200
+ guard let s = v.superview else { return }
201
+ v = s
202
+ }
203
+ }
204
+
205
+ private func tapped() {
206
+ // A tap on Buoy's own button would show Buoy's toast or dial, not the app.
207
+ if downOnBuoy { downOnBuoy = false; return }
208
+ guard enabled, pictures, !customer, !frozen, stoppedReason == nil else { return }
209
+ tapTimer?.invalidate()
210
+ // F2: about 300 ms after the tap, so the screen has time to change.
211
+ tapTimer = Timer.scheduledTimer(withTimeInterval: 0.3, repeats: false) { [weak self] _ in
212
+ self?.take(kind: "tap", attempts: 1)
213
+ }
214
+ }
215
+
216
+ // MARK: video
217
+
218
+ func startVideo(always: Bool) {
219
+ guard enabled, !customer else { return }
220
+ videoOn = true
221
+ videoAlways = always
222
+ if !always { video.removeAll(); marks.removeAll() }
223
+ videoUntil = always ? .infinity : CACurrentMediaTime() + 60 // F3: 60 s at most
224
+ nextVideoDue = CACurrentMediaTime()
225
+ if videoLink == nil {
226
+ let l = CADisplayLink(target: BuoyLinkProxy(self), selector: #selector(BuoyLinkProxy.tick))
227
+ l.add(to: .main, forMode: .common)
228
+ videoLink = l
229
+ }
230
+ }
231
+
232
+ func stopVideo() {
233
+ videoOn = false
234
+ videoAlways = false
235
+ videoLink?.invalidate()
236
+ videoLink = nil
237
+ }
238
+
239
+ func mark() { if videoOn { marks.append(Date().timeIntervalSince1970 * 1000) } }
240
+
241
+ fileprivate func videoTick() {
242
+ let now = CACurrentMediaTime()
243
+ if now > videoUntil { stopVideo(); return }
244
+ guard videoOn, !frozen, stoppedReason == nil, now >= nextVideoDue else { return }
245
+ // Set the next slot from now, so a long pause never makes a backlog.
246
+ nextVideoDue = now + 1 / videoFps
247
+ if !busy { take(kind: "video", attempts: 1) }
248
+ }
249
+
250
+ // MARK: the case
251
+
252
+ /// F8: the last frame. Buoy is usually open by now, so this keeps the frame
253
+ /// taken when Buoy's button was touched, unless a fresh one can be taken.
254
+ func captureLast(_ done: @escaping ([String: Any]) -> Void) {
255
+ // Buoy's toast is up by now, so the frame from the button touch is the
256
+ // better last frame while it is new.
257
+ if let b = buttonLastT, last?.t == b, Date().timeIntervalSince1970 * 1000 - b < 10_000 {
258
+ done(["ok": true, "reason": NSNull()])
259
+ return
260
+ }
261
+ take(kind: "last", attempts: customer ? 6 : 1) { [weak self] ok in
262
+ done(["ok": ok, "reason": ok ? NSNull() : (self?.lastReason ?? "no frame")])
263
+ }
264
+ }
265
+
266
+ private var lastReason: String?
267
+
268
+ // Debug builds run this code with no optimizing, several times slower, so
269
+ // they get more time before a frame is dropped.
270
+ #if DEBUG
271
+ private static let walkBudgetMs: Double = 160
272
+ #else
273
+ private static let walkBudgetMs: Double = 40
274
+ #endif
275
+
276
+ func takeMedia(_ done: @escaping ([String: Any]) -> Void) {
277
+ frozen = true // F8: no late job adds a frame now
278
+ var frames: [[String: Any]] = []
279
+ func add(_ f: BuoyFrame) {
280
+ frames.append(["t": f.t, "kind": f.kind, "w": f.w, "h": f.h, "boxes": f.boxes, "moved": f.moved,
281
+ "jpeg": f.jpeg.base64EncodedString()])
282
+ }
283
+ // Keep only what can fit in a case before making base64 (C2).
284
+ var bytes = 0
285
+ let budget = 4 * 1024 * 1024
286
+ // C2's order: the last frame, taps (newest first), video near a "Bug here"
287
+ // mark (closest first), then the rest of the video spread over the clip.
288
+ var out: [BuoyFrame] = []
289
+ if let l = last { out.append(l) }
290
+ out += taps.reversed()
291
+ let gap = { (f: BuoyFrame) -> Double in self.marks.map { abs($0 - f.t) }.min() ?? .greatestFiniteMagnitude }
292
+ let near = video.indices.filter { gap(video[$0]) <= 3000 }.sorted { gap(video[$0]) < gap(video[$1]) }
293
+ out += near.map { video[$0] }
294
+ let nearSet = Set(near)
295
+ // The rest in spread order, so a size cut keeps frames from the whole clip.
296
+ let rest = video.indices.filter { !nearSet.contains($0) }
297
+ out += BuoyFrameEngine.spreadOrder(rest.count).map { video[rest[$0]] }
298
+ var kept: [BuoyFrame] = []
299
+ var cut = 0
300
+ for f in out {
301
+ if kept.count >= 150 || bytes + f.jpeg.count > budget { cut += 1; continue }
302
+ kept.append(f)
303
+ bytes += f.jpeg.count
304
+ }
305
+ kept.sort { $0.t < $1.t }
306
+ kept.forEach(add)
307
+ done([
308
+ "frames": frames,
309
+ "marks": marks,
310
+ "dropped": cut + dropped.values.reduce(0, +),
311
+ "reasons": dropped,
312
+ "stopped": stoppedReason ?? NSNull(),
313
+ "lastAgeMs": last.map { Date().timeIntervalSince1970 * 1000 - $0.t } ?? NSNull(),
314
+ "settings": ["maskAllText": masks.text, "maskAllImages": masks.images, "maskAllInputs": masks.inputs],
315
+ ])
316
+ }
317
+
318
+ /// 0..<n in an order where every start of the list spans the range (ends, middle, quarters…).
319
+ static func spreadOrder(_ n: Int) -> [Int] {
320
+ guard n > 0 else { return [] }
321
+ var used = [Bool](repeating: false, count: n)
322
+ var out: [Int] = []
323
+ func add(_ i: Int) { if !used[i] { used[i] = true; out.append(i) } }
324
+ add(0); add(n - 1)
325
+ var parts = 2
326
+ while out.count < n {
327
+ var k = 1
328
+ while k < parts { add(Int((Double(n - 1) * Double(k) / Double(parts)).rounded())); k += 2 }
329
+ if parts > n * 2 { for i in 0..<n { add(i) } }
330
+ parts *= 2
331
+ }
332
+ return out
333
+ }
334
+
335
+ func resume() { frozen = false }
336
+
337
+ func clear() {
338
+ // A frame still being made belongs to the old settings: a new run token drops it.
339
+ run += 1
340
+ busy = false
341
+ tapTimer?.invalidate(); tapTimer = nil
342
+ taps.removeAll(); video.removeAll(); last = nil; marks.removeAll(); dropped.removeAll()
343
+ frozen = false
344
+ }
345
+
346
+ func stopAll() {
347
+ run += 1
348
+ stopVideo()
349
+ tapTimer?.invalidate(); tapTimer = nil
350
+ for w in watchers { w.view?.removeGestureRecognizer(w) }
351
+ watchers.removeAll()
352
+ if let o = visibleObserver { NotificationCenter.default.removeObserver(o); visibleObserver = nil }
353
+ busy = false
354
+ enabled = false
355
+ }
356
+
357
+ // MARK: one frame
358
+
359
+ private func drop(_ reason: String) {
360
+ dropped[reason, default: 0] += 1
361
+ lastReason = reason
362
+ }
363
+
364
+ /// Takes one frame over two display ticks: read the boxes on the first, then
365
+ /// read, draw and read again on the second (H13).
366
+ private func take(kind: String, attempts: Int, done: ((Bool) -> Void)? = nil) {
367
+ guard enabled else { done?(false); return }
368
+ // Customer mode keeps the last frame only (M1); a tap queued before the switch adds nothing.
369
+ if kind == "tap" && (customer || !pictures) { done?(false); return }
370
+ attachWatcher()
371
+ guard !busy else { drop("busy"); done?(false); return }
372
+ guard UIApplication.shared.applicationState == .active else { drop("app in background"); done?(false); return } // F7
373
+ busy = true
374
+ let token = run
375
+ let before = Self.walkNow(masks: masks)
376
+ BuoyNextTick.after { [weak self] in
377
+ guard let self, token == self.run else { done?(false); return }
378
+ self.shoot(kind: kind, before: before, token: token) { ok, retry in
379
+ if !ok && retry && attempts > 1 {
380
+ // Customer mode: wait for the screen to hold still.
381
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
382
+ guard token == self.run else { done?(false); return }
383
+ self.take(kind: kind, attempts: attempts - 1, done: done)
384
+ }
385
+ } else {
386
+ done?(ok)
387
+ }
388
+ }
389
+ }
390
+ }
391
+
392
+ private static func walkNow(masks: BuoyMaskSettings) -> (BuoyWalk, CGRect, [UIWindow])? {
393
+ let found = appWindows()
394
+ guard !found.all.isEmpty else { return nil }
395
+ let frame = found.frame
396
+ return (BuoyWalker.walk(found.all, in: frame, settings: masks, budgetMs: Self.walkBudgetMs), frame, found.all)
397
+ }
398
+
399
+ private func shoot(kind: String, before: (BuoyWalk, CGRect, [UIWindow])?, token: Int, done: @escaping (Bool, Bool) -> Void) {
400
+ let t0 = CACurrentMediaTime()
401
+ func fail(_ reason: String, retry: Bool = false) { busy = false; drop(reason); done(false, retry) }
402
+ guard UIApplication.shared.applicationState == .active else { return fail("app in background") }
403
+ guard let before, let now = Self.walkNow(masks: masks) else { return fail("no app window") }
404
+ let (walkA, frameA, windowsA) = before
405
+ let (walkB, frame, windows) = now
406
+ // H2: any change to the windows between reads means we can't trust the boxes.
407
+ guard frame == frameA, windows.map({ ObjectIdentifier($0) }) == windowsA.map({ ObjectIdentifier($0) }) else { return fail("the window changed") }
408
+ if let f = walkA.failed ?? walkB.failed { return fail(f) }
409
+ if walkA.buoyOpen || walkB.buoyOpen { return fail("a Buoy screen was open") } // F5
410
+ guard frame.width >= 1, frame.height >= 1 else { return fail("no app window") }
411
+
412
+ // Draw at 1x in the renderer's default format: the fast path (SPEC section 6).
413
+ let scale = min(1, Self.maxLongSide / max(frame.width, frame.height))
414
+ let fmt = UIGraphicsImageRendererFormat()
415
+ fmt.scale = scale
416
+ fmt.opaque = true
417
+ var drew = true
418
+ let image = UIGraphicsImageRenderer(size: frame.size, format: fmt).image { _ in
419
+ for w in windows {
420
+ let r = w.frame.offsetBy(dx: -frame.minX, dy: -frame.minY)
421
+ if !w.drawHierarchy(in: r, afterScreenUpdates: false) { drew = false }
422
+ }
423
+ }
424
+ guard drew, let shot = image.cgImage else { return fail("iOS couldn't draw the screen") }
425
+
426
+ let walkC = BuoyWalker.walk(windows, in: frame, settings: masks, budgetMs: Self.walkBudgetMs)
427
+ if let f = walkC.failed { return fail(f) }
428
+ if walkC.buoyOpen { return fail("a Buoy screen was open") }
429
+
430
+ // H13: every place each part was, a tick ago, now and after the draw.
431
+ var boxes: [ObjectIdentifier: CGRect] = walkA.parts
432
+ var moved = false
433
+ for walk in [walkB.parts, walkC.parts] {
434
+ for (id, r) in walk {
435
+ if let old = boxes[id] {
436
+ if old != r { moved = true }
437
+ boxes[id] = old.union(r)
438
+ } else {
439
+ boxes[id] = r
440
+ moved = true
441
+ }
442
+ }
443
+ }
444
+ if walkA.parts.keys.contains(where: { walkC.parts[$0] == nil }) { moved = true }
445
+ if moved && customer { return fail("the screen was moving", retry: true) }
446
+
447
+ let mainMs = (CACurrentMediaTime() - t0) * 1000
448
+ noteSpeed(mainMs, kind: kind)
449
+
450
+ let rects = Self.merge(Array(boxes.values))
451
+ let size = frame.size
452
+ let wall = Date().timeIntervalSince1970 * 1000
453
+ queue.async { [weak self] in
454
+ let jpeg = autoreleasepool { Self.boxAndEncode(shot, size: size, scale: scale, rects: rects) }
455
+ DispatchQueue.main.async {
456
+ // A newer run (clear, stop): still answer, so a waiting report never hangs.
457
+ guard let self, token == self.run else { done(false, false); return }
458
+ self.busy = false
459
+ guard let jpeg else { self.drop("couldn't make the picture"); done(false, false); return }
460
+ let f = BuoyFrame(t: wall, kind: kind, w: Int(size.width.rounded()), h: Int(size.height.rounded()),
461
+ jpeg: jpeg, boxes: rects.count, moved: moved)
462
+ self.keep(f)
463
+ done(true, false)
464
+ }
465
+ }
466
+ }
467
+
468
+ private func keep(_ f: BuoyFrame) {
469
+ // F8: once the case is being made, only its own last frame can land.
470
+ if frozen && f.kind != "last" { return }
471
+ switch f.kind {
472
+ case "last": last = f
473
+ case "tap":
474
+ taps.append(f)
475
+ if taps.count > 10 { taps.removeFirst(taps.count - 10) } // F2
476
+ default:
477
+ video.append(f)
478
+ let keepFor: Double = videoAlways ? 30_000 : 60_000 // F3, F3a
479
+ let cutoff = f.t - keepFor
480
+ video.removeAll { $0.t < cutoff }
481
+ var total = video.reduce(0) { $0 + $1.jpeg.count }
482
+ while total > Self.maxVideoBytes, !video.isEmpty { total -= video.removeFirst().jpeg.count } // F4
483
+ }
484
+ }
485
+
486
+ /// F6 and M1: slow phones take fewer frames, then stop.
487
+ private func noteSpeed(_ ms: Double, kind: String) {
488
+ if kind == "last" { return }
489
+ if ms > 250 {
490
+ stoppedReason = "pictures stopped: this phone took \(Int(ms)) ms for one"
491
+ stopVideo()
492
+ } else if ms > 100 {
493
+ slowInRow += 1
494
+ if slowInRow >= 2 { videoFps = max(0.5, videoFps / 2); slowInRow = 0 }
495
+ } else {
496
+ slowInRow = 0
497
+ }
498
+ }
499
+
500
+ /// Drops boxes that sit fully inside another one.
501
+ static func merge(_ rects: [CGRect]) -> [CGRect] {
502
+ let sorted = rects.sorted { $0.width * $0.height > $1.width * $1.height }
503
+ var out: [CGRect] = []
504
+ for r in sorted where !out.contains(where: { $0.contains(r) }) { out.append(r) }
505
+ return out
506
+ }
507
+
508
+ static func boxAndEncode(_ shot: CGImage, size: CGSize, scale: CGFloat, rects: [CGRect]) -> Data? {
509
+ let w = Int((size.width * scale).rounded()), h = Int((size.height * scale).rounded())
510
+ guard w > 0, h > 0, w * h <= 4_000_000,
511
+ let space = CGColorSpace(name: CGColorSpace.sRGB),
512
+ let ctx = CGContext(data: nil, width: w, height: h, bitsPerComponent: 8, bytesPerRow: 0,
513
+ space: space, bitmapInfo: CGImageAlphaInfo.noneSkipLast.rawValue)
514
+ else { return nil }
515
+ ctx.interpolationQuality = .medium
516
+ ctx.draw(shot, in: CGRect(x: 0, y: 0, width: w, height: h))
517
+ ctx.setFillColor(red: 0x1F / 255, green: 0x1F / 255, blue: 0x1F / 255, alpha: 1) // H3
518
+ for r in rects {
519
+ let s = CGRect(x: r.minX * scale, y: r.minY * scale, width: r.width * scale, height: r.height * scale).integral
520
+ ctx.fill(CGRect(x: s.minX, y: CGFloat(h) - s.maxY, width: s.width, height: s.height))
521
+ }
522
+ guard let out = ctx.makeImage() else { return nil }
523
+ let data = NSMutableData()
524
+ guard let dest = CGImageDestinationCreateWithData(data, UTType.jpeg.identifier as CFString, 1, nil) else { return nil }
525
+ CGImageDestinationAddImage(dest, out, [kCGImageDestinationLossyCompressionQuality: 0.6] as CFDictionary)
526
+ guard CGImageDestinationFinalize(dest) else { return nil }
527
+ return data as Data
528
+ }
529
+
530
+ /// The app's windows in the scene in front: the main one, plus others the
531
+ /// app shows (alerts, toasts), low to high. Never the keyboard.
532
+ static func appWindows() -> (main: UIWindow?, all: [UIWindow], frame: CGRect) {
533
+ let scenes = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }
534
+ let scene = scenes.first { $0.activationState == .foregroundActive && $0.windows.contains { $0.isKeyWindow } }
535
+ ?? scenes.first { $0.activationState == .foregroundActive }
536
+ var windows = scene?.windows ?? []
537
+ if windows.isEmpty { windows = UIApplication.shared.windows }
538
+ // Every app window that shows, at any level (an app can put its own window
539
+ // at .alert), but not the keyboard or the system's own windows.
540
+ let shown = windows.filter { w in
541
+ let name = NSStringFromClass(type(of: w))
542
+ return !w.isHidden && w.alpha > 0
543
+ && !name.contains("Keyboard") && !name.contains("TextEffects") && !name.contains("StatusBar")
544
+ && !name.hasPrefix("_UI") && !name.contains("RemoteKeyboard")
545
+ }.sorted { $0.windowLevel.rawValue < $1.windowLevel.rawValue }
546
+ let main = shown.first { $0.isKeyWindow && $0.windowLevel == .normal } ?? shown.first { $0.windowLevel == .normal } ?? shown.first
547
+ let frame = scene?.coordinateSpace.bounds ?? main?.bounds ?? .zero
548
+ return (main, shown, frame)
549
+ }
550
+
551
+ // MARK: test hook
552
+
553
+ /// The testID for a boxed view: its own, or the nearest parent's when the box
554
+ /// covers most of that parent (RN puts testID on a wrapper, like an image's).
555
+ static func probeID(_ v: UIView, _ box: CGRect) -> String {
556
+ if let id = v.accessibilityIdentifier, !id.isEmpty { return id }
557
+ let mine = v.convert(v.bounds, to: nil)
558
+ var p = v.superview
559
+ while let parent = p {
560
+ if let id = parent.accessibilityIdentifier, !id.isEmpty {
561
+ let theirs = parent.convert(parent.bounds, to: nil)
562
+ return mine.width * mine.height >= theirs.width * theirs.height * 0.8 ? id : ""
563
+ }
564
+ p = parent.superview
565
+ }
566
+ return ""
567
+ }
568
+
569
+ func probe(_ done: @escaping ([String: Any]) -> Void) {
570
+ let found = Self.appWindows()
571
+ let walk = BuoyWalker.walk(found.all, in: found.frame, settings: masks, budgetMs: 200)
572
+ var parts: [[String: Any]] = []
573
+ for w in found.all {
574
+ var stack: [UIView] = [w]
575
+ while let v = stack.popLast() {
576
+ if let r = walk.parts[ObjectIdentifier(v)] {
577
+ var part: [String: Any] = ["testID": Self.probeID(v, r), "cls": NSStringFromClass(type(of: v)), "x": r.minX, "y": r.minY, "w": r.width, "h": r.height]
578
+ parts.append(part)
579
+ }
580
+ stack += v.subviews
581
+ }
582
+ }
583
+ let wasFrozen = frozen
584
+ frozen = false
585
+ take(kind: "last", attempts: 1) { [weak self] ok in
586
+ guard let self else { return }
587
+ self.frozen = wasFrozen
588
+ done(["ok": ok, "reason": ok ? NSNull() : (self.lastReason ?? NSNull()), "parts": parts,
589
+ "failed": walk.failed ?? NSNull(), "buoyOpen": walk.buoyOpen,
590
+ "jpeg": ok ? (self.last?.jpeg.base64EncodedString() ?? NSNull()) : NSNull(),
591
+ "w": found.frame.width, "h": found.frame.height])
592
+ }
593
+ }
594
+ }
595
+
596
+ /// Watches touches without taking them: it never recognizes, so the app's own
597
+ /// gestures and RN's touch handling see every touch as before.
598
+ final class BuoyTapWatcher: UIGestureRecognizer {
599
+ var onBegan: ((UIView?) -> Void)?
600
+ var onEnded: (() -> Void)?
601
+ override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent) {
602
+ onBegan?(touches.first?.view)
603
+ }
604
+ override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent) {
605
+ onEnded?()
606
+ state = .failed // never recognizes; failing resets it for the next touch
607
+ }
608
+ override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent) { state = .failed }
609
+ override func canPrevent(_ g: UIGestureRecognizer) -> Bool { false }
610
+ override func canBePrevented(by g: UIGestureRecognizer) -> Bool { false }
611
+ }
612
+
613
+ /// Runs a block once, on the next display tick.
614
+ final class BuoyNextTick: NSObject {
615
+ private var link: CADisplayLink?
616
+ private var block: (() -> Void)?
617
+ private static var live = Set<BuoyNextTick>()
618
+ static func after(_ block: @escaping () -> Void) {
619
+ let n = BuoyNextTick()
620
+ n.block = block
621
+ let l = CADisplayLink(target: n, selector: #selector(fire))
622
+ n.link = l
623
+ live.insert(n)
624
+ l.add(to: .main, forMode: .common)
625
+ }
626
+ @objc private func fire() {
627
+ link?.invalidate(); link = nil
628
+ Self.live.remove(self)
629
+ let b = block; block = nil
630
+ b?()
631
+ }
632
+ }
633
+
634
+ /// CADisplayLink keeps its target alive; this keeps the engine out of that loop.
635
+ @available(iOSApplicationExtension, unavailable)
636
+ final class BuoyLinkProxy: NSObject {
637
+ weak var engine: BuoyFrameEngine?
638
+ init(_ e: BuoyFrameEngine) { engine = e }
639
+ @objc func tick() { engine?.videoTick() }
640
+ }