-
Notifications
You must be signed in to change notification settings - Fork 43
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
chore: screenshot masking #146
Changes from 6 commits
eb58c21
d7b270c
6b1e068
1f375f5
fef79d7
43e1eb8
211d338
f297967
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -22,6 +22,11 @@ | |
private let urlInterceptor: URLSessionInterceptor | ||
private var sessionSwizzler: URLSessionSwizzler? | ||
|
||
// SwiftUI image types | ||
private let swiftUIImageTypes = ["_TtCOCV7SwiftUI11DisplayList11ViewUpdater8Platform13CGDrawingView", | ||
"_TtC7SwiftUIP33_A34643117F00277B93DEBAB70EC0697122_UIShapeHitTestingView", | ||
"SwiftUI._UIGraphicsView", "SwiftUI.ImageLayer"].compactMap { NSClassFromString($0) } | ||
|
||
init(_ config: PostHogConfig) { | ||
self.config = config | ||
urlInterceptor = URLSessionInterceptor(self.config) | ||
|
@@ -69,7 +74,7 @@ | |
let timestamp = Date().toMillis() | ||
let snapshotStatus = windowViews.object(forKey: view) ?? ViewTreeSnapshotStatus() | ||
|
||
guard let wireframe = toWireframe(view) else { | ||
guard let wireframe = config.sessionReplayConfig.screenshotMode ? toScreenshotWireframe(view) : toWireframe(view) else { | ||
return | ||
} | ||
|
||
|
@@ -129,33 +134,133 @@ | |
style.paddingLeft = Int(insets.left) | ||
} | ||
|
||
private func toWireframe(_ view: UIView, parentId: Int? = nil) -> RRWireframe? { | ||
if !view.isVisible() { | ||
return nil | ||
} | ||
|
||
private func createBasicWireframe(_ view: UIView) -> RRWireframe { | ||
let wireframe = RRWireframe() | ||
|
||
wireframe.id = view.hash | ||
wireframe.posX = Int(view.frame.origin.x) | ||
wireframe.posY = Int(view.frame.origin.y) | ||
wireframe.width = Int(view.frame.size.width) | ||
wireframe.height = Int(view.frame.size.height) | ||
let style = RRStyle() | ||
|
||
// no parent id means its the root | ||
if parentId == nil, config.sessionReplayConfig.screenshotMode { | ||
if let image = view.toImage() { | ||
wireframe.base64 = imageToBase64(image) | ||
return wireframe | ||
} | ||
|
||
private func findMaskableWidgets(_ view: UIView, _ parent: UIView, _ maskableWidgets: inout [CGRect]) { | ||
if let textView = view as? UITextView { | ||
if isTextViewSensitive(textView) { | ||
maskableWidgets.append(view.toAbsoluteRect(parent)) | ||
return | ||
} | ||
} | ||
|
||
if let textField = view as? UITextField { | ||
if isTextFieldSensitive(textField) { | ||
maskableWidgets.append(view.toAbsoluteRect(parent)) | ||
return | ||
} | ||
} | ||
|
||
if let image = view as? UIImageView { | ||
if isImageViewSensitive(image) { | ||
maskableWidgets.append(view.toAbsoluteRect(parent)) | ||
return | ||
} | ||
} | ||
|
||
if view is UILabel { | ||
if isTextInputSensitive(view) { | ||
maskableWidgets.append(view.toAbsoluteRect(parent)) | ||
return | ||
} | ||
} | ||
|
||
if swiftUIImageTypes.contains(where: { view.isKind(of: $0) }) { | ||
if isAnyInputSensitive(view) { | ||
maskableWidgets.append(view.toAbsoluteRect(parent)) | ||
return | ||
} | ||
} | ||
|
||
if !view.subviews.isEmpty { | ||
for child in view.subviews { | ||
if !child.isVisible() { | ||
continue | ||
} | ||
|
||
findMaskableWidgets(child, parent, &maskableWidgets) | ||
} | ||
wireframe.type = "screenshot" | ||
return wireframe | ||
} | ||
} | ||
|
||
private func toScreenshotWireframe(_ view: UIView) -> RRWireframe? { | ||
if !view.isVisible() { | ||
return nil | ||
} | ||
|
||
var maskableWidgets: [CGRect] = [] | ||
findMaskableWidgets(view, view, &maskableWidgets) | ||
|
||
let wireframe = createBasicWireframe(view) | ||
|
||
if let image = view.toImage() { | ||
let redactedImage = UIGraphicsImageRenderer(size: image.size, format: .init(for: .init(displayScale: 1))).image { context in | ||
context.cgContext.interpolationQuality = .none | ||
image.draw(at: .zero) | ||
|
||
for rect in maskableWidgets { | ||
let path = UIBezierPath(roundedRect: rect, cornerRadius: 10) | ||
UIColor.black.setFill() | ||
path.fill() | ||
} | ||
} | ||
wireframe.base64 = imageToBase64(redactedImage) | ||
Comment on lines
+210
to
+220
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. possible optimization for the future, this can be done async but requires changing a few things and adjusting timestamps, if performance is an issue, this is a place to look at. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I've started doing this optimization but led me to do other things that also required to be in the main thread so the changes didn't make much effect, most likely we'll need to pay the memory footprint price instead of processing the images right away, so we can batch the process in background threads but pay the price of holding a copy of the images in memory which can lead to OOM quite quickly as well. |
||
} | ||
wireframe.type = "screenshot" | ||
return wireframe | ||
} | ||
|
||
private func isAssetsImage(_ image: UIImage) -> Bool { | ||
// https://github.com/daydreamboy/lldb_scripts#9-pimage | ||
image.imageAsset?.value(forKey: "_containingBundle") != nil | ||
} | ||
|
||
private func isAnyInputSensitive(_ view: UIView) -> Bool { | ||
config.sessionReplayConfig.maskAllTextInputs || config.sessionReplayConfig.maskAllImages || view.isNoCapture() | ||
} | ||
|
||
private func isTextInputSensitive(_ view: UIView) -> Bool { | ||
config.sessionReplayConfig.maskAllTextInputs || view.isNoCapture() | ||
} | ||
|
||
private func isTextViewSensitive(_ view: UITextView) -> Bool { | ||
isTextInputSensitive(view) || view.isSensitiveText() | ||
} | ||
|
||
private func isTextFieldSensitive(_ view: UITextField) -> Bool { | ||
isTextInputSensitive(view) || view.isSensitiveText() | ||
} | ||
|
||
private func isImageViewSensitive(_ view: UIImageView) -> Bool { | ||
var isAsset = false | ||
if let image = view.image { | ||
isAsset = isAssetsImage(image) | ||
} | ||
return (config.sessionReplayConfig.maskAllImages && !isAsset) || view.isNoCapture() | ||
} | ||
|
||
private func toWireframe(_ view: UIView) -> RRWireframe? { | ||
if !view.isVisible() { | ||
return nil | ||
} | ||
|
||
let wireframe = createBasicWireframe(view) | ||
|
||
let style = RRStyle() | ||
|
||
if let textView = view as? UITextView { | ||
wireframe.type = "text" | ||
let isSensitive = config.sessionReplayConfig.maskAllTextInputs || textView.isNoCapture() || textView.isSensitiveText() | ||
wireframe.text = isSensitive ? textView.text.mask() : textView.text | ||
wireframe.text = isTextViewSensitive(textView) ? textView.text.mask() : textView.text | ||
wireframe.disabled = !textView.isEditable | ||
style.color = textView.textColor?.toRGBString() | ||
style.fontFamily = textView.font?.familyName | ||
|
@@ -169,12 +274,11 @@ | |
if let textField = view as? UITextField { | ||
wireframe.type = "input" | ||
wireframe.inputType = "text_area" | ||
let isSensitive = isTextFieldSensitive(textField) | ||
if let text = textField.text { | ||
let isSensitive = config.sessionReplayConfig.maskAllTextInputs || textField.isNoCapture() || textField.isSensitiveText() | ||
wireframe.value = isSensitive ? text.mask() : text | ||
} else { | ||
if let text = textField.placeholder { | ||
let isSensitive = config.sessionReplayConfig.maskAllTextInputs || textField.isNoCapture() || textField.isSensitiveText() | ||
wireframe.value = isSensitive ? text.mask() : text | ||
} | ||
} | ||
|
@@ -198,10 +302,10 @@ | |
wireframe.checked = theSwitch.isOn | ||
} | ||
|
||
if let image = view as? UIImageView { | ||
if let imageView = view as? UIImageView { | ||
wireframe.type = "image" | ||
if !image.isNoCapture(), !config.sessionReplayConfig.maskAllImages { | ||
if let image = image.image { | ||
if let image = imageView.image { | ||
if !isImageViewSensitive(imageView) { | ||
wireframe.base64 = imageToBase64(image) | ||
} | ||
} | ||
|
@@ -220,7 +324,7 @@ | |
if let label = view as? UILabel { | ||
wireframe.type = "text" | ||
if let text = label.text { | ||
wireframe.text = (config.sessionReplayConfig.maskAllTextInputs || label.isNoCapture()) ? text.mask() : text | ||
wireframe.text = isTextInputSensitive(view) ? text.mask() : text | ||
} | ||
wireframe.disabled = !label.isEnabled | ||
style.color = label.textColor?.toRGBString() | ||
|
@@ -264,7 +368,7 @@ | |
if !view.subviews.isEmpty { | ||
var childWireframes: [RRWireframe] = [] | ||
for subview in view.subviews { | ||
if let child = toWireframe(subview, parentId: view.hash) { | ||
if let child = toWireframe(subview) { | ||
childWireframes.append(child) | ||
} | ||
} | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I wonder if this changes for every new release or something, some of them are auto-generated but that's the best I found so far.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I suppose we won't know until we release but is there a chance that there are more auto-generated types in a customers app?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Since the types are auto-generated, we cannot just do
view as? SwiftUI.Text
similarly to UIKit.So yeah I believe this is a moving thing that we'll discover more over time or new ones will come up within the next few SwiftUI versions, but those are the "root" auto-generated types that work well with the basic widgets at least.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Some of them are mapped to the UIKit though, for example:
TextField ->
UITextField
.navigationTitle ->
UILabel