sitepong 0.2.2 → 0.2.4

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 (57) hide show
  1. package/android/build.gradle +39 -0
  2. package/android/src/main/java/com/sitepong/deviceid/DeviceIdHelper.kt +45 -0
  3. package/android/src/main/java/com/sitepong/deviceid/DeviceIdModule.kt +45 -0
  4. package/android/src/main/java/com/sitepong/screenrecorder/ChunkUploader.kt +78 -0
  5. package/android/src/main/java/com/sitepong/screenrecorder/H264Encoder.kt +163 -0
  6. package/android/src/main/java/com/sitepong/screenrecorder/ScreenCapture.kt +103 -0
  7. package/android/src/main/java/com/sitepong/screenrecorder/ScreenRecorderModule.kt +80 -0
  8. package/android/src/main/java/com/sitepong/screenrecorder/SensitiveViewManager.kt +19 -0
  9. package/app.plugin.js +426 -0
  10. package/dist/cdn/sitepong.min.js +5 -5
  11. package/dist/cdn/sitepong.min.js.map +1 -1
  12. package/dist/entries/rn.d.ts +188 -16
  13. package/dist/entries/rn.js +188 -35
  14. package/dist/entries/rn.js.map +1 -1
  15. package/dist/entries/web.js +2 -1
  16. package/dist/entries/web.js.map +1 -1
  17. package/dist/entries/web.mjs +2 -1
  18. package/dist/entries/web.mjs.map +1 -1
  19. package/dist/index.d.mts +1 -1
  20. package/dist/index.d.ts +1 -1
  21. package/dist/index.js +2 -1
  22. package/dist/index.js.map +1 -1
  23. package/dist/index.mjs +2 -1
  24. package/dist/index.mjs.map +1 -1
  25. package/dist/react/index.js +2 -1
  26. package/dist/react/index.js.map +1 -1
  27. package/dist/react/index.mjs +2 -1
  28. package/dist/react/index.mjs.map +1 -1
  29. package/expo-module.config.json +16 -0
  30. package/ios/DeviceId/DeviceIdModule.swift +55 -0
  31. package/ios/DeviceId/KeychainHelper.swift +68 -0
  32. package/ios/LiveActivity/ConfigStore.swift +43 -0
  33. package/ios/LiveActivity/DSLNode.swift +188 -0
  34. package/ios/LiveActivity/SitePongActivityAttributes.swift +112 -0
  35. package/ios/LiveActivity/SitePongLiveActivityModule.swift +250 -0
  36. package/ios/LiveActivity/widget/ColorHex.swift +30 -0
  37. package/ios/LiveActivity/widget/DSLAnimations.swift +128 -0
  38. package/ios/LiveActivity/widget/DSLRenderer.swift +538 -0
  39. package/ios/LiveActivity/widget/SitePongLiveActivityWidget.swift +393 -0
  40. package/ios/LiveActivity/widget/SitePongWidgetBundle.swift +84 -0
  41. package/ios/ScreenRecorder/ChunkUploader.swift +52 -0
  42. package/ios/ScreenRecorder/H264Encoder.swift +229 -0
  43. package/ios/ScreenRecorder/ScreenCapture.swift +149 -0
  44. package/ios/ScreenRecorder/ScreenRecorderModule.swift +179 -0
  45. package/ios/ScreenRecorder/SensitiveViewManager.swift +63 -0
  46. package/ios/Sitepong.podspec +57 -0
  47. package/ios/WatchtowerCore/Screenshotter.swift +163 -0
  48. package/ios/WatchtowerCore/SwiftUIModifiers.swift +78 -0
  49. package/ios/WatchtowerCore/Swizzling.swift +59 -0
  50. package/ios/WatchtowerCore/TapEvent.swift +61 -0
  51. package/ios/WatchtowerCore/Transport.swift +136 -0
  52. package/ios/WatchtowerCore/UIViewExtensions.swift +31 -0
  53. package/ios/WatchtowerCore/Watchtower.swift +80 -0
  54. package/ios/WatchtowerCore/WatchtowerEngine.swift +565 -0
  55. package/ios/WatchtowerCore/WatchtowerHash.swift +120 -0
  56. package/ios/WatchtowerCore/WatchtowerRegistry.swift +87 -0
  57. package/package.json +19 -8
@@ -0,0 +1,565 @@
1
+ #if canImport(UIKit)
2
+ import UIKit
3
+
4
+ /// The capture engine: resolves taps/screens, gates screenshot uploads on a
5
+ /// per-session set, and drives the transport. Bridge-free (no React/RN imports).
6
+ final class WatchtowerEngine {
7
+ static let shared = WatchtowerEngine()
8
+
9
+ private(set) var isRunning = false
10
+
11
+ private var transport: Transport?
12
+ private var sessionId = UUID().uuidString
13
+ private var sequence: UInt64 = 0
14
+ private var sampleRate: Double = 0.1
15
+ private var appVersion: String?
16
+ /// Identity from Watchtower.setUser (§1.1 distinct_id). nil = anonymous.
17
+ private var distinctId: String?
18
+ /// Hardware identifier (utsname.machine, e.g. "iPhone17,1") + OS version,
19
+ /// stamped on every event (§1.1).
20
+ private var deviceModel: String?
21
+ private var osVersion: String?
22
+ private var sessionStartedAt: Date?
23
+ /// Layer-3 sampling (§1.4): drawn once per session against sampleRate. A
24
+ /// sampled session uploads a frame after every screen capture and stamps
25
+ /// frame_hash on its taps.
26
+ private var isSampledSession = false
27
+ private var lastFrameHash: String?
28
+ /// The last screen_view we emitted — dedup guard + prev_screen_name source.
29
+ private var lastEmittedScreenName: String?
30
+ /// Bumped on every emitted screen_view; dead-tap checks compare it to rule
31
+ /// out taps that navigated.
32
+ private var navCounter: UInt64 = 0
33
+ /// Platform stamped on every TapEvent (§1.1). Default "ios"; the RN bridge
34
+ /// overrides to "react-native-ios" via start(platform:).
35
+ private var platform: String = "ios"
36
+
37
+ /// Explicit screen identity override via setScreen (§3.2). Cleared/overridden
38
+ /// by viewDidAppear unless re-set.
39
+ private var explicitScreenName: String?
40
+ /// Last screen name resolved from a UIViewController.viewDidAppear.
41
+ private var lastUIKitScreenName: String?
42
+
43
+ /// Per-session set of uploaded screen fingerprints (upload-on-miss, §1.3/§3.4).
44
+ private var uploadedScreenFps = Set<String>()
45
+ /// In-flight uploads, to avoid duplicate template POSTs for the same fp.
46
+ private var inFlightScreenFps = Set<String>()
47
+
48
+ /// Last computed pHash per screen name. Taps read this cache — the touch
49
+ /// pipeline NEVER renders or hashes (§3.4). Captures refresh it on screen
50
+ /// appearance and on a throttled per-tap cadence.
51
+ private var fpCache: [String: String] = [:]
52
+ /// Wall-clock of the last completed capture per screen (throttling).
53
+ private var lastCaptureAt: [String: CFTimeInterval] = [:]
54
+ /// Screens with a capture currently in the render/hash/upload pipeline.
55
+ private var capturesInFlight = Set<String>()
56
+ /// Minimum interval between captures of the same screen. A tap only
57
+ /// triggers a re-capture after this cooldown, so a burst of taps costs at
58
+ /// most one render per second (industry pattern: frames on their own
59
+ /// cadence, decoupled from the tap event stream).
60
+ private let captureThrottle: CFTimeInterval = 1.0
61
+
62
+ private let encodeQueue = DispatchQueue(label: "com.watchtower.encode", qos: .utility)
63
+
64
+ // MARK: - Lifecycle
65
+
66
+ func start(apiKey: String, projectId: String, endpoint: URL, sampleRate: Double,
67
+ platform: String = "ios") {
68
+ guard !isRunning else { return }
69
+ self.transport = Transport(apiKey: apiKey, projectId: projectId, endpoint: endpoint)
70
+ self.sessionId = UUID().uuidString
71
+ self.sequence = 0
72
+ self.sampleRate = sampleRate
73
+ self.platform = platform
74
+ self.uploadedScreenFps.removeAll()
75
+ self.inFlightScreenFps.removeAll()
76
+ self.fpCache.removeAll()
77
+ self.lastCaptureAt.removeAll()
78
+ self.capturesInFlight.removeAll()
79
+ self.appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String
80
+ self.deviceModel = Self.hardwareModel()
81
+ self.osVersion = UIDevice.current.systemVersion
82
+ self.sessionStartedAt = Date()
83
+ self.isSampledSession = Double.random(in: 0..<1) < sampleRate
84
+ self.lastFrameHash = nil
85
+ self.lastEmittedScreenName = nil
86
+ self.navCounter = 0
87
+ self.pendingTaps.removeAll()
88
+ Swizzling.installIfNeeded()
89
+ observeBackground()
90
+ isRunning = true
91
+ emit(baseEvent(type: "session_start"))
92
+ }
93
+
94
+ func stop() {
95
+ guard isRunning else { return }
96
+ flushPendingTaps()
97
+ var end = baseEvent(type: "session_end")
98
+ end.reason = "stop"
99
+ end.duration_ms = durationMs()
100
+ emit(end)
101
+ isRunning = false
102
+ transport?.flushAndWait()
103
+ transport = nil
104
+ }
105
+
106
+ func setUser(_ id: String?) {
107
+ distinctId = id
108
+ }
109
+
110
+ var currentSessionId: String? { isRunning ? sessionId : nil }
111
+
112
+ private func durationMs() -> UInt64 {
113
+ guard let started = sessionStartedAt else { return 0 }
114
+ return UInt64(max(0, Date().timeIntervalSince(started) * 1000))
115
+ }
116
+
117
+ /// Common fields for every event type (§1.1); increments the shared
118
+ /// per-session sequence.
119
+ private func baseEvent(type: String) -> CaptureEvent {
120
+ sequence += 1
121
+ return CaptureEvent(
122
+ type: type, session_id: sessionId, sequence: sequence,
123
+ timestamp: ISO8601DateFormatter.wt.string(from: Date()),
124
+ platform: platform, app_version: appVersion,
125
+ distinct_id: distinctId, device_model: deviceModel,
126
+ os_version: osVersion
127
+ )
128
+ }
129
+
130
+ private func emit(_ event: CaptureEvent) {
131
+ transport?.enqueue(event)
132
+ }
133
+
134
+ private static func hardwareModel() -> String? {
135
+ // On simulator utsname.machine is the host arch ("arm64"); the real
136
+ // device identifier is in the environment.
137
+ if let sim = ProcessInfo.processInfo.environment["SIMULATOR_MODEL_IDENTIFIER"] {
138
+ return sim
139
+ }
140
+ var sys = utsname()
141
+ uname(&sys)
142
+ return withUnsafePointer(to: &sys.machine) {
143
+ $0.withMemoryRebound(to: CChar.self, capacity: 1) { String(validatingUTF8: $0) }
144
+ }
145
+ }
146
+
147
+ func setScreen(_ name: String) {
148
+ explicitScreenName = name
149
+ scheduleAppearanceCapture()
150
+ }
151
+
152
+ private func observeBackground() {
153
+ NotificationCenter.default.addObserver(
154
+ self, selector: #selector(appDidEnterBackground),
155
+ name: UIApplication.didEnterBackgroundNotification, object: nil)
156
+ }
157
+
158
+ @objc private func appDidEnterBackground() {
159
+ // Pending dead-checks can't complete in the background — flush them
160
+ // undetermined (dead omitted), then mark the session boundary. The
161
+ // session does NOT end here (§1.1): reason "background" lets the
162
+ // server close it on timeout if the app never returns.
163
+ flushPendingTaps()
164
+ var end = baseEvent(type: "session_end")
165
+ end.reason = "background"
166
+ end.duration_ms = durationMs()
167
+ emit(end)
168
+ transport?.flush()
169
+ }
170
+
171
+ // MARK: - Screen views (§1.1 screen_view)
172
+
173
+ /// Single choke point for screen identity changes — called with the
174
+ /// resolved name from scheduleAppearanceCapture (which both viewDidAppear
175
+ /// and setScreen funnel through). Emits a screen_view with the transition
176
+ /// edge (prev_screen_name) and bumps navCounter for dead-tap checks.
177
+ private func noteScreenChanged(_ name: String) {
178
+ guard name != lastEmittedScreenName else { return }
179
+ var ev = baseEvent(type: "screen_view")
180
+ ev.screen_name = name
181
+ ev.prev_screen_name = lastEmittedScreenName
182
+ lastEmittedScreenName = name
183
+ navCounter += 1
184
+ emit(ev)
185
+ }
186
+
187
+ // MARK: - Screen identity (§3.2)
188
+
189
+ func didAppear(viewController vc: UIViewController) {
190
+ // Skip container controllers that don't represent a screen.
191
+ if vc is UINavigationController || vc is UITabBarController { return }
192
+ // SwiftUI hosting controllers get their identity from the
193
+ // `.watchtowerScreen` modifier inside, not the generic class name; don't
194
+ // let them clobber the auto screen name.
195
+ if String(describing: type(of: vc)).hasPrefix("UIHostingController") {
196
+ if let explicit = vc.watchtowerScreenName { lastUIKitScreenName = explicit }
197
+ // An explicit setScreen (e.g. React-Navigation route via the RN
198
+ // bridge) overrides until the NEXT setScreen — a SwiftUI hosting
199
+ // appearance must not silently clear it. Only an explicit
200
+ // watchtowerScreenName on the VC itself supersedes (handled above
201
+ // by updating lastUIKitScreenName, which currentScreenName falls
202
+ // back to only when no explicit name is set).
203
+ scheduleAppearanceCapture()
204
+ return
205
+ }
206
+ let name = vc.watchtowerScreenName
207
+ ?? vc.restorationIdentifier
208
+ ?? String(describing: type(of: vc))
209
+ lastUIKitScreenName = name
210
+ // Per §3.2 the explicit setScreen overrides until the next setScreen.
211
+ // We update the auto fallback (lastUIKitScreenName) but intentionally do
212
+ // NOT clear explicitScreenName here: on the RN path every view is a
213
+ // generic RNSScreen / transient keyboard controller, so the React-
214
+ // Navigation route fed via setScreen must remain authoritative. Native
215
+ // UIKit hosts that never call setScreen are unaffected (explicit is nil).
216
+ scheduleAppearanceCapture()
217
+ }
218
+
219
+ private func currentScreenName(forPoint point: CGPoint, in window: UIWindow) -> String {
220
+ if let explicit = explicitScreenName { return explicit }
221
+ // SwiftUI screen region wins if the touch is inside one.
222
+ if let sui = WatchtowerRegistry.shared.screenName(at: point, in: window) {
223
+ return sui
224
+ }
225
+ // A currently-visible SwiftUI screen region (e.g. a pushed hosting
226
+ // controller) takes precedence over the last UIKit screen, so taps in
227
+ // chrome (nav bar back button) still attribute to the SwiftUI screen.
228
+ if let sui = WatchtowerRegistry.shared.topScreenName(in: window) { return sui }
229
+ if let uikit = lastUIKitScreenName { return uikit }
230
+ return "Unknown"
231
+ }
232
+
233
+ // MARK: - Touch capture (§3.1)
234
+
235
+ func handle(event: UIEvent, in window: UIWindow) {
236
+ guard event.type == .touches, let touches = event.allTouches else { return }
237
+ // Never record keyboard input. The swizzle sees every UIWindow,
238
+ // including the system keyboard windows (UIRemoteKeyboardWindow /
239
+ // UITextEffectsWindow) — a tap there with normalized coordinates is a
240
+ // keystroke, and recording it would let a password be reconstructed
241
+ // from the event stream.
242
+ if Self.isSystemInputWindow(window) { return }
243
+ for touch in touches where touch.phase == .ended {
244
+ let point = touch.location(in: window)
245
+ captureTap(at: point, in: window)
246
+ }
247
+ }
248
+
249
+ /// True for the system keyboard / input-accessory windows. Matched by class
250
+ /// name (they're private UIKit classes).
251
+ static func isSystemInputWindow(_ window: UIWindow) -> Bool {
252
+ let cls = String(describing: type(of: window))
253
+ return cls.contains("Keyboard") || cls.contains("TextEffects")
254
+ }
255
+
256
+ private func captureTap(at point: CGPoint, in window: UIWindow) {
257
+ let bounds = window.bounds
258
+ guard bounds.width > 0, bounds.height > 0 else { return }
259
+
260
+ let screenName = currentScreenName(forPoint: point, in: window)
261
+
262
+ // Resolve element identity (§3.3).
263
+ let resolved = resolveElement(at: point, in: window)
264
+
265
+ let nx = max(0, min(1, Double(point.x / bounds.width)))
266
+ let ny = max(0, min(1, Double(point.y / bounds.height)))
267
+
268
+ // The touch pipeline never renders or hashes (§3.4): read the cached
269
+ // fingerprint for this screen and ask for a (throttled, next-runloop)
270
+ // capture refresh. The first tap on a not-yet-captured screen carries
271
+ // the zero-hash fallback; the appearance-triggered capture normally
272
+ // fills the cache before a human can tap.
273
+ let screenFp = "\(screenName):\(fpCache[screenName] ?? "0000000000000000")"
274
+ requestCapture(screenName: screenName)
275
+
276
+ var event = baseEvent(type: "tap")
277
+ event.screen_fp = screenFp
278
+ event.screen_name = screenName
279
+ event.element_id = resolved.id
280
+ event.element_label = resolved.label
281
+ event.element_role = resolved.role
282
+ event.x = nx
283
+ event.y = ny
284
+ event.viewport_w = UInt(bounds.width)
285
+ event.viewport_h = UInt(bounds.height)
286
+ event.frame_hash = isSampledSession ? lastFrameHash : nil
287
+ stageForDeadCheck(event, screenName: screenName)
288
+ }
289
+
290
+ // MARK: - Dead-tap detection (§1.1 `dead`)
291
+
292
+ private struct PendingTap {
293
+ var event: CaptureEvent
294
+ let screenName: String
295
+ let fpAtTap: String?
296
+ let navAtTap: UInt64
297
+ let stagedAt: CFTimeInterval
298
+ }
299
+ /// Taps held ~1s before enqueue so the dead heuristic can stamp them.
300
+ /// Batch flush is 10s, so this delay is invisible on the wire.
301
+ private var pendingTaps: [UInt64: PendingTap] = [:]
302
+
303
+ /// Hold the tap briefly: a tap is dead when, ~1s later, the screen hasn't
304
+ /// changed (navCounter), no transition ran, and the screen's redacted
305
+ /// dHash is unchanged. Cheap signals rule most taps out before the one
306
+ /// render the fp re-check needs (and that render is coalesced per screen).
307
+ private func stageForDeadCheck(_ event: CaptureEvent, screenName: String) {
308
+ let seq = event.sequence
309
+ pendingTaps[seq] = PendingTap(event: event, screenName: screenName,
310
+ fpAtTap: fpCache[screenName], navAtTap: navCounter,
311
+ stagedAt: CACurrentMediaTime())
312
+ // Half-way point: if the user didn't navigate, force a fresh capture so
313
+ // the fp comparison at +1.0s reflects the post-tap screen state.
314
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
315
+ guard let self = self, let p = self.pendingTaps[seq], self.isRunning else { return }
316
+ if self.navCounter == p.navAtTap {
317
+ self.requestCapture(screenName: p.screenName, force: true)
318
+ }
319
+ }
320
+ DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) { [weak self] in
321
+ guard let self = self else { return }
322
+ guard var p = self.pendingTaps.removeValue(forKey: seq) else { return }
323
+ // dead only when a capture COMPLETED after the tap and the hash is
324
+ // still identical — a stale cache (capture pending/failed) leaves
325
+ // the tap undetermined rather than false-flagging it.
326
+ if self.isRunning,
327
+ self.navCounter == p.navAtTap,
328
+ let before = p.fpAtTap,
329
+ let capturedAt = self.lastCaptureAt[p.screenName],
330
+ capturedAt > p.stagedAt,
331
+ self.fpCache[p.screenName] == before {
332
+ p.event.dead = true
333
+ }
334
+ self.emit(p.event)
335
+ }
336
+ }
337
+
338
+ /// Enqueue all held taps immediately, dead undetermined (omitted). Called
339
+ /// on stop/background so nothing is lost to the hold window.
340
+ private func flushPendingTaps() {
341
+ let held = pendingTaps.values.sorted { $0.event.sequence < $1.event.sequence }
342
+ pendingTaps.removeAll()
343
+ for p in held { emit(p.event) }
344
+ }
345
+
346
+ struct Resolved {
347
+ let id: String
348
+ let label: String?
349
+ let role: String?
350
+ }
351
+
352
+ /// Hit-test → resolve deepest interactive view → element identity (§3.3).
353
+ private func resolveElement(at point: CGPoint, in window: UIWindow) -> Resolved {
354
+ let hit = window.hitTest(point, with: nil)
355
+ let view = deepestInteractive(from: hit) ?? hit
356
+
357
+ // SwiftUI tag region takes precedence for id if the hit view itself
358
+ // has no identity (SwiftUI hit views are generic hosting views).
359
+ let tagRegion = WatchtowerRegistry.shared.tag(at: point, in: window)
360
+
361
+ let role = roleFor(view: view)
362
+ let label = labelFor(view: view)
363
+
364
+ // Resolution order: accessibilityIdentifier -> watchtowerTag (UIView) ->
365
+ // SwiftUI tag -> accessibilityLabel -> "<Class>@<tag>".
366
+ if let aid = view?.accessibilityIdentifier, !aid.isEmpty {
367
+ return Resolved(id: aid, label: label, role: role)
368
+ }
369
+ if let wt = view?.watchtowerTag, !wt.isEmpty {
370
+ return Resolved(id: wt, label: label, role: role)
371
+ }
372
+ if let tag = tagRegion?.id, !tag.isEmpty {
373
+ return Resolved(id: tag, label: label ?? "", role: role ?? "view")
374
+ }
375
+ if let al = view?.accessibilityLabel, !al.isEmpty {
376
+ return Resolved(id: al, label: label, role: role)
377
+ }
378
+ let cls = view.map { String(describing: type(of: $0)) } ?? "UnknownView"
379
+ let tagNum = view?.tag ?? 0
380
+ return Resolved(id: "\(cls)@\(tagNum)", label: label, role: role)
381
+ }
382
+
383
+ private func deepestInteractive(from view: UIView?) -> UIView? {
384
+ var v = view
385
+ while let cur = v {
386
+ if cur is UIControl { return cur }
387
+ if cur is UITableViewCell { return cur }
388
+ if cur is UICollectionViewCell { return cur }
389
+ if cur.accessibilityIdentifier?.isEmpty == false { return cur }
390
+ if cur.watchtowerTag?.isEmpty == false { return cur }
391
+ if cur.isAccessibilityElement { return cur }
392
+ v = cur.superview
393
+ }
394
+ return view
395
+ }
396
+
397
+ private func roleFor(view: UIView?) -> String? {
398
+ switch view {
399
+ case is UIButton: return "button"
400
+ case is UISwitch: return "switch"
401
+ case is UISlider: return "slider"
402
+ case is UITextField: return "textfield"
403
+ case is UITableViewCell: return "cell"
404
+ case is UICollectionViewCell: return "cell"
405
+ case is UIControl: return "control"
406
+ default: return view == nil ? nil : "view"
407
+ }
408
+ }
409
+
410
+ private func labelFor(view: UIView?) -> String? {
411
+ if let button = view as? UIButton {
412
+ if let t = button.titleLabel?.text, !t.isEmpty { return t }
413
+ }
414
+ if let al = view?.accessibilityLabel, !al.isEmpty { return al }
415
+ return nil
416
+ }
417
+
418
+ // MARK: - Screenshot capture pipeline (§3.4)
419
+
420
+ /// Resolve the current screen name without a touch point, mirroring
421
+ /// currentScreenName(forPoint:)'s precedence: explicit setScreen > visible
422
+ /// SwiftUI screen region > last UIKit appearance. SwiftUI must come before
423
+ /// the UIKit fallback — a pushed hosting controller doesn't update
424
+ /// lastUIKitScreenName (its identity lives in the .watchtowerScreen region),
425
+ /// so checking lastUIKitScreenName first would report the screen underneath.
426
+ private func resolveScreenName(in window: UIWindow) -> String {
427
+ return explicitScreenName
428
+ ?? WatchtowerRegistry.shared.topScreenName(in: window)
429
+ ?? lastUIKitScreenName ?? "Unknown"
430
+ }
431
+
432
+ /// Warm the fingerprint cache when a screen appears (viewDidAppear /
433
+ /// setScreen). The short delay lets the transition begin so the settled-
434
+ /// layout gate in performCapture sees it and defers to the settled frame.
435
+ private func scheduleAppearanceCapture() {
436
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in
437
+ guard let self = self, self.isRunning, let window = self.keyWindow() else { return }
438
+ let name = self.resolveScreenName(in: window)
439
+ self.noteScreenChanged(name)
440
+ self.requestCapture(screenName: name)
441
+ }
442
+ }
443
+
444
+ /// Ask for a capture of `screenName`. Coalesced (one in flight per screen),
445
+ /// throttled (at most one per captureThrottle once the cache is warm), and
446
+ /// ALWAYS deferred to the next main-runloop turn — never renders inside the
447
+ /// touch-delivery call stack.
448
+ private func requestCapture(screenName: String, force: Bool = false) {
449
+ guard isRunning else { return }
450
+ guard !capturesInFlight.contains(screenName) else { return }
451
+ if !force, fpCache[screenName] != nil,
452
+ let last = lastCaptureAt[screenName],
453
+ CACurrentMediaTime() - last < captureThrottle {
454
+ return
455
+ }
456
+ capturesInFlight.insert(screenName)
457
+ DispatchQueue.main.async { [weak self] in
458
+ self?.performCapture(screenName: screenName, attempt: 0)
459
+ }
460
+ }
461
+
462
+ /// One capture attempt. Templates are only STORED from a SETTLED layout: a
463
+ /// frame rendered mid-scroll or mid-transition reflects an in-between state,
464
+ /// not the canonical screen, and storing it would create a spurious second
465
+ /// template for the same screen. While layout is in flight this re-polls a
466
+ /// bounded number of runloop turns (~50ms each; 12 turns covers a push
467
+ /// animation or scroll deceleration), converging on the settled frame.
468
+ ///
469
+ /// Thread split: main thread does the raw render + sensitive-rect walk only;
470
+ /// blur, RGBA extraction, dHash, and PNG encode all run on encodeQueue.
471
+ private func performCapture(screenName: String, attempt: Int) {
472
+ guard isRunning, let window = keyWindow(),
473
+ resolveScreenName(in: window) == screenName else {
474
+ capturesInFlight.remove(screenName)
475
+ return
476
+ }
477
+ if Screenshotter.isLayoutInFlight(in: window) {
478
+ guard attempt < 12 else { capturesInFlight.remove(screenName); return }
479
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in
480
+ self?.performCapture(screenName: screenName, attempt: attempt + 1)
481
+ }
482
+ return
483
+ }
484
+ guard let raw = Screenshotter.renderRaw(window: window) else {
485
+ capturesInFlight.remove(screenName)
486
+ return
487
+ }
488
+
489
+ encodeQueue.async { [weak self] in
490
+ guard let self = self else { return }
491
+ // Redaction happens BEFORE hashing (§5), so the hash is computed
492
+ // over the same pixels that would be uploaded.
493
+ let redacted = Screenshotter.redact(raw)
494
+ guard let bytes = Screenshotter.rgbaBytes(from: redacted) else {
495
+ DispatchQueue.main.async { self.capturesInFlight.remove(screenName) }
496
+ return
497
+ }
498
+ let pHash = WatchtowerHash.dHash(rgba: bytes.rgba, width: bytes.width, height: bytes.height)
499
+ let png = redacted.pngData()
500
+
501
+ DispatchQueue.main.async {
502
+ self.capturesInFlight.remove(screenName)
503
+ guard self.isRunning else { return }
504
+ self.fpCache[screenName] = pHash
505
+ self.lastCaptureAt[screenName] = CACurrentMediaTime()
506
+
507
+ // Layer-3 sampled session (§1.4): every successful capture also
508
+ // uploads a session-scoped frame; taps carry its hash.
509
+ if self.isSampledSession, self.lastFrameHash != pHash, let png = png {
510
+ self.lastFrameHash = pHash
511
+ self.transport?.postFrame(
512
+ sessionId: self.sessionId,
513
+ timestamp: ISO8601DateFormatter.wt.string(from: Date()),
514
+ screenName: screenName, frameHash: pHash,
515
+ width: bytes.width, height: bytes.height,
516
+ pngBase64: png.base64EncodedString()
517
+ )
518
+ }
519
+
520
+ let screenFp = "\(screenName):\(pHash)"
521
+ guard !self.uploadedScreenFps.contains(screenFp),
522
+ !self.inFlightScreenFps.contains(screenFp),
523
+ let png = png else { return }
524
+ self.inFlightScreenFps.insert(screenFp)
525
+ self.transport?.postTemplate(
526
+ routeName: screenName, pHash: pHash,
527
+ width: bytes.width, height: bytes.height,
528
+ pngBase64: png.base64EncodedString()
529
+ ) { _ in
530
+ DispatchQueue.main.async {
531
+ self.inFlightScreenFps.remove(screenFp)
532
+ self.uploadedScreenFps.insert(screenFp)
533
+ }
534
+ }
535
+ }
536
+ }
537
+ }
538
+
539
+ // MARK: - Manual capture (§3 captureScreenshot)
540
+
541
+ func captureScreenshot() {
542
+ guard isRunning, let window = keyWindow() else { return }
543
+ requestCapture(screenName: resolveScreenName(in: window), force: true)
544
+ }
545
+
546
+ func keyWindow() -> UIWindow? {
547
+ return UIApplication.shared.connectedScenes
548
+ .compactMap { $0 as? UIWindowScene }
549
+ .flatMap { $0.windows }
550
+ .first(where: { $0.isKeyWindow }) ??
551
+ UIApplication.shared.connectedScenes
552
+ .compactMap { $0 as? UIWindowScene }
553
+ .flatMap { $0.windows }
554
+ .first
555
+ }
556
+ }
557
+
558
+ extension ISO8601DateFormatter {
559
+ static let wt: ISO8601DateFormatter = {
560
+ let f = ISO8601DateFormatter()
561
+ f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
562
+ return f
563
+ }()
564
+ }
565
+ #endif
@@ -0,0 +1,120 @@
1
+ import Foundation
2
+
3
+ /// dHash perceptual hash — Swift port that MUST stay byte-identical to the
4
+ /// reference TypeScript implementation in `@sitepong/capture-core`.
5
+ ///
6
+ /// Algorithm (spec §2):
7
+ /// 1. Input is RGBA (redaction already applied, §5).
8
+ /// 2. Grayscale: gray = round(0.299 R + 0.587 G + 0.114 B).
9
+ /// 3. Bilinear resize to width=9, height=8 (8 rows x 9 cols of gray),
10
+ /// half-pixel-center / align-corners=false.
11
+ /// 4. For each row, cols c in 0..7: bit = (gray[r][c] < gray[r][c+1]) ? 1 : 0.
12
+ /// 5. Row-major, row 0 first; within a row, c=0 is the MSB of that byte.
13
+ /// => 64 bits => 8 bytes => 16 lowercase hex chars (MSB first).
14
+ public enum WatchtowerHash {
15
+
16
+ public static let dHashWidth = 9
17
+ public static let dHashHeight = 8
18
+
19
+ /// ITU-R BT.601 luma weights, matched exactly across TS and Swift.
20
+ @inline(__always)
21
+ static func toGray(_ r: Double, _ g: Double, _ b: Double) -> Double {
22
+ // Swift's rounded() uses .toNearestOrAwayFromZero, matching JS Math.round
23
+ // for the non-negative values produced here.
24
+ return (0.299 * r + 0.587 * g + 0.114 * b).rounded()
25
+ }
26
+
27
+ /// Convert an RGBA byte buffer to a full-resolution grayscale plane.
28
+ static func rgbaToGray(_ rgba: [UInt8], width: Int, height: Int) -> [Double] {
29
+ var out = [Double](repeating: 0, count: width * height)
30
+ let count = width * height
31
+ for i in 0..<count {
32
+ let o = i * 4
33
+ out[i] = toGray(Double(rgba[o]), Double(rgba[o + 1]), Double(rgba[o + 2]))
34
+ }
35
+ return out
36
+ }
37
+
38
+ /// Bilinear resize of a single-channel grayscale plane to dstW x dstH using
39
+ /// the align-corners=false half-pixel center mapping:
40
+ /// srcX = (dstX + 0.5) * (srcW / dstW) - 0.5 (clamped)
41
+ static func bilinearResizeGray(
42
+ _ src: [Double], srcW: Int, srcH: Int, dstW: Int, dstH: Int
43
+ ) -> [Double] {
44
+ var dst = [Double](repeating: 0, count: dstW * dstH)
45
+ let scaleX = Double(srcW) / Double(dstW)
46
+ let scaleY = Double(srcH) / Double(dstH)
47
+
48
+ for dy in 0..<dstH {
49
+ var sy = (Double(dy) + 0.5) * scaleY - 0.5
50
+ if sy < 0 { sy = 0 }
51
+ if sy > Double(srcH - 1) { sy = Double(srcH - 1) }
52
+ let y0 = Int(sy.rounded(.down))
53
+ let y1 = min(y0 + 1, srcH - 1)
54
+ let wy = sy - Double(y0)
55
+
56
+ for dx in 0..<dstW {
57
+ var sx = (Double(dx) + 0.5) * scaleX - 0.5
58
+ if sx < 0 { sx = 0 }
59
+ if sx > Double(srcW - 1) { sx = Double(srcW - 1) }
60
+ let x0 = Int(sx.rounded(.down))
61
+ let x1 = min(x0 + 1, srcW - 1)
62
+ let wx = sx - Double(x0)
63
+
64
+ let p00 = src[y0 * srcW + x0]
65
+ let p01 = src[y0 * srcW + x1]
66
+ let p10 = src[y1 * srcW + x0]
67
+ let p11 = src[y1 * srcW + x1]
68
+
69
+ let top = p00 + (p01 - p00) * wx
70
+ let bottom = p10 + (p11 - p10) * wx
71
+ dst[dy * dstW + dx] = top + (bottom - top) * wy
72
+ }
73
+ }
74
+ return dst
75
+ }
76
+
77
+ /// Compute the dHash of an RGBA image. Returns 16-char lowercase hex.
78
+ public static func dHash(rgba: [UInt8], width: Int, height: Int) -> String {
79
+ precondition(width > 0 && height > 0, "dHash: invalid dimensions")
80
+ precondition(rgba.count >= width * height * 4, "dHash: rgba buffer too short")
81
+
82
+ let gray = rgbaToGray(rgba, width: width, height: height)
83
+ let small = bilinearResizeGray(gray, srcW: width, srcH: height,
84
+ dstW: dHashWidth, dstH: dHashHeight)
85
+
86
+ var bytes = [UInt8](repeating: 0, count: 8)
87
+ for row in 0..<dHashHeight {
88
+ var byte = 0
89
+ for c in 0..<8 {
90
+ let left = small[row * dHashWidth + c]
91
+ let right = small[row * dHashWidth + c + 1]
92
+ let bit = left < right ? 1 : 0
93
+ byte |= bit << (7 - c)
94
+ }
95
+ bytes[row] = UInt8(byte)
96
+ }
97
+
98
+ var hex = ""
99
+ hex.reserveCapacity(16)
100
+ for i in 0..<8 {
101
+ hex += String(format: "%02x", bytes[i])
102
+ }
103
+ return hex
104
+ }
105
+
106
+ /// Hamming distance between two dHash hex strings (popcount of XOR).
107
+ public static func hamming(_ a: String, _ b: String) -> Int {
108
+ let ac = Array(a), bc = Array(b)
109
+ guard ac.count == bc.count, ac.count % 2 == 0 else { return Int.max }
110
+ var dist = 0
111
+ var i = 0
112
+ while i < ac.count {
113
+ let byteA = UInt8(String(ac[i...(i + 1)]), radix: 16) ?? 0
114
+ let byteB = UInt8(String(bc[i...(i + 1)]), radix: 16) ?? 0
115
+ dist += (byteA ^ byteB).nonzeroBitCount
116
+ i += 2
117
+ }
118
+ return dist
119
+ }
120
+ }