Ловим скриншоты
На днях прилетел мне баг от реального пользователя, в тикете всё как положено — и описание и запись экрана. Только вот непонятно что за версия приложения. Решил я добавить оверлей с номером версии и билда в момент записи экрана или скриншота. Задача вроде бы простая — я помню там есть какие-то уведомления, подписался, скрыл/показал и дело в шляпе! Минут 30 должно хватить! Но всё оказалось куда сложнее чем я думал!
🚨
Если не терпится попробовать — по ссылке либа и проекты на SwiftUI и UIKit. Помните что всё использование на ваш страх и риск. 📦 Библиотека и демо-проект
Apple позволяет трекать запись экрана — тут всё без сюрпризов. Вот тебе ивент в Notification Center, вот тебе флажок у UIScreen. Но со скриншотами всё не так радужно: есть только уведомление о том, что скриншот сделан, то есть приходит только после того, как он сделан. То есть заранее подготовиться никак. Какого чёрта, подумал я, ведь точно видел приложения, которые в момент делают подмену вьюх когда делается скриншот! (спойлер: не видел я таких, ложные воспоминания)
struct ContentView: View {
@State private var isScreenCaptured = false
private let center = NotificationCenter.default
var body: some View {
ZStack {
Text("Main Content")
if isScreenCaptured {
Text("Screen Recording in Progress")
.foregroundColor(.white)
}
}
.onReceive(center.publisher(for: UIScreen.capturedDidChangeNotification)) { _ in
// ⚠️ Deprecated в iOS 18+, лучше использовать `sceneCaptureState`
isScreenCaptured = UIScreen.main.isCaptured
}
/// Отловить скриншот можно подписавшись на `UIApplication.userDidTakeScreenshotNotification`
/// но уведомление приходит после скриншота, подготовить интерфейс к скриншоту не получиться!
}
}
Хорошие художники копируют, великие — крадут
Ну что далеко ходить? В телеграмме я точно помню скрываются сообщения на скриншотах. Достаточно посмотреть какие события они ловят и что делают чтобы скрыть. Погнали! Git clone, grep ‘screenshot’ Минут через 20 раскапываю, что есть такой метод setLayerDisableScreenshots в UIKitUtils.m который используя внутреннюю вьюху UITextField делает любой слой скрываемым на любом захвате экрана, скриншот это или запись видео.
Грубо говоря, он «бафает» любой слой и позволяет ему скрываться когда идет запись. Но как это работает, по-любому какое-то событие прилетает этому слою и он скрывается. Что-б долго не экспериментировать, я решил открыть старый добрый Hopper Disassembler и покопаться в UIKitCore.
☝🏻
Чтобы быстро найти путь до нужного фреймворка достаточно поставить брейкпоинт где-нибудь в свифтовом коде и выполнить po Bundle(for: UIView.self). Вместо UIView подставить нужный класс.
Чтож, раскопки приводят к следующему выводу: никаких событий действительно нет, при установке isSecureTextEntry текст филд устанавливает слою внутреннего контейнера специальные атрибуты (disableUpdateMask) которые отправляются на рендер сервер, а тот уже решает рисовать это вью или нет. Чтож, логично, секьюрно. Это позволит не рисовать секьюрные поля даже если приложение зависло и главный поток не отвечает.
⚠️
Несмотря на то, что тут в явном виде мы видим значения флага 0x12, завязаться на это не стоит. У меня на iOS 18 это работает через раз, поэтому я оставил решение как в телеграмме, оно работает как часы!
Вот такой код получается:
import UIKit
private let uiKitTextField = UITextField()
private var captureSecuredView: UIView?
public extension CALayer {
func makeHiddenOnCapture() {
let captureSecuredView: UIView? = captureSecuredView
?? uiKitTextField.subviews
.first(where: { NSStringFromClass(type(of: $0)).contains("LayoutCanvasView") })
let originalLayer = captureSecuredView?.layer
captureSecuredView?.setValue(self, forKey: "layer")
uiKitTextField.isSecureTextEntry = false
uiKitTextField.isSecureTextEntry = true
captureSecuredView?.setValue(originalLayer, forKey: "layer")
}
}
Реализуем в SwiftUI
Поскольку изначально мне нужно было решить задачу в SwiftUI проекте, умения скрывать UIKit’овые слои было недостаточно. Первой идеей было использовать маски. Типа берем ZStack, там рисуем белую и черную вью, где черная будет UIViewRepresentable со скрываемым слоем. Давайте закодим:
struct HiddenOnCaptureColorView: UIViewRepresentable {
let color: UIColor
func makeUIView(context: Context) -> UIView {
let view = UIView()
view.layer.makeHiddenOnCapture()
updateViewColor(view: view)
return view
}
func updateUIView(_ uiView: UIView, context: Context) {
updateViewColor(view: uiView)
}
func updateViewColor(view: UIView) {
view.backgroundColor = color
}
}
struct VisibleOnlyOnCaptureModifier: ViewModifier {
func body(content: Content) -> some View {
content.mask {
ZStack {
Color.black
HiddenOnCaptureColorView(color: .white)
}
}
}
}
Но вот незадача — я забыл что mask работает не с белым/черным цветом, а с прозрачными/непрозрачными пикселями. Что же делать? Беглый гуглёж не дал результатов, зато ChatGPT подсказал — есть такой метод на View, называется luminanceToAlpha() который смешает пиксели и сделает черные прозрачным, а белые непрозрачным. Не верилось что это заработает, но это оказалось оно!
struct VisibleOnlyOnCaptureModifier: ViewModifier {
func body(content: Content) -> some View {
content.mask {
ZStack {
Color.black
HiddenOnCaptureColorView(color: .white)
}
.compositingGroup()
.luminanceToAlpha()
}
}
}
Юхууу! Всё оказалось проще чем я думал! Меняем цвета местами и получаем вью которые видны только на скриншот или скрываются.
⚠️
Работает это всё только на реальном девайсе, в симуляторе такое проделать не получится, как и сделать UI тесты. Учитывайте это.
⚠️
Т.к. это манипуляции с масками, они не оказывают никакого эффекта на лейаут, учитывайте это! Работают они подобно модификатору alpha, то есть учитываются при расчете верстки, но просто скрыты/показаны при записи.
Приключение еще на полдня
К этому времени я уже написал пост в свой телеграмм канал, и решил написать эту статью. Не хватало только полной поддержки UIKit. Скрывать вью там легко, а вот показывать…
Опять же, казалось всё просто — сабклассимся от вью, вешаем маску на слой и дело в шляпе. Собираю рабочий эксперимент — и ничего не работает. Я снова забыл, что вью должны быть не черно-белыми, а прозрачными/непрозрачными. Но как мне сделать эффект luminanceToAlpha? Такого в UIKit нет.
Спускаемся ниже
Я уже было хотел забить на это дело, но спортивный интерес не давал покоя. Ну как так то? В SwiftUI сделал, а в родном UIKit нет!
Решил посмотреть что там во View Hierarchy. Я запустил минимальное SwiftUI приложение и решил посмотреть какие «китовые» вью она нарисует, ведь весь SwiftUI рано или поздно становится слоями которые будут отправлены на рендер-сервер.
Не буду расписывать весь мой траверсс по дереву вью, но довольно быстро я нахожу то что мне надо. Вот моя вью, с маской, в которой два слоя и ФИЛЬТР с одноименным названием luminanceToAlpha! Каеф!
Осталось немного приватной ObjC магии и мы это заведем. Итак, нам нужен слой, у которого внутри будут два слоя (черный и белый), один из которых будет прокачан на скрытие на скриншотах, а родитель будет их смешивать с помощью фильтра. Это специальный приватный CAFilter (не путать с CIFilter) который так просто не получить, но так как ObjectiveC рантайм позволяет творить магию вне хогвардса, это нам под силам!
func makeFilter() -> NSObject? {
guard let filterClass: AnyClass = NSClassFromString("CAFilter") else { return nil }
let selector = Selector("filterWithName:")
typealias Function = @convention(c) (AnyClass, Selector, String) -> AnyObject?
guard let filterWithName = class_getClassMethod(filterClass, selector) else { return nil }
let implementation = method_getImplementation(filterWithName)
let function = unsafeBitCast(implementation, to: Function.self)
guard let filter = function(filterClass, selector, "luminanceToAlpha") as? NSObject else { return nil }
return filter
}
Победа!
Отлично, я добился чего хотел и даже больше. Я оформил всё это добро в 📦 SPM-пакет, который можно использовать в проекте. В нём два таргета — для SwiftUI и UIKit. Первую из них можно использовать вполне безопасно, т.к. никакие приватные символы там не светятся, а вот с версий для UIKit нужно быть предельно осторожным из-за CAFilters. Вроде бы ничего такого, но лучше не использовать её напрямую, а просто подсмотреть решение и как-то обфурсцировать символы перед релизом в AppStore.