sitepong 0.2.3 → 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.
- package/android/build.gradle +39 -0
- package/android/src/main/java/com/sitepong/deviceid/DeviceIdHelper.kt +45 -0
- package/android/src/main/java/com/sitepong/deviceid/DeviceIdModule.kt +45 -0
- package/android/src/main/java/com/sitepong/screenrecorder/ChunkUploader.kt +78 -0
- package/android/src/main/java/com/sitepong/screenrecorder/H264Encoder.kt +163 -0
- package/android/src/main/java/com/sitepong/screenrecorder/ScreenCapture.kt +103 -0
- package/android/src/main/java/com/sitepong/screenrecorder/ScreenRecorderModule.kt +80 -0
- package/android/src/main/java/com/sitepong/screenrecorder/SensitiveViewManager.kt +19 -0
- package/app.plugin.js +426 -0
- package/dist/cdn/sitepong.min.js +5 -5
- package/dist/cdn/sitepong.min.js.map +1 -1
- package/dist/entries/rn.d.ts +188 -16
- package/dist/entries/rn.js +188 -35
- package/dist/entries/rn.js.map +1 -1
- package/dist/entries/web.js +2 -1
- package/dist/entries/web.js.map +1 -1
- package/dist/entries/web.mjs +2 -1
- package/dist/entries/web.mjs.map +1 -1
- package/dist/index.d.mts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +2 -1
- package/dist/index.mjs.map +1 -1
- package/dist/react/index.js +2 -1
- package/dist/react/index.js.map +1 -1
- package/dist/react/index.mjs +2 -1
- package/dist/react/index.mjs.map +1 -1
- package/expo-module.config.json +16 -0
- package/ios/DeviceId/DeviceIdModule.swift +55 -0
- package/ios/DeviceId/KeychainHelper.swift +68 -0
- package/ios/LiveActivity/ConfigStore.swift +43 -0
- package/ios/LiveActivity/DSLNode.swift +188 -0
- package/ios/LiveActivity/SitePongActivityAttributes.swift +112 -0
- package/ios/LiveActivity/SitePongLiveActivityModule.swift +250 -0
- package/ios/LiveActivity/widget/ColorHex.swift +30 -0
- package/ios/LiveActivity/widget/DSLAnimations.swift +128 -0
- package/ios/LiveActivity/widget/DSLRenderer.swift +538 -0
- package/ios/LiveActivity/widget/SitePongLiveActivityWidget.swift +393 -0
- package/ios/LiveActivity/widget/SitePongWidgetBundle.swift +84 -0
- package/ios/ScreenRecorder/ChunkUploader.swift +52 -0
- package/ios/ScreenRecorder/H264Encoder.swift +229 -0
- package/ios/ScreenRecorder/ScreenCapture.swift +149 -0
- package/ios/ScreenRecorder/ScreenRecorderModule.swift +179 -0
- package/ios/ScreenRecorder/SensitiveViewManager.swift +63 -0
- package/ios/Sitepong.podspec +57 -0
- package/ios/WatchtowerCore/Screenshotter.swift +163 -0
- package/ios/WatchtowerCore/SwiftUIModifiers.swift +78 -0
- package/ios/WatchtowerCore/Swizzling.swift +59 -0
- package/ios/WatchtowerCore/TapEvent.swift +61 -0
- package/ios/WatchtowerCore/Transport.swift +136 -0
- package/ios/WatchtowerCore/UIViewExtensions.swift +31 -0
- package/ios/WatchtowerCore/Watchtower.swift +80 -0
- package/ios/WatchtowerCore/WatchtowerEngine.swift +565 -0
- package/ios/WatchtowerCore/WatchtowerHash.swift +120 -0
- package/ios/WatchtowerCore/WatchtowerRegistry.swift +87 -0
- package/package.json +17 -10
|
@@ -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
|
+
}
|