From 7260f00b8f09f0250be8f8bea237112697eae560 Mon Sep 17 00:00:00 2001 From: Philip Niedertscheider Date: Wed, 9 Apr 2025 11:12:26 +0200 Subject: [PATCH] add analysis tools --- .../iOS-Swift/ErrorsViewController.swift | 2 + .../iOS-Swift/SentrySDKWrapper.swift | 45 +++++- .../Tools/DSNDisplayViewController.swift | 4 +- .../TransactionsViewController.swift | 18 ++- Sentry.xcodeproj/project.pbxproj | 22 ++- .../Sentry/SentrySessionReplayIntegration.m | 13 +- .../Sentry/include/SentryDisplayLinkWrapper.m | 1 + .../Preview/SentryMaskingPreviewView.swift | 2 +- .../SessionReplay/SentryOnDemandReplay.swift | 135 +++++++++++------- .../SessionReplay/SentryReplayOptions.swift | 15 +- .../SentryReplayVideoMaker.swift | 27 +++- .../SessionReplay/SentrySessionReplay.swift | 31 +++- .../SentryDefaultMaskRenderer.swift | 3 + .../SentryExperimentalMaskRenderer.swift | 2 +- .../ViewCapture/SentryViewPhotographer.swift | 6 +- .../SentryViewScreenshotProvider.swift | 2 +- .../Tools/ViewRedaction/RedactRegion.swift | 109 ++++++++++++++ .../ViewRedaction/RedactRegionType.swift | 19 +++ .../UIRedactBuilder.swift | 97 ++++++------- .../ViewRedaction/ViewHierarchyNode.swift | 85 +++++++++++ 20 files changed, 512 insertions(+), 126 deletions(-) create mode 100644 Sources/Swift/Tools/ViewRedaction/RedactRegion.swift create mode 100644 Sources/Swift/Tools/ViewRedaction/RedactRegionType.swift rename Sources/Swift/Tools/{ViewCapture => ViewRedaction}/UIRedactBuilder.swift (87%) create mode 100644 Sources/Swift/Tools/ViewRedaction/ViewHierarchyNode.swift diff --git a/Samples/iOS-Swift/iOS-Swift/ErrorsViewController.swift b/Samples/iOS-Swift/iOS-Swift/ErrorsViewController.swift index a83e6df57fd..802236ef7e2 100644 --- a/Samples/iOS-Swift/iOS-Swift/ErrorsViewController.swift +++ b/Samples/iOS-Swift/iOS-Swift/ErrorsViewController.swift @@ -17,6 +17,8 @@ class ErrorsViewController: UIViewController { override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) + print("--> ErrorsViewController.viewDidAppear") + SentrySDK.reportFullyDisplayed() if ProcessInfo.processInfo.arguments.contains("--io.sentry.feedback.inject-screenshot") { diff --git a/Samples/iOS-Swift/iOS-Swift/SentrySDKWrapper.swift b/Samples/iOS-Swift/iOS-Swift/SentrySDKWrapper.swift index 1b7590fbef3..084e07cb176 100644 --- a/Samples/iOS-Swift/iOS-Swift/SentrySDKWrapper.swift +++ b/Samples/iOS-Swift/iOS-Swift/SentrySDKWrapper.swift @@ -19,8 +19,51 @@ struct SentrySDKWrapper { options.debug = true if #available(iOS 16.0, *), enableSessionReplay { - options.sessionReplay = SentryReplayOptions(sessionSampleRate: 0, onErrorSampleRate: 1, maskAllText: true, maskAllImages: true) + options.sessionReplay = SentryReplayOptions(sessionSampleRate: 0.0, onErrorSampleRate: 1, maskAllText: true, maskAllImages: true) options.sessionReplay.quality = .high + options.sessionReplay.frameRate = 10 + try! FileManager.default.removeItem(atPath: "/tmp/workdir") + try! FileManager.default.createDirectory(atPath: "/tmp/workdir", withIntermediateDirectories: true, attributes: nil) + var previousEncodedViewData: Data? + var counter = 0 + options.sessionReplay.onNewFrame = { _, viewHiearchy, redactRegions, renderedViewImage, maskedViewImage in + guard TransactionsViewController.isTransitioning else { return } + do { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + + let encodedViewData = try encoder.encode(viewHiearchy) + if let previousEncodedViewData = previousEncodedViewData { + if encodedViewData == previousEncodedViewData { + return + } + } + previousEncodedViewData = encodedViewData + if counter >= 2 { + return + } + counter += 1 + + let viewHiearchyPath = "/tmp/workdir/\(counter)-0_view.json" + let regionsPath = "/tmp/workdir/\(counter)-1_regions.json" + let imagePath = "/tmp/workdir/\(counter)-2_image.png" + let maskedImagePath = "/tmp/workdir/\(counter)-3_masked.png" + + try encodedViewData.write(to: URL(fileURLWithPath: viewHiearchyPath)) + + let encodedRegionsData = try encoder.encode(redactRegions) + try encodedRegionsData.write(to: URL(fileURLWithPath: regionsPath)) + + let encodedImage = renderedViewImage.pngData() + try encodedImage?.write(to: URL(fileURLWithPath: imagePath)) + + let encodedMaskedImage = maskedViewImage.pngData() + try encodedMaskedImage?.write(to: URL(fileURLWithPath: maskedImagePath)) + + } catch { + print("Could not encode redact regions. Error: \(error)") + } + } } if #available(iOS 15.0, *), enableMetricKit { diff --git a/Samples/iOS-Swift/iOS-Swift/Tools/DSNDisplayViewController.swift b/Samples/iOS-Swift/iOS-Swift/Tools/DSNDisplayViewController.swift index adeec5511e5..38910f10c51 100644 --- a/Samples/iOS-Swift/iOS-Swift/Tools/DSNDisplayViewController.swift +++ b/Samples/iOS-Swift/iOS-Swift/Tools/DSNDisplayViewController.swift @@ -15,13 +15,13 @@ class DSNDisplayViewController: UIViewController { override init(nibName nibNameOrNil: String?, bundle nibBundleOrNil: Bundle?) { super.init(nibName: nibNameOrNil, bundle: nibBundleOrNil) - + if #available(iOS 13.0, *) { view.backgroundColor = .systemFill } else { view.backgroundColor = .lightGray.withAlphaComponent(0.5) } - + label.numberOfLines = 0 label.lineBreakMode = .byCharWrapping label.textAlignment = .center diff --git a/Samples/iOS-Swift/iOS-Swift/TransactionsViewController.swift b/Samples/iOS-Swift/iOS-Swift/TransactionsViewController.swift index dfb4a833fac..b49eb0e8d61 100644 --- a/Samples/iOS-Swift/iOS-Swift/TransactionsViewController.swift +++ b/Samples/iOS-Swift/iOS-Swift/TransactionsViewController.swift @@ -2,29 +2,37 @@ import Sentry import UIKit class TransactionsViewController: UIViewController { - + static var isTransitioning = false @IBOutlet weak var appHangFullyBlockingButton: UIButton! private let dispatchQueue = DispatchQueue(label: "ViewController", attributes: .concurrent) private var timer: Timer? @IBOutlet weak var dsnView: UIView! - + override func viewDidLoad() { super.viewDidLoad() addDSNDisplay(self, vcview: dsnView) SentrySDK.reportFullyDisplayed() } - + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + print("--> TransactionsViewController.viewWillAppear") + TransactionsViewController.isTransitioning = true + } + override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) + print("--> TransactionsViewController.viewDidAppear") + TransactionsViewController.isTransitioning = false periodicallyDoWork() } - + override func viewDidDisappear(_ animated: Bool) { super .viewDidDisappear(animated) self.timer?.invalidate() } - + private func periodicallyDoWork() { self.timer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: true) { _ in diff --git a/Sentry.xcodeproj/project.pbxproj b/Sentry.xcodeproj/project.pbxproj index 7b787fedbfb..08bef33fd18 100644 --- a/Sentry.xcodeproj/project.pbxproj +++ b/Sentry.xcodeproj/project.pbxproj @@ -815,6 +815,9 @@ A8F17B342902870300990B25 /* SentryHttpStatusCodeRange.m in Sources */ = {isa = PBXBuildFile; fileRef = A8F17B332902870300990B25 /* SentryHttpStatusCodeRange.m */; }; D4009EB22D771BC20007AF30 /* SentryFileIOTrackerSwiftHelpersTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4009EB12D771BB90007AF30 /* SentryFileIOTrackerSwiftHelpersTests.swift */; }; D42E48572D48DF1600D251BC /* SentryBuildAppStartSpansTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D42E48562D48DF1600D251BC /* SentryBuildAppStartSpansTests.swift */; }; + D43546462D8C2B0B00E9C810 /* RedactRegionType.swift in Sources */ = {isa = PBXBuildFile; fileRef = D43546452D8C2B0A00E9C810 /* RedactRegionType.swift */; }; + D43546482D8C2B2300E9C810 /* RedactRegion.swift in Sources */ = {isa = PBXBuildFile; fileRef = D43546472D8C2B2300E9C810 /* RedactRegion.swift */; }; + D435464A2D8C32A500E9C810 /* ViewHierarchyNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D43546492D8C32A400E9C810 /* ViewHierarchyNode.swift */; }; D43647F12D5CFB71001468E0 /* SentrySpanKeyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D43647F02D5CFB71001468E0 /* SentrySpanKeyTests.swift */; }; D43647F32D5CFBC7001468E0 /* FileManagerTracingIntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D43647F22D5CFBC2001468E0 /* FileManagerTracingIntegrationTests.swift */; }; D43B26D62D70964C007747FD /* SentrySpanOperation.m in Sources */ = {isa = PBXBuildFile; fileRef = D43B26D52D709648007747FD /* SentrySpanOperation.m */; }; @@ -1974,6 +1977,9 @@ D41909942D490006002B83D0 /* SentryNSDictionarySanitize+Tests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "SentryNSDictionarySanitize+Tests.m"; sourceTree = ""; }; D42E48562D48DF1600D251BC /* SentryBuildAppStartSpansTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryBuildAppStartSpansTests.swift; sourceTree = ""; }; D42E48582D48FC8F00D251BC /* SentryNSDictionarySanitizeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SentryNSDictionarySanitizeTests.swift; sourceTree = ""; }; + D43546452D8C2B0A00E9C810 /* RedactRegionType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RedactRegionType.swift; sourceTree = ""; }; + D43546472D8C2B2300E9C810 /* RedactRegion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RedactRegion.swift; sourceTree = ""; }; + D43546492D8C32A400E9C810 /* ViewHierarchyNode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewHierarchyNode.swift; sourceTree = ""; }; D43647F02D5CFB71001468E0 /* SentrySpanKeyTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SentrySpanKeyTests.swift; sourceTree = ""; }; D43647F22D5CFBC2001468E0 /* FileManagerTracingIntegrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileManagerTracingIntegrationTests.swift; sourceTree = ""; }; D43B26D52D709648007747FD /* SentrySpanOperation.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = SentrySpanOperation.m; sourceTree = ""; }; @@ -3857,6 +3863,17 @@ path = ViewCapture; sourceTree = ""; }; + D43546442D8C2AFD00E9C810 /* ViewRedaction */ = { + isa = PBXGroup; + children = ( + D43546492D8C32A400E9C810 /* ViewHierarchyNode.swift */, + D43546472D8C2B2300E9C810 /* RedactRegion.swift */, + D43546452D8C2B0A00E9C810 /* RedactRegionType.swift */, + D8AFC0562BDA895400118BE1 /* UIRedactBuilder.swift */, + ); + path = ViewRedaction; + sourceTree = ""; + }; D468C0602D36699700964230 /* IO */ = { isa = PBXGroup; children = ( @@ -3913,7 +3930,6 @@ D8CAC0722BA4473000E38F34 /* SentryViewPhotographer.swift */, D4E829D52D75E39900D375AD /* SentryViewRenderer.swift */, D8AFC0192BD7A20B00118BE1 /* SentryViewScreenshotProvider.swift */, - D8AFC0562BDA895400118BE1 /* UIRedactBuilder.swift */, ); path = ViewCapture; sourceTree = ""; @@ -4071,6 +4087,7 @@ D856272A2A374A6800FB8062 /* Tools */ = { isa = PBXGroup; children = ( + D43546442D8C2AFD00E9C810 /* ViewRedaction */, D4E829DD2D75FCA200D375AD /* ViewCapture */, D856272B2A374A8600FB8062 /* UrlSanitized.swift */, D8292D7A2A38AF04009872F7 /* HTTPHeaderSanitizer.swift */, @@ -4996,6 +5013,7 @@ 7B63459F280EBA7200CFA05A /* SentryUIEventTracker.m in Sources */, 7BF9EF782722B35D00B5BBEF /* SentrySubClassFinder.m in Sources */, D80CD8D32B751447002F710B /* SentryMXCallStackTree.swift in Sources */, + D435464A2D8C32A500E9C810 /* ViewHierarchyNode.swift in Sources */, 84CFA4CD2C9E0CA3008DA5F4 /* SentryUserFeedbackIntegration.m in Sources */, 7BCFA71627D0BB50008C662C /* SentryANRTrackerV1.m in Sources */, 8459FCC02BD73EB20038E9C9 /* SentryProfilerSerialization.mm in Sources */, @@ -5066,6 +5084,7 @@ 63FE712120DA4C1000CDBAE8 /* SentryCrashSymbolicator.c in Sources */, 627C77892D50B6840055E966 /* SentryBreadcrumbCodable.swift in Sources */, 63FE70D720DA4C1000CDBAE8 /* SentryCrashMonitor_MachException.c in Sources */, + D43546462D8C2B0B00E9C810 /* RedactRegionType.swift in Sources */, 7B96572226830D2400C66E25 /* SentryScopeSyncC.c in Sources */, 0A9BF4E228A114940068D266 /* SentryViewHierarchyIntegration.m in Sources */, 0ADC33EC28D9BB780078D980 /* SentryUIDeviceWrapper.m in Sources */, @@ -5172,6 +5191,7 @@ 7BD729982463E93500EA3610 /* SentryDateUtil.m in Sources */, 639FCF9D1EBC7F9500778193 /* SentryThread.m in Sources */, D88B30A92D48D8C3008DE513 /* SentryMaskingPreviewView.swift in Sources */, + D43546482D8C2B2300E9C810 /* RedactRegion.swift in Sources */, 849B8F992C6E906900148E1F /* SentryUserFeedbackFormConfiguration.swift in Sources */, 8E8C57A225EEFC07001CEEFA /* SentrySampling.m in Sources */, 8454CF8D293EAF9A006AC140 /* SentryMetricProfiler.mm in Sources */, diff --git a/Sources/Sentry/SentrySessionReplayIntegration.m b/Sources/Sentry/SentrySessionReplayIntegration.m index 0eb4de59798..2b8aad6096d 100644 --- a/Sources/Sentry/SentrySessionReplayIntegration.m +++ b/Sources/Sentry/SentrySessionReplayIntegration.m @@ -192,6 +192,7 @@ - (void)resumePreviousSessionReplay:(SentryEvent *)event [[SentryOnDemandReplay alloc] initWithContentFrom:lastReplayURL.path]; resumeReplayMaker.bitRate = _replayOptions.replayBitRate; resumeReplayMaker.videoScale = _replayOptions.sizeScale; + resumeReplayMaker.frameRate = _replayOptions.frameRate; NSDate *beginning = hasCrashInfo ? [NSDate dateWithTimeIntervalSinceReferenceDate:crashInfo.lastSegmentEnd] @@ -319,9 +320,15 @@ - (void)startWithOptions:(SentryReplayOptions *)replayOptions SentryOnDemandReplay *replayMaker = [[SentryOnDemandReplay alloc] initWithOutputPath:docs.path]; replayMaker.bitRate = replayOptions.replayBitRate; replayMaker.videoScale = replayOptions.sizeScale; - replayMaker.cacheMaxSize - = (NSInteger)(shouldReplayFullSession ? replayOptions.sessionSegmentDuration + 1 - : replayOptions.errorReplayDuration + 1); + replayMaker.frameRate = replayOptions.frameRate; + replayMaker.onNewFrame = replayOptions.onNewFrame; + + // The cache should be at least the amount of frames fitting into he session segment duration + // plus one frame to ensure that the last frame is not dropped. + NSInteger sessionSegmentDuration + = (NSInteger)(shouldReplayFullSession ? replayOptions.sessionSegmentDuration + : replayOptions.errorReplayDuration); + replayMaker.cacheMaxSize = (sessionSegmentDuration * replayOptions.frameRate) + 1; dispatch_queue_attr_t attributes = dispatch_queue_attr_make_with_qos_class( DISPATCH_QUEUE_SERIAL, DISPATCH_QUEUE_PRIORITY_LOW, 0); diff --git a/Sources/Sentry/include/SentryDisplayLinkWrapper.m b/Sources/Sentry/include/SentryDisplayLinkWrapper.m index 29eab1a8f6e..85a721924bb 100644 --- a/Sources/Sentry/include/SentryDisplayLinkWrapper.m +++ b/Sources/Sentry/include/SentryDisplayLinkWrapper.m @@ -21,6 +21,7 @@ - (CFTimeInterval)targetTimestamp API_AVAILABLE(ios(10.0), tvos(10.0)) - (void)linkWithTarget:(id)target selector:(SEL)sel { displayLink = [CADisplayLink displayLinkWithTarget:target selector:sel]; + displayLink.preferredFramesPerSecond = 10; [displayLink addToRunLoop:[NSRunLoop mainRunLoop] forMode:NSRunLoopCommonModes]; } diff --git a/Sources/Swift/Integrations/SessionReplay/Preview/SentryMaskingPreviewView.swift b/Sources/Swift/Integrations/SessionReplay/Preview/SentryMaskingPreviewView.swift index 9757e531e0d..d90485aeb01 100644 --- a/Sources/Swift/Integrations/SessionReplay/Preview/SentryMaskingPreviewView.swift +++ b/Sources/Swift/Integrations/SessionReplay/Preview/SentryMaskingPreviewView.swift @@ -62,7 +62,7 @@ class SentryMaskingPreviewView: UIView { private func update() { guard let superview = self.superview, idle else { return } idle = false - self.photographer.image(view: superview) { image in + self.photographer.image(view: superview) { _, _, _, image in DispatchQueue.main.async { self.imageView.image = image self.idle = true diff --git a/Sources/Swift/Integrations/SessionReplay/SentryOnDemandReplay.swift b/Sources/Swift/Integrations/SessionReplay/SentryOnDemandReplay.swift index 6017a98a5f9..07e106e4dac 100644 --- a/Sources/Swift/Integrations/SessionReplay/SentryOnDemandReplay.swift +++ b/Sources/Swift/Integrations/SessionReplay/SentryOnDemandReplay.swift @@ -21,34 +21,41 @@ enum SentryOnDemandReplayError: Error { @objcMembers class SentryOnDemandReplay: NSObject, SentryReplayVideoMaker { - + private let _outputPath: String private var _totalFrames = 0 private let dateProvider: SentryCurrentDateProvider private let workingQueue: SentryDispatchQueueWrapper private var _frames = [SentryReplayFrame]() - - #if SENTRY_TEST || SENTRY_TEST_CI || DEBUG + +#if SENTRY_TEST || SENTRY_TEST_CI || DEBUG //This is exposed only for tests, no need to make it thread safe. var frames: [SentryReplayFrame] { get { _frames } set { _frames = newValue } } - #endif // SENTRY_TEST || SENTRY_TEST_CI || DEBUG +#endif // SENTRY_TEST || SENTRY_TEST_CI || DEBUG var videoScale: Float = 1 var bitRate = 20_000 var frameRate = 1 var cacheMaxSize = UInt.max - + var onNewFrame: (( + _ timestamp: Date, + _ viewHiearchy: ViewHierarchyNode, + _ redactRegions: [RedactRegion], + _ renderedViewImage: UIImage, + _ maskedViewImage: UIImage + ) -> Void)? + init(outputPath: String, workingQueue: SentryDispatchQueueWrapper, dateProvider: SentryCurrentDateProvider) { self._outputPath = outputPath self.dateProvider = dateProvider self.workingQueue = workingQueue } - + convenience init(withContentFrom outputPath: String, workingQueue: SentryDispatchQueueWrapper, dateProvider: SentryCurrentDateProvider) { self.init(outputPath: outputPath, workingQueue: workingQueue, dateProvider: dateProvider) - + do { let content = try FileManager.default.contentsOfDirectory(atPath: outputPath) _frames = content.compactMap { @@ -57,111 +64,141 @@ class SentryOnDemandReplay: NSObject, SentryReplayVideoMaker { return SentryReplayFrame(imagePath: "\(outputPath)/\($0)", time: Date(timeIntervalSinceReferenceDate: time), screenName: nil) }.sorted { $0.time < $1.time } } catch { - SentryLog.debug("Could not list frames from replay: \(error.localizedDescription)") + SentryLog.debug("[SessionReplay] Could not list frames from replay: \(error.localizedDescription)") return } } - + convenience init(outputPath: String) { self.init(outputPath: outputPath, workingQueue: SentryDispatchQueueWrapper(name: "io.sentry.onDemandReplay", attributes: nil), dateProvider: SentryDefaultCurrentDateProvider()) } - + convenience init(withContentFrom outputPath: String) { self.init(withContentFrom: outputPath, workingQueue: SentryDispatchQueueWrapper(name: "io.sentry.onDemandReplay", attributes: nil), dateProvider: SentryDefaultCurrentDateProvider()) } - - func addFrameAsync(image: UIImage, forScreen: String?) { + + @objc func addFrameAsync( + timestamp: Date, + viewHiearchy: ViewHierarchyNode, + redactRegions: [RedactRegion], + renderedViewImage: UIImage, + maskedViewImage: UIImage, + forScreen screen: String? + ) { workingQueue.dispatchAsync({ - self.addFrame(image: image, forScreen: forScreen) + self.addFrame( + timestamp: timestamp, + viewHiearchy: viewHiearchy, + redactRegions: redactRegions, + renderedViewImage: renderedViewImage, + maskedViewImage: maskedViewImage, + forScreen: screen + ) }) } - - private func addFrame(image: UIImage, forScreen: String?) { - guard let data = rescaleImage(image)?.pngData() else { return } - - let date = dateProvider.date() - let imagePath = (_outputPath as NSString).appendingPathComponent("\(date.timeIntervalSinceReferenceDate).png") + + private func addFrame( + timestamp: Date, + viewHiearchy: ViewHierarchyNode, + redactRegions: [RedactRegion], + renderedViewImage: UIImage, + maskedViewImage: UIImage, + forScreen screen: String? + ) { + guard let data = rescaleImage(maskedViewImage)?.pngData() else { return } + onNewFrame?(timestamp, viewHiearchy, redactRegions, renderedViewImage, maskedViewImage) + + let imagePath = (_outputPath as NSString).appendingPathComponent("\(timestamp.timeIntervalSinceReferenceDate).png") do { try data.write(to: URL(fileURLWithPath: imagePath)) } catch { - SentryLog.debug("Could not save replay frame. Error: \(error)") + SentryLog.debug("[SessionReplay] Could not save replay frame. Error: \(error)") return } - _frames.append(SentryReplayFrame(imagePath: imagePath, time: date, screenName: forScreen)) - + _frames.append(SentryReplayFrame(imagePath: imagePath, time: timestamp, screenName: screen)) + + // Remove oldest frame from the cache and delete the file from disk if reaching the limit while _frames.count > cacheMaxSize { - let first = _frames.removeFirst() - try? FileManager.default.removeItem(at: URL(fileURLWithPath: first.imagePath)) + do { + let first = _frames.removeFirst() + try FileManager.default.removeItem(at: URL(fileURLWithPath: first.imagePath)) + } catch { + SentryLog.debug("[SessionReplay] Could not delete oldest session replay frame in cache. Error: \(error)") + } } _totalFrames += 1 } - + private func rescaleImage(_ originalImage: UIImage) -> UIImage? { guard originalImage.scale > 1 else { return originalImage } - + UIGraphicsBeginImageContextWithOptions(originalImage.size, false, 1) defer { UIGraphicsEndImageContext() } - + originalImage.draw(in: CGRect(origin: .zero, size: originalImage.size)) return UIGraphicsGetImageFromCurrentImageContext() } - + func releaseFramesUntil(_ date: Date) { + SentryLog.debug("[SessionReplay] Releasing frames until timestamp: \(date)") workingQueue.dispatchAsync ({ while let first = self._frames.first, first.time < date { + SentryLog.debug("Releasing frame: \(first.time), path: \(first.imagePath)") self._frames.removeFirst() try? FileManager.default.removeItem(at: URL(fileURLWithPath: first.imagePath)) } }) } - + var oldestFrameDate: Date? { return _frames.first?.time } - + func createVideoWith(beginning: Date, end: Date) throws -> [SentryVideoInfo] { + SentryLog.debug("[SessionReplay] Creating videos from: \(beginning), to: \(end)") let videoFrames = filterFrames(beginning: beginning, end: end) var frameCount = 0 - + var videos = [SentryVideoInfo]() - + while frameCount < videoFrames.count { let outputFileURL = URL(fileURLWithPath: _outputPath.appending("/\(videoFrames[frameCount].time.timeIntervalSinceReferenceDate).mp4")) if let videoInfo = try renderVideo(with: videoFrames, from: &frameCount, at: outputFileURL) { videos.append(videoInfo) } else { frameCount++ - } + } } + SentryLog.debug("[SessionReplay] Created \(videos.count) videos") return videos } - + private func renderVideo(with videoFrames: [SentryReplayFrame], from: inout Int, at outputFileURL: URL) throws -> SentryVideoInfo? { guard from < videoFrames.count, let image = UIImage(contentsOfFile: videoFrames[from].imagePath) else { return nil } let videoWidth = image.size.width * CGFloat(videoScale) let videoHeight = image.size.height * CGFloat(videoScale) - + let videoWriter = try AVAssetWriter(url: outputFileURL, fileType: .mp4) let videoWriterInput = AVAssetWriterInput(mediaType: .video, outputSettings: createVideoSettings(width: videoWidth, height: videoHeight)) - + guard let currentPixelBuffer = SentryPixelBuffer(size: CGSize(width: videoWidth, height: videoHeight), videoWriterInput: videoWriterInput) else { throw SentryOnDemandReplayError.cantCreatePixelBuffer } - + videoWriter.add(videoWriterInput) videoWriter.startWriting() videoWriter.startSession(atSourceTime: .zero) - + var lastImageSize: CGSize = image.size var usedFrames = [SentryReplayFrame]() let group = DispatchGroup() - + var result: Result? var frameCount = from - + group.enter() videoWriterInput.requestMediaDataWhenReady(on: workingQueue.queue) { guard videoWriter.status == .writing else { @@ -183,8 +220,10 @@ class SentryOnDemandReplay: NSObject, SentryReplayVideoMaker { return } lastImageSize = image.size - - let presentTime = CMTime(seconds: Double(frameCount), preferredTimescale: CMTimeScale(1 / self.frameRate)) + + // Calculate the time of the frame based on the frame rate + let timePerFrame = CMTimeMake(value: 1, timescale: Int32(self.frameRate)) + let presentTime = CMTimeMultiply(timePerFrame, multiplier: Int32(frameCount)) if currentPixelBuffer.append(image: image, presentationTime: presentTime) != true { videoWriter.cancelWriting() result = .failure(videoWriter.error ?? SentryOnDemandReplayError.errorRenderingVideo ) @@ -197,15 +236,15 @@ class SentryOnDemandReplay: NSObject, SentryReplayVideoMaker { } guard group.wait(timeout: .now() + 2) == .success else { throw SentryOnDemandReplayError.errorRenderingVideo } from = frameCount - + return try result?.get() } - + private func finishVideo(outputFileURL: URL, usedFrames: [SentryReplayFrame], videoHeight: Int, videoWidth: Int, videoWriter: AVAssetWriter) -> Result { let group = DispatchGroup() var finishError: Error? var result: SentryVideoInfo? - + group.enter() videoWriter.inputs.forEach { $0.markAsFinished() } videoWriter.finishWriting { @@ -226,11 +265,11 @@ class SentryOnDemandReplay: NSObject, SentryReplayVideoMaker { } } group.wait() - + if let finishError = finishError { return .failure(finishError) } return .success(result) } - + private func filterFrames(beginning: Date, end: Date) -> [SentryReplayFrame] { var frames = [SentryReplayFrame]() //Using dispatch queue as sync mechanism since we need a queue already to generate the video. @@ -239,7 +278,7 @@ class SentryOnDemandReplay: NSObject, SentryReplayVideoMaker { }) return frames } - + private func createVideoSettings(width: CGFloat, height: CGFloat) -> [String: Any] { return [ AVVideoCodecKey: AVVideoCodecType.h264, diff --git a/Sources/Swift/Integrations/SessionReplay/SentryReplayOptions.swift b/Sources/Swift/Integrations/SessionReplay/SentryReplayOptions.swift index ba81e9aeadd..cae1d0fcf17 100644 --- a/Sources/Swift/Integrations/SessionReplay/SentryReplayOptions.swift +++ b/Sources/Swift/Integrations/SessionReplay/SentryReplayOptions.swift @@ -1,5 +1,8 @@ @_implementationOnly import _SentryPrivate import Foundation +#if canImport(UIKit) +import UIKit +#endif @objcMembers public class SentryReplayOptions: NSObject, SentryRedactOptions { @@ -140,6 +143,16 @@ public class SentryReplayOptions: NSObject, SentryRedactOptions { */ public var enableFastViewRendering = false + #if canImport(UIKit) + public var onNewFrame: (( + _ timestamp: Date, + _ viewHiearchy: ViewHierarchyNode, + _ redactRegions: [RedactRegion], + _ renderedViewImage: UIImage, + _ maskedViewImage: UIImage + ) -> Void)? + #endif + /** * Defines the quality of the session replay. * Higher bit rates better quality, but also bigger files to transfer. @@ -160,7 +173,7 @@ public class SentryReplayOptions: NSObject, SentryRedactOptions { * The more the havier the process is. * The minimum is 1, if set to zero this will change to 1. */ - var frameRate: UInt = 1 { + public var frameRate: UInt = 1 { didSet { if frameRate < 1 { frameRate = 1 } } diff --git a/Sources/Swift/Integrations/SessionReplay/SentryReplayVideoMaker.swift b/Sources/Swift/Integrations/SessionReplay/SentryReplayVideoMaker.swift index 32831f38642..5a55ca732a7 100644 --- a/Sources/Swift/Integrations/SessionReplay/SentryReplayVideoMaker.swift +++ b/Sources/Swift/Integrations/SessionReplay/SentryReplayVideoMaker.swift @@ -4,14 +4,35 @@ import UIKit @objc protocol SentryReplayVideoMaker: NSObjectProtocol { - func addFrameAsync(image: UIImage, forScreen: String?) + func addFrameAsync( + timestamp: Date, + viewHiearchy: ViewHierarchyNode, + redactRegions: [RedactRegion], + renderedViewImage: UIImage, + maskedViewImage: UIImage, + forScreen screen: String? + ) func releaseFramesUntil(_ date: Date) func createVideoWith(beginning: Date, end: Date) throws -> [SentryVideoInfo] } extension SentryReplayVideoMaker { - func addFrameAsync(image: UIImage) { - self.addFrameAsync(image: image, forScreen: nil) + func addFrameAsync( + timestamp: Date, + viewHiearchy: ViewHierarchyNode, + redactRegions: [RedactRegion], + renderedViewImage: UIImage, + maskedViewImage: UIImage, + forScreen screen: String? + ) { + self.addFrameAsync( + timestamp: timestamp, + viewHiearchy: viewHiearchy, + redactRegions: redactRegions, + renderedViewImage: renderedViewImage, + maskedViewImage: maskedViewImage, + forScreen: screen + ) } } diff --git a/Sources/Swift/Integrations/SessionReplay/SentrySessionReplay.swift b/Sources/Swift/Integrations/SessionReplay/SentrySessionReplay.swift index 49e856871ab..521fbf4b426 100644 --- a/Sources/Swift/Integrations/SessionReplay/SentrySessionReplay.swift +++ b/Sources/Swift/Integrations/SessionReplay/SentrySessionReplay.swift @@ -311,17 +311,38 @@ class SentrySessionReplay: NSObject { processingScreenshot = true lock.unlock() + let timestamp = dateProvider.date() let screenName = delegate?.currentScreenNameForSessionReplay() - - screenshotProvider.image(view: rootView) { [weak self] screenshot in - self?.newImage(image: screenshot, forScreen: screenName) + screenshotProvider.image(view: rootView) { [weak self] viewHiearchy, redactRegions, renderedViewImage, maskedViewImage in + self?.newImage( + timestamp: timestamp, + viewHiearchy: viewHiearchy, + redactRegions: redactRegions, + renderedViewImage: renderedViewImage, + maskedViewImage: maskedViewImage, + forScreen: screenName + ) } } - private func newImage(image: UIImage, forScreen screen: String?) { + private func newImage( + timestamp: Date, + viewHiearchy: ViewHierarchyNode, + redactRegions: [RedactRegion], + renderedViewImage: UIImage, + maskedViewImage: UIImage, + forScreen screen: String? + ) { lock.synchronized { processingScreenshot = false - replayMaker.addFrameAsync(image: image, forScreen: screen) + replayMaker.addFrameAsync( + timestamp: timestamp, + viewHiearchy: viewHiearchy, + redactRegions: redactRegions, + renderedViewImage: renderedViewImage, + maskedViewImage: maskedViewImage, + forScreen: screen + ) } } } diff --git a/Sources/Swift/Tools/ViewCapture/SentryDefaultMaskRenderer.swift b/Sources/Swift/Tools/ViewCapture/SentryDefaultMaskRenderer.swift index ba7f6ee085e..47d79bdb280 100644 --- a/Sources/Swift/Tools/ViewCapture/SentryDefaultMaskRenderer.swift +++ b/Sources/Swift/Tools/ViewCapture/SentryDefaultMaskRenderer.swift @@ -48,6 +48,9 @@ class SentryDefaultMaskRenderer: NSObject, SentryMaskRenderer { self.updateClipping(for: context.cgContext, clipPaths: clipPaths, clipOutPath: clipOutPath) + UIColor(red: 0.0, green: 1.0, blue: 0.0, alpha: 0.5).setFill() + context.cgContext.addPath(path) + context.cgContext.fillPath() case .clipBegin: clipPaths.append(path) self.updateClipping(for: context.cgContext, diff --git a/Sources/Swift/Tools/ViewCapture/SentryExperimentalMaskRenderer.swift b/Sources/Swift/Tools/ViewCapture/SentryExperimentalMaskRenderer.swift index 2cf68171276..be4daf5c50e 100644 --- a/Sources/Swift/Tools/ViewCapture/SentryExperimentalMaskRenderer.swift +++ b/Sources/Swift/Tools/ViewCapture/SentryExperimentalMaskRenderer.swift @@ -7,7 +7,7 @@ class SentryExperimentalMaskRenderer: SentryDefaultMaskRenderer { override func maskScreenshot(screenshot image: UIImage, size: CGSize, masking: [RedactRegion]) -> UIImage { // The `SentryDefaultMaskRenderer` is also using an display scale of 1, therefore we also use 1 here. // This could be evaluated in future iterations to view performance impact vs quality. - let image = SentryGraphicsImageRenderer(size: size, scale: 1).image { context in + let image = SentryGraphicsImageRenderer(size: size, scale: 2).image { context in // The experimental mask renderer only uses a different graphics renderer and can reuse the default masking logic. applyMasking(to: context, image: image, size: size, masking: masking) } diff --git a/Sources/Swift/Tools/ViewCapture/SentryViewPhotographer.swift b/Sources/Swift/Tools/ViewCapture/SentryViewPhotographer.swift index 481078a32d0..ec6bfad94e6 100644 --- a/Sources/Swift/Tools/ViewCapture/SentryViewPhotographer.swift +++ b/Sources/Swift/Tools/ViewCapture/SentryViewPhotographer.swift @@ -35,7 +35,7 @@ class SentryViewPhotographer: NSObject, SentryViewScreenshotProvider { func image(view: UIView, onComplete: @escaping ScreenshotCallback) { let viewSize = view.bounds.size - let redact = redactBuilder.redactRegionsFor(view: view) + let (viewHiearchy, redact) = redactBuilder.redactRegionsFor(view: view) // The render method is synchronous and must be called on the main thread. // This is because the render method accesses the view hierarchy which is managed from the main thread. let renderedScreenshot = renderer.render(view: view) @@ -45,13 +45,13 @@ class SentryViewPhotographer: NSObject, SentryViewScreenshotProvider { // Moving it to a background thread to avoid blocking the main thread, therefore reducing the performance // impact/lag of the user interface. let maskedScreenshot = maskRenderer.maskScreenshot(screenshot: renderedScreenshot, size: viewSize, masking: redact) - onComplete(maskedScreenshot) + onComplete(viewHiearchy, redact, renderedScreenshot, maskedScreenshot) } } func image(view: UIView) -> UIImage { let viewSize = view.bounds.size - let redact = redactBuilder.redactRegionsFor(view: view) + let (_, redact) = redactBuilder.redactRegionsFor(view: view) let renderedScreenshot = renderer.render(view: view) let maskedScreenshot = maskRenderer.maskScreenshot(screenshot: renderedScreenshot, size: viewSize, masking: redact) diff --git a/Sources/Swift/Tools/ViewCapture/SentryViewScreenshotProvider.swift b/Sources/Swift/Tools/ViewCapture/SentryViewScreenshotProvider.swift index 0b99bc1bffb..ec544185606 100644 --- a/Sources/Swift/Tools/ViewCapture/SentryViewScreenshotProvider.swift +++ b/Sources/Swift/Tools/ViewCapture/SentryViewScreenshotProvider.swift @@ -3,7 +3,7 @@ import Foundation import UIKit -typealias ScreenshotCallback = (UIImage) -> Void +typealias ScreenshotCallback = (_ viewHierarchy: ViewHierarchyNode, _ redactRegions: [RedactRegion], _ renderedViewImage: UIImage, _ maskedViewImage: UIImage) -> Void @objc protocol SentryViewScreenshotProvider: NSObjectProtocol { diff --git a/Sources/Swift/Tools/ViewRedaction/RedactRegion.swift b/Sources/Swift/Tools/ViewRedaction/RedactRegion.swift new file mode 100644 index 00000000000..017bb540ac4 --- /dev/null +++ b/Sources/Swift/Tools/ViewRedaction/RedactRegion.swift @@ -0,0 +1,109 @@ +#if canImport(UIKit) && !SENTRY_NO_UIKIT +#if os(iOS) || os(tvOS) +import Foundation +import ObjectiveC.NSObjCRuntime +import UIKit + +@objc public class RedactRegion: NSObject, Encodable { + enum CodingKeys: CodingKey { + case size + case transform + case type + case color + case name + } + + public let size: CGSize + public let transform: CGAffineTransform + public let type: RedactRegionType + public let color: UIColor? + public let name: String + + init(size: CGSize, transform: CGAffineTransform, type: RedactRegionType, color: UIColor? = nil, name: String) { + self.size = size + self.transform = transform + self.type = type + self.color = color + self.name = name + super.init() + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(size, forKey: .size) + try container.encode(transform, forKey: .transform) + try container.encode(type, forKey: .type) + try container.encode(UIColorBox(color), forKey: .color) + try container.encode(name, forKey: .name) + } + + func canReplace(as other: RedactRegion) -> Bool { + size == other.size && transform == other.transform && type == other.type + } +} + +private struct UIColorBox: Codable { + let color: UIColor? + + init(_ color: UIColor?) { + self.color = color + } + + init(from decoder: any Decoder) throws { + let container = try decoder.singleValueContainer() + let cgColorBox = try container.decode(CGColorBox.self) + if let cgColor = cgColorBox.cgColor { + color = UIColor(cgColor: cgColor) + } else { + color = nil + } + } + + func encode(to encoder: any Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(CGColorBox(color?.cgColor)) + } +} + +private struct CGColorBox: Codable { + let cgColor: CGColor? + + init(_ cgColor: CGColor?) { + self.cgColor = cgColor + } + + init(from decoder: any Decoder) throws { + let container = try decoder.singleValueContainer() + let colorData = try container.decode(CGColorData.self) + + guard let colorSpace = CGColorSpace(name: colorData.colorSpaceName as CFString), + let cgColor = CGColor(colorSpace: colorSpace, components: colorData.components) else { + self.cgColor = nil + return + } + + self.cgColor = cgColor + } + + func encode(to encoder: any Encoder) throws { + var container = encoder.singleValueContainer() + + guard let cgColor = cgColor, + let colorSpaceName = cgColor.colorSpace?.name as String?, + let components = cgColor.components else { + try container.encodeNil() + return + } + + let colorData = CGColorData(components: components, colorSpaceName: colorSpaceName) + try container.encode(colorData) + } +} + +private struct CGColorData: Codable { + let components: [CGFloat] + let colorSpaceName: String +} + +#endif +#endif diff --git a/Sources/Swift/Tools/ViewRedaction/RedactRegionType.swift b/Sources/Swift/Tools/ViewRedaction/RedactRegionType.swift new file mode 100644 index 00000000000..100dcf7076e --- /dev/null +++ b/Sources/Swift/Tools/ViewRedaction/RedactRegionType.swift @@ -0,0 +1,19 @@ +public enum RedactRegionType: String, Codable { + /// Redacts the region. + case redact = "redact" + + /// Marks a region to not draw anything. + /// This is used for opaque views. + case clipOut = "clip_out" + + /// Push a clip region to the drawing context. + /// This is used for views that clip to its bounds. + case clipBegin = "clip_begin" + + /// Pop the last Pushed region from the drawing context. + /// Used after prossing every child of a view that clip to its bounds. + case clipEnd = "clip_end" + + /// These regions are redacted first, there is no way to avoid it. + case redactSwiftUI = "redact_swiftui" +} diff --git a/Sources/Swift/Tools/ViewCapture/UIRedactBuilder.swift b/Sources/Swift/Tools/ViewRedaction/UIRedactBuilder.swift similarity index 87% rename from Sources/Swift/Tools/ViewCapture/UIRedactBuilder.swift rename to Sources/Swift/Tools/ViewRedaction/UIRedactBuilder.swift index 3db19ce67b8..f6313edc12e 100644 --- a/Sources/Swift/Tools/ViewCapture/UIRedactBuilder.swift +++ b/Sources/Swift/Tools/ViewRedaction/UIRedactBuilder.swift @@ -7,44 +7,6 @@ import UIKit import WebKit #endif -enum RedactRegionType { - /// Redacts the region. - case redact - - /// Marks a region to not draw anything. - /// This is used for opaque views. - case clipOut - - /// Push a clip region to the drawing context. - /// This is used for views that clip to its bounds. - case clipBegin - - /// Pop the last Pushed region from the drawing context. - /// Used after prossing every child of a view that clip to its bounds. - case clipEnd - - /// These regions are redacted first, there is no way to avoid it. - case redactSwiftUI -} - -struct RedactRegion { - let size: CGSize - let transform: CGAffineTransform - let type: RedactRegionType - let color: UIColor? - - init(size: CGSize, transform: CGAffineTransform, type: RedactRegionType, color: UIColor? = nil) { - self.size = size - self.transform = transform - self.type = type - self.color = color - } - - func canReplace(as other: RedactRegion) -> Bool { - size == other.size && transform == other.transform && type == other.type - } -} - class UIRedactBuilder { ///This is a wrapper which marks it's direct children to be ignored private var ignoreContainerClassIdentifier: ObjectIdentifier? @@ -178,10 +140,10 @@ class UIRedactBuilder { This function returns the redaction regions in reverse order from what was found in the view hierarchy, allowing the processing of regions from top to bottom. This ensures that clip regions are applied first before drawing a redact mask on lower views. */ - func redactRegionsFor(view: UIView) -> [RedactRegion] { + func redactRegionsFor(view: UIView) -> (ViewHierarchyNode, [RedactRegion]) { var redactingRegions = [RedactRegion]() - self.mapRedactRegion(fromLayer: view.layer.presentation() ?? view.layer, + let node = self.mapRedactRegion(fromLayer: view.layer.presentation() ?? view.layer, relativeTo: nil, redacting: &redactingRegions, rootFrame: view.frame, @@ -199,7 +161,7 @@ class UIRedactBuilder { } //The swiftUI type needs to appear first in the list so it always get masked - return (otherRegions + swiftUIRedact).reversed() + return (node, (otherRegions + swiftUIRedact).reversed()) } private func shouldIgnore(view: UIView) -> Bool { @@ -238,9 +200,11 @@ class UIRedactBuilder { return image.imageAsset?.value(forKey: "_containingBundle") == nil } - private func mapRedactRegion(fromLayer layer: CALayer, relativeTo parentLayer: CALayer?, redacting: inout [RedactRegion], rootFrame: CGRect, transform: CGAffineTransform, forceRedact: Bool = false) { - guard !redactClassesIdentifiers.isEmpty && !layer.isHidden && layer.opacity != 0, let view = layer.delegate as? UIView else { return } - + private func mapRedactRegion(fromLayer layer: CALayer, relativeTo parentLayer: CALayer?, redacting: inout [RedactRegion], rootFrame: CGRect, transform: CGAffineTransform, forceRedact: Bool = false) -> ViewHierarchyNode { + let node = ViewHierarchyNode(layer: layer) + guard !redactClassesIdentifiers.isEmpty && !layer.isHidden && layer.opacity != 0, let view = layer.delegate as? UIView else { + return node + } let newTransform = concatenateTranform(transform, from: layer, withParent: parentLayer) let ignore = !forceRedact && shouldIgnore(view: view) @@ -249,9 +213,17 @@ class UIRedactBuilder { var enforceRedact = forceRedact if !ignore && redact { - redacting.append(RedactRegion(size: layer.bounds.size, transform: newTransform, type: swiftUI ? .redactSwiftUI : .redact, color: self.color(for: view))) + redacting.append(RedactRegion( + size: layer.bounds.size, + transform: newTransform, + type: swiftUI ? .redactSwiftUI : .redact, + color: self.color(for: view), + name: layer.debugDescription + )) - guard !view.clipsToBounds else { return } + guard !view.clipsToBounds else { + return node + } enforceRedact = true } else if isOpaque(view) { let finalViewFrame = CGRect(origin: .zero, size: layer.bounds.size).applying(newTransform) @@ -259,23 +231,46 @@ class UIRedactBuilder { //Because the current view is covering everything we found so far we can clear `redacting` list redacting.removeAll() } else { - redacting.append(RedactRegion(size: layer.bounds.size, transform: newTransform, type: .clipOut)) + let image = SentryGraphicsImageRenderer(size: view.bounds.size, scale: 1).image { _ in + view.drawHierarchy(in: view.bounds, afterScreenUpdates: false) + } + print(image) + redacting.append(RedactRegion( + size: layer.bounds.size, + transform: newTransform, + type: .clipOut, + name: layer.debugDescription + )) } } - guard let subLayers = layer.sublayers, subLayers.count > 0 else { return } + guard let subLayers = layer.sublayers, subLayers.count > 0 else { + return node + } let clipToBounds = view.clipsToBounds if clipToBounds { /// Because the order in which we process the redacted regions is reversed, we add the end of the clip region first. /// The beginning will be added after all the subviews have been mapped. - redacting.append(RedactRegion(size: layer.bounds.size, transform: newTransform, type: .clipEnd)) + redacting.append(RedactRegion( + size: layer.bounds.size, + transform: newTransform, + type: .clipEnd, + name: layer.debugDescription + )) } for subLayer in subLayers.sorted(by: { $0.zPosition < $1.zPosition }) { - mapRedactRegion(fromLayer: subLayer, relativeTo: layer, redacting: &redacting, rootFrame: rootFrame, transform: newTransform, forceRedact: enforceRedact) + let child = mapRedactRegion(fromLayer: subLayer, relativeTo: layer, redacting: &redacting, rootFrame: rootFrame, transform: newTransform, forceRedact: enforceRedact) + node.children.append(child) } if clipToBounds { - redacting.append(RedactRegion(size: layer.bounds.size, transform: newTransform, type: .clipBegin)) + redacting.append(RedactRegion( + size: layer.bounds.size, + transform: newTransform, + type: .clipBegin, + name: layer.debugDescription + )) } + return node } /** diff --git a/Sources/Swift/Tools/ViewRedaction/ViewHierarchyNode.swift b/Sources/Swift/Tools/ViewRedaction/ViewHierarchyNode.swift new file mode 100644 index 00000000000..cbd1606d028 --- /dev/null +++ b/Sources/Swift/Tools/ViewRedaction/ViewHierarchyNode.swift @@ -0,0 +1,85 @@ +import QuartzCore + +@objc public class ViewHierarchyNode: NSObject, Encodable { + enum CodingKeys: CodingKey { + case layer + case children + } + + public var layer: CALayer? + public var children: [ViewHierarchyNode] + + init(layer: CALayer?, children: [ViewHierarchyNode] = []) { + self.layer = layer + self.children = children + super.init() + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(children, forKey: .children) + if let layer = layer { + try container.encode(CALayerBox(layer), forKey: .layer) + } + } + + public static func == (lhs: ViewHierarchyNode, rhs: ViewHierarchyNode) -> Bool { + if !lhs.children.elementsEqual(rhs.children) { + return false + } + if let lhsLayer = lhs.layer, let rhsLayer = rhs.layer { + return CALayerBox(lhsLayer) == CALayerBox(rhsLayer) + } + return lhs.layer == nil && rhs.layer == nil + } +} + +struct CALayerBox: Encodable, Equatable { + enum CodingKeys: CodingKey { + case description + case frame + case delegateType + case type + case customTag + } + + let layer: CALayer + + init(_ layer: CALayer) { + self.layer = layer + } + + func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(type(of: layer).description(), forKey: .type) + try container.encode(String(describing: layer.delegate), forKey: .delegateType) + try container.encode(layer.frame, forKey: .frame) + try container.encode(layer.customTag, forKey: .customTag) + } + + static func == (lhs: CALayerBox, rhs: CALayerBox) -> Bool { + if type(of: lhs.layer).description() != type(of: rhs.layer).description() { + return false + } + if String(describing: type(of: lhs.layer.delegate)) != String(describing: type(of: rhs.layer.delegate)) { + return false + } + if lhs.layer.frame != rhs.layer.frame { + return false + } + return true + } +} + +public extension CALayer { + static let customTagAssociationKey = UnsafeRawPointer(bitPattern: "customTagAssociationKey".hashValue)! + + var customTag: String? { + get { + objc_getAssociatedObject(self, CALayer.customTagAssociationKey) as? String + } + set { + objc_setAssociatedObject(self, CALayer.customTagAssociationKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + } + } +}