* feat: add ios and rn source * fix(ios): remove testing keys * fix(tracker): change default path
286 lines
10 KiB
Swift
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)
|
|
}
|
|
}
|