openreplay/tracker/tracker-ios/Sources/ORTracker/ScreenCapture/ScreenCapture.swift
Delirium c0da34c528
feat(ios): add Ios source (#1640)
* feat: add ios and rn source

* fix(ios): remove testing keys

* fix(tracker): change default path
2023-11-10 10:32:55 +01:00

286 lines
10 KiB
Swift

import UIKit
import Foundation
import SwiftUI
import SWCompression
// MARK: - screenshot manager
open class ScreenshotManager {
public static let shared = ScreenshotManager()
private let messagesQueue = OperationQueue()
private var timer: Timer?
private var sendTimer: Timer?
private var sanitizedElements: [Sanitizable] = []
private var observedInputs: [UITextField] = []
private var screenshots: [Data] = []
private var lastIndex = 0
// MARK: capture settings
// should we blur out sensitive views, or place a solid box on top
private var isBlurMode = true
private var blurRadius = 2.5
// this affects how big the image will be compared to real phone screan.
// we also can use default UIScreen.main.scale which is around 3.0 (dense pixel screen)
private var screenScale = 1.25
private var settings: (captureRate: Double, imgCompression: Double) = (captureRate: 0.33, imgCompression: 0.5)
private init() { }
func start() {
startTakingScreenshots(every: settings.captureRate)
}
func setSettings(settings: (captureRate: Double, imgCompression: Double)) {
self.settings = settings
}
func stop() {
timer?.invalidate()
timer = nil
lastIndex = 0
screenshots.removeAll()
}
func startTakingScreenshots(every interval: TimeInterval) {
takeScreenshot()
timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in
self?.takeScreenshot()
}
}
public func addSanitizedElement(_ element: Sanitizable) {
#if DEBUG
DebugUtils.log("called add")
#endif
sanitizedElements.append(element)
}
public func removeSanitizedElement(_ element: Sanitizable) {
#if DEBUG
DebugUtils.log("called remove")
#endif
sanitizedElements.removeAll { $0 as AnyObject === element as AnyObject }
}
// MARK: - UI Capturing
func takeScreenshot() {
let window = UIApplication.shared.windows.first { $0.isKeyWindow }
let size = window?.frame.size ?? CGSize.zero
UIGraphicsBeginImageContextWithOptions(size, false, screenScale)
guard let context = UIGraphicsGetCurrentContext() else { return }
// Rendering current window in custom context
// 2nd option looks to be more precise
// window?.layer.render(in: context)
#warning("Can slow down the app depending on complexity of the UI tree")
window?.drawHierarchy(in: window?.bounds ?? CGRect.zero, afterScreenUpdates: false)
// MARK: sanitize
// Sanitizing sensitive elements
if isBlurMode {
let stripeWidth: CGFloat = 5.0
let stripeSpacing: CGFloat = 15.0
let stripeColor: UIColor = .gray.withAlphaComponent(0.7)
for element in sanitizedElements {
if let frame = element.frameInWindow {
let totalWidth = frame.size.width
let totalHeight = frame.size.height
let convertedFrame = CGRect(
x: frame.origin.x,
y: frame.origin.y,
width: frame.size.width,
height: frame.size.height
)
let cropFrame = CGRect(
x: frame.origin.x * screenScale,
y: frame.origin.y * screenScale,
width: frame.size.width * screenScale,
height: frame.size.height * screenScale
)
if let regionImage = UIGraphicsGetImageFromCurrentImageContext()?.cgImage?.cropping(to: cropFrame) {
let imageToBlur = UIImage(cgImage: regionImage, scale: screenScale, orientation: .up)
let blurredImage = imageToBlur.applyBlurWithRadius(blurRadius)
blurredImage?.draw(in: convertedFrame)
context.saveGState()
UIRectClip(convertedFrame)
// Draw diagonal lines within the clipped region
for x in stride(from: -totalHeight, to: totalWidth, by: stripeSpacing + stripeWidth) {
context.move(to: CGPoint(x: x + convertedFrame.minX, y: convertedFrame.minY))
context.addLine(to: CGPoint(x: x + totalHeight + convertedFrame.minX, y: totalHeight + convertedFrame.minY))
}
context.setLineWidth(stripeWidth)
stripeColor.setStroke()
context.strokePath()
context.restoreGState()
#if DEBUG
context.setStrokeColor(UIColor.black.cgColor)
context.setLineWidth(1)
context.stroke(convertedFrame)
#endif
}
} else {
removeSanitizedElement(element)
}
}
} else {
context.setFillColor(UIColor.blue.cgColor)
for element in sanitizedElements {
if let frame = element.frameInWindow {
context.fill(frame)
}
}
}
// Get the resulting image
if let image = UIGraphicsGetImageFromCurrentImageContext() {
if let compressedData = image.jpegData(compressionQuality: self.settings.imgCompression) {
screenshots.append(compressedData)
if screenshots.count >= 10 {
self.sendScreenshots()
}
}
}
UIGraphicsEndImageContext()
}
// Not using this because no idea how to sync it with the replay fps rn
//func onError() {
// takeScreenshot()
//}
// MARK: - sending screenshots
func sendScreenshots() {
guard let sessionId = NetworkManager.shared.sessionId else {
return
}
let localFilePath = "/Users/nikitamelnikov/Desktop/session/"
let desktopURL = URL(fileURLWithPath: localFilePath)
var archiveName = "\(sessionId)-\(String(format: "%06d", self.lastIndex)).tar.gz"
let archiveURL = desktopURL.appendingPathComponent(archiveName)
// Ensure the directory exists
let fileManager = FileManager.default
if !fileManager.fileExists(atPath: localFilePath) {
try? fileManager.createDirectory(at: desktopURL, withIntermediateDirectories: true, attributes: nil)
}
var combinedData = Data()
let images = screenshots
for (index, imageData) in screenshots.enumerated() {
combinedData.append(imageData)
#if DEBUG
let filename = "\(lastIndex)_\(index).jpeg"
let fileURL = desktopURL.appendingPathComponent(filename)
do {
try imageData.write(to: fileURL)
} catch {
DebugUtils.log("Unexpected error: \(error).")
}
#endif
}
#if DEBUG
DebugUtils.log("saved image files in \(localFilePath)")
#endif
messagesQueue.addOperation {
var entries: [TarEntry] = []
for imageData in images {
let filename = "\(String(format: "%06d", self.lastIndex)).jpeg"
var tarEntry = TarContainer.Entry(info: .init(name: filename, type: .regular), data: imageData)
tarEntry.info.permissions = Permissions(rawValue: 420)
tarEntry.info.creationTime = Date()
tarEntry.info.modificationTime = Date()
entries.append(tarEntry)
self.lastIndex+=1
}
do {
let gzData = try GzipArchive.archive(data: TarContainer.create(from: entries))
#if DEBUG
try gzData.write(to: archiveURL)
DebugUtils.log("Archive saved to \(archiveURL.path)")
MessageCollector.shared.sendImagesBatch(batch: gzData, fileName: archiveName)
#else
MessageCollector.shared.sendImagesBatch(batch: gzData, fileName: archiveName)
#endif
} catch {
DebugUtils.log("Error writing tar.gz data: \(error)")
}
}
screenshots.removeAll()
}
}
// MARK: making extensions for UI
struct SensitiveViewWrapperRepresentable: UIViewRepresentable {
@Binding var viewWrapper: SensitiveViewWrapper?
func makeUIView(context: Context) -> SensitiveViewWrapper {
let wrapper = SensitiveViewWrapper()
viewWrapper = wrapper
return wrapper
}
func updateUIView(_ uiView: SensitiveViewWrapper, context: Context) { }
}
struct SensitiveModifier: ViewModifier {
@State private var viewWrapper: SensitiveViewWrapper?
func body(content: Content) -> some View {
content
.background(SensitiveViewWrapperRepresentable(viewWrapper: $viewWrapper))
}
}
public extension View {
func sensitive() -> some View {
self.modifier(SensitiveModifier())
}
}
class SensitiveViewWrapper: UIView {
override func didMoveToSuperview() {
super.didMoveToSuperview()
if self.superview != nil {
ScreenshotManager.shared.addSanitizedElement(self)
} else {
ScreenshotManager.shared.removeSanitizedElement(self)
}
}
}
class SensitiveTextField: UITextField {
override func didMoveToWindow() {
super.didMoveToWindow()
if self.window != nil {
ScreenshotManager.shared.addSanitizedElement(self)
} else {
ScreenshotManager.shared.removeSanitizedElement(self)
}
}
}
// Protocol to make a UIView sanitizable
public protocol Sanitizable {
var frameInWindow: CGRect? { get }
}
func getCaptureSettings(for quality: RecordingQuality) -> (captureRate: Double, imgCompression: Double) {
switch quality {
case .Low:
return (captureRate: 1, imgCompression: 0.4)
case .Standard:
return (captureRate: 0.33, imgCompression: 0.5)
case .High:
return (captureRate: 0.20, imgCompression: 0.55)
}
}