Ловим скриншоты

На днях прилетел мне баг от реального пользователя, в тикете всё как положено — и описание и запись экрана. Только вот непонятно что за версия приложения. Решил я добавить оверлей с номером версии и билда в момент записи экрана или скриншота. Задача вроде бы простая — я помню там есть какие-то уведомления, подписался, скрыл/показал и дело в шляпе! Минут 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.

Share
Send
Pin
1 mon