@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.
- package/BuoyBugLinks.podspec +18 -0
- package/LICENSE +58 -0
- package/README.md +25 -2
- package/android/build.gradle +31 -0
- package/android/consumer-rules.pro +8 -0
- package/android/src/main/AndroidManifest.xml +1 -0
- package/android/src/main/java/gg/buoy/buglinks/frames/BuoyFramesModule.kt +53 -0
- package/android/src/main/java/gg/buoy/buglinks/frames/BuoyFramesPackage.kt +11 -0
- package/android/src/main/java/gg/buoy/buglinks/frames/FrameEngine.kt +707 -0
- package/android/src/main/java/gg/buoy/buglinks/frames/FrameWalker.kt +181 -0
- package/ios/BuoyFrames.m +17 -0
- package/ios/BuoyFrames.swift +640 -0
- package/ios/BuoyFramesWalk.swift +213 -0
- package/lib/commonjs/components/BugLinksBody.js +1 -0
- package/lib/commonjs/components/TeamBugs.js +1 -0
- package/lib/commonjs/components/focus.js +1 -0
- package/lib/commonjs/components/overlays.js +1 -0
- package/lib/commonjs/components/topBanners.js +1 -0
- package/lib/commonjs/engine/client.js +1 -0
- package/lib/commonjs/engine/credential.js +1 -0
- package/lib/commonjs/engine/linksStore.js +1 -0
- package/lib/commonjs/engine/loader.js +1 -0
- package/lib/commonjs/engine/opener.js +1 -0
- package/lib/commonjs/engine/otherTabs.js +1 -0
- package/lib/commonjs/engine/phoneCheck.js +1 -0
- package/lib/commonjs/engine/phoneCrypto.js +1 -0
- package/lib/commonjs/engine/randomBytes.js +1 -0
- package/lib/commonjs/engine/reporter.js +1 -0
- package/lib/commonjs/engine/sandbox.js +1 -0
- package/lib/commonjs/engine/settings.js +1 -0
- package/lib/commonjs/engine/tabStorage.js +1 -0
- package/lib/commonjs/engine/teamKeys.js +1 -0
- package/lib/commonjs/engine/teamList.js +1 -0
- package/lib/commonjs/index.js +1 -0
- package/lib/commonjs/media/BuoyMask.js +1 -0
- package/lib/commonjs/media/frames.js +1 -0
- package/lib/commonjs/media/nativeFrames.js +1 -0
- package/lib/commonjs/media/nativeFrames.web.js +1 -0
- package/lib/commonjs/media/webFrames.js +14 -0
- package/lib/commonjs/package.json +1 -0
- package/lib/commonjs/preset.js +1 -0
- package/lib/commonjs/sync/bugLinksSyncAdapter.js +1 -0
- package/lib/module/components/BugLinksBody.js +1 -0
- package/lib/module/components/TeamBugs.js +1 -0
- package/lib/module/components/focus.js +1 -0
- package/lib/module/components/overlays.js +1 -0
- package/lib/module/components/topBanners.js +1 -0
- package/lib/module/engine/client.js +1 -0
- package/lib/module/engine/credential.js +1 -0
- package/lib/module/engine/linksStore.js +1 -0
- package/lib/module/engine/loader.js +1 -0
- package/lib/module/engine/opener.js +1 -0
- package/lib/module/engine/otherTabs.js +1 -0
- package/lib/module/engine/phoneCheck.js +1 -0
- package/lib/module/engine/phoneCrypto.js +1 -0
- package/lib/module/engine/randomBytes.js +1 -0
- package/lib/module/engine/reporter.js +1 -0
- package/lib/module/engine/sandbox.js +1 -0
- package/lib/module/engine/settings.js +1 -0
- package/lib/module/engine/tabStorage.js +1 -0
- package/lib/module/engine/teamKeys.js +1 -0
- package/lib/module/engine/teamList.js +1 -0
- package/lib/module/index.js +1 -0
- package/lib/module/media/BuoyMask.js +1 -0
- package/lib/module/media/frames.js +1 -0
- package/lib/module/media/nativeFrames.js +1 -0
- package/lib/module/media/nativeFrames.web.js +1 -0
- package/lib/module/media/webFrames.js +14 -0
- package/lib/module/preset.js +1 -0
- package/lib/module/sync/bugLinksSyncAdapter.js +1 -0
- package/lib/typescript/components/BugLinksBody.d.ts +2 -0
- package/lib/typescript/components/TeamBugs.d.ts +8 -0
- package/lib/typescript/components/focus.d.ts +6 -0
- package/lib/typescript/components/overlays.d.ts +10 -0
- package/lib/typescript/components/topBanners.d.ts +35 -0
- package/lib/typescript/engine/client.d.ts +78 -0
- package/lib/typescript/engine/credential.d.ts +11 -0
- package/lib/typescript/engine/linksStore.d.ts +132 -0
- package/lib/typescript/engine/loader.d.ts +172 -0
- package/lib/typescript/engine/opener.d.ts +21 -0
- package/lib/typescript/engine/otherTabs.d.ts +52 -0
- package/lib/typescript/engine/phoneCheck.d.ts +8 -0
- package/lib/typescript/engine/phoneCrypto.d.ts +16 -0
- package/lib/typescript/engine/randomBytes.d.ts +7 -0
- package/lib/typescript/engine/reporter.d.ts +53 -0
- package/lib/typescript/engine/sandbox.d.ts +83 -0
- package/lib/typescript/engine/settings.d.ts +42 -0
- package/lib/typescript/engine/tabStorage.d.ts +6 -0
- package/lib/typescript/engine/teamKeys.d.ts +37 -0
- package/lib/typescript/engine/teamList.d.ts +63 -0
- package/lib/typescript/index.d.ts +23 -0
- package/lib/typescript/media/BuoyMask.d.ts +19 -0
- package/lib/typescript/media/frames.d.ts +34 -0
- package/lib/typescript/media/nativeFrames.d.ts +2 -0
- package/lib/typescript/media/nativeFrames.web.d.ts +2 -0
- package/lib/typescript/media/webFrames.d.ts +178 -0
- package/lib/typescript/preset.d.ts +50 -0
- package/lib/typescript/sync/bugLinksSyncAdapter.d.ts +41 -0
- package/lib/web/index.mjs +20 -0
- 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
|
+
}
|