├── .github └── workflows │ └── swift.yml ├── .gitignore ├── LICENSE ├── Package.swift ├── README.md ├── Sources └── Unscreenshottable │ └── ProtectedView.swift ├── Tests └── UnscreenshottableTests │ └── UnscreenshottableTests.swift └── demo.gif /.github/workflows/swift.yml: -------------------------------------------------------------------------------- 1 | # This workflow will build a Swift project 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-swift 3 | 4 | name: Swift 5 | 6 | on: 7 | push: 8 | branches: [ "main" ] 9 | pull_request: 10 | branches: [ "main" ] 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: macos-latest 16 | 17 | steps: 18 | - uses: actions/checkout@v4 19 | - name: Build and run tests 20 | run: xcodebuild test -scheme Unscreenshottable -destination 'platform=iOS Simulator,name=iPhone 15 Pro' 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | xcuserdata/ 5 | DerivedData/ 6 | .swiftpm/configuration/registries.json 7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 8 | .netrc 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Vikram Kriplaney 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.9 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "Unscreenshottable", 8 | platforms: [.iOS(.v14)], 9 | products: [ 10 | // Products define the executables and libraries a package produces, making them visible to other packages. 11 | .library( 12 | name: "Unscreenshottable", 13 | targets: ["Unscreenshottable"]), 14 | ], 15 | targets: [ 16 | // Targets are the basic building blocks of a package, defining a module or a test suite. 17 | // Targets can depend on other targets in this package and products from dependencies. 18 | .target( 19 | name: "Unscreenshottable"), 20 | .testTarget( 21 | name: "UnscreenshottableTests", 22 | dependencies: ["Unscreenshottable"]), 23 | ] 24 | ) 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SwiftUI Unscreenshottable 📵 2 | Protect sensitive content on iOS. 3 | 4 | ![](demo.gif) 5 | 6 | Unscreenshottable can protect your view from: 7 | 8 | ### 1. Screenshots 9 | Hide your view during a screenshot, optionally replacing it with another view. 10 | > [!CAUTION] 11 | > Unscreenshottable's screenshot protection relies on internal, undocumented iOS view hierarchy. It *may* be safe to\ 12 | > submit to the App Store, but may stop working in future iOS versions – if Apple changes the view hierarchy. 13 | > The library includes a unit test that checks for the availability of the required internal view. 14 | 15 | ### 2. Screen Sharing 16 | Hide your view while the screen is being shared, for example via AirPlay. 17 | 18 | ### 3. Inactivity 19 | Hide your view while your app is inactive, for example during task switching. 20 | > [!NOTE] 21 | > You can also protect views with the `.privacySensitive()` modifier available since iOS 15, but Unscreenshottable 22 | > allows you to replace your content with another view and supports iOS 14. 23 | 24 | ## Usage 25 | 26 | You can apply the `protected` modifier to your top-level view, typically `ContentView`: 27 | 28 | ```swift 29 | import SwiftUI 30 | import Unscreenshottable 31 | 32 | @main 33 | struct UnscreenshottableDemoApp: App { 34 | var body: some Scene { 35 | WindowGroup { 36 | ContentView() 37 | .protected { 38 | Text("No screenshots nor screen sharing, please.") 39 | } 40 | } 41 | } 42 | } 43 | ``` 44 | 45 | ### Protection Options 46 | 47 | The `.protected` modifier can take an optional parameter if you want to limit the protection types (the default is all three). You can even combine multiple protections: 48 | 49 | ```swift 50 | ContentView() 51 | .protected(from: inactivity) { 52 | Image("logo") 53 | } 54 | .protected(from: [.screenshots, .screenSharing]) { 55 | Text("No screenshots nor screen sharing, please.") 56 | } 57 | ``` 58 | 59 | ## Installation 60 | ### Swift Package Manager 61 | 62 | Use the package URL or search for the SwiftUI-Unscreenshottable package: https://github.com/markiv/SwiftUI-Unscreenshottable.git. 63 | 64 | For how to integrate package dependencies refer to the [*Adding Package Dependencies to Your App* documentation](https://developer.apple.com/documentation/xcode/adding_package_dependencies_to_your_app). 65 | -------------------------------------------------------------------------------- /Sources/Unscreenshottable/ProtectedView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ProtectedView.swift 3 | // Unscreenshottable 4 | // 5 | // Created by Vikram Kriplaney on 03.06.2024. 6 | // 7 | 8 | import class Combine.AnyCancellable 9 | import SwiftUI 10 | 11 | public struct ProtectionOptions: OptionSet { 12 | public let rawValue: Int 13 | /// Hide the view from screenshots. 14 | public static let screenshots = ProtectionOptions(rawValue: 0x01) 15 | /// Hide the view from screen sharing (e.g. AirPlay). 16 | public static let screenSharing = ProtectionOptions(rawValue: 0x02) 17 | /// Hide the view while the app is not active (e.g. while task switching). 18 | public static let inactivity = ProtectionOptions(rawValue: 0x04) 19 | /// Hide the view from screenshots, screen sharing and during inactivity (task switching). 20 | public static let all = ProtectionOptions([.screenshots, .screenSharing, inactivity]) 21 | 22 | public init(rawValue: Int) { 23 | self.rawValue = rawValue 24 | } 25 | } 26 | 27 | struct ProtectedView: UIViewRepresentable { 28 | let options: ProtectionOptions 29 | @State private var textField: UITextField 30 | @State private var secureCanvas: UIView? 31 | @State private var hostingController: UIHostingController 32 | // Just observing size classes allows updateUIView and sizeThatFits to be called when the screen is rotated 33 | @Environment(\.horizontalSizeClass) private var horizontalSizeClass 34 | @Environment(\.verticalSizeClass) private var verticalSizeClass 35 | // @Environment(\.isSceneCaptured) private var isSceneCaptured // since iOS 17 36 | private var cancellables = Set() 37 | 38 | init(options: ProtectionOptions = .all, content: @escaping () -> Content) { 39 | self.options = options 40 | self.textField = .init() 41 | self.hostingController = UIHostingController(rootView: content()) 42 | 43 | textField.isSecureTextEntry = true 44 | textField.isUserInteractionEnabled = false 45 | 46 | // Subscribes to NotificationCenter notifications and calls a decision closure. 47 | func subscribe(to notification: Notification.Name, shouldHide: @escaping (Notification) -> Bool) { 48 | NotificationCenter.default.publisher(for: notification) 49 | .sink { [weak hostingController] notification in 50 | hostingController?.view.isHidden = shouldHide(notification) 51 | } 52 | .store(in: &cancellables) 53 | } 54 | 55 | if options.contains(.inactivity) { 56 | subscribe(to: UIApplication.willResignActiveNotification) { _ in true } 57 | subscribe(to: UIApplication.didBecomeActiveNotification) { _ in false } 58 | } 59 | if options.contains(.screenSharing) { 60 | subscribe(to: UIScreen.capturedDidChangeNotification) { ($0.object as? UIScreen)?.isCaptured ?? false } 61 | } 62 | } 63 | 64 | func makeUIView(context: Context) -> UIView { 65 | hostingController.view 66 | } 67 | 68 | func updateUIView(_ uiView: UIView, context: Context) { 69 | guard options.contains(.screenshots), secureCanvas == nil else { return } 70 | DispatchQueue.main.async { 71 | // "Harvest" a canvas view from the secure `TextField`'s view hierarchy. 72 | if let secureCanvas = textField.canvasView, let view = hostingController.view { 73 | hostingController.view = secureCanvas 74 | secureCanvas.overlay(subview: view) 75 | } 76 | } 77 | } 78 | 79 | @available(iOS 16.0, *) 80 | func sizeThatFits(_ proposal: ProposedViewSize, uiView: UIView, context: Context) -> CGSize? { 81 | let size = CGSize(width: proposal.width ?? .infinity, height: proposal.height ?? .infinity) 82 | hostingController.view.frame.size = size 83 | return size 84 | } 85 | } 86 | 87 | public extension View { 88 | /// Protects this view from screenshotting, screen sharing and/or during inactivity. 89 | /// - Parameter placeholder: The optional content to display instead of this view during a screenshotting, screen 90 | /// sharing and/or inactivity. An empty view is displayed by default. 91 | /// - Parameter options: Protection options: See ``ProtectionOptions``. ``ProtectionOptions/all`` is the default. 92 | /// - Returns: A protected view. 93 | /// 94 | /// - Warning: Screenshot protection may stop working if Apple changes the view hierarchy in future iOS versions. 95 | func protected( 96 | from options: ProtectionOptions = .all, @ViewBuilder placeholder: () -> some View = { EmptyView() } 97 | ) -> some View { 98 | ProtectedView(options: options) { 99 | self 100 | } 101 | .ignoresSafeArea() // allow navigation and tab bar custom backgrounds to work. 102 | .background(placeholder()) // place the placeholder behind the protected view. 103 | } 104 | } 105 | 106 | private extension UIView { 107 | func overlay(subview: UIView) { 108 | subview.translatesAutoresizingMaskIntoConstraints = false 109 | addSubview(subview) 110 | NSLayoutConstraint.activate([ 111 | topAnchor.constraint(equalTo: subview.topAnchor), 112 | leftAnchor.constraint(equalTo: subview.leftAnchor), 113 | rightAnchor.constraint(equalTo: subview.rightAnchor), 114 | bottomAnchor.constraint(equalTo: subview.bottomAnchor) 115 | ]) 116 | } 117 | } 118 | 119 | extension UITextField { 120 | /// Extracts a canvas view from a secure `TextField`'s view hierarchy. 121 | /// The canvas view is a internal view that hides its subviews during a screenshot. 122 | /// - Warning: This may stop working if Apple changes the view hierarchy in future iOS versions. 123 | var canvasView: UIView? { 124 | subviews.first { 125 | // iOS 15... iOS 14... 126 | ["_UITextLayoutCanvasView", "_UITextFieldCanvasView"].contains(type(of: $0).description()) 127 | } ?? subviews.first { 128 | type(of: $0).description().hasSuffix("CanvasView") // speculative attempt for future versions 129 | } 130 | } 131 | } 132 | 133 | #if DEBUG 134 | /// Demonstrates the `.protected` modifier. 135 | /// You can try it by dropping `DemoProtectedView()` into your app in DEBUG mode. 136 | public struct DemoProtectedView: View { 137 | public init() {} 138 | 139 | public var body: some View { 140 | VStack(spacing: 20) { 141 | Label("Can you screenshot this?", systemImage: "camera") 142 | Label("Can you AirPlay this?", systemImage: "airplayvideo") 143 | Label("Can you see this while task switching?", systemImage: "appwindow.swipe.rectangle") 144 | } 145 | .labelStyle(ItemLabelStyle()) 146 | .imageScale(.large) 147 | .font(.title.bold()) 148 | .padding() 149 | .protected { 150 | VStack { 151 | Image(systemName: "nosign") 152 | .resizable().scaledToFit() 153 | .foregroundColor(.red) 154 | Text("Screenshots and screen sharing are not allowed.") 155 | } 156 | .font(.largeTitle.bold()) 157 | .padding() 158 | } 159 | .multilineTextAlignment(.center) 160 | } 161 | 162 | struct ItemLabelStyle: LabelStyle { 163 | func makeBody(configuration: Configuration) -> some View { 164 | VStack { 165 | configuration.icon 166 | .foregroundColor(.accentColor) 167 | configuration.title 168 | } 169 | } 170 | } 171 | } 172 | 173 | #Preview { 174 | DemoProtectedView() 175 | } 176 | #endif 177 | -------------------------------------------------------------------------------- /Tests/UnscreenshottableTests/UnscreenshottableTests.swift: -------------------------------------------------------------------------------- 1 | @testable import Unscreenshottable 2 | import XCTest 3 | 4 | final class UnscreenshottableTests: XCTestCase { 5 | /// Tests that a canvas view can still be harvested from a secure TextField. 6 | /// If this test fails, view protection will not work on your target iOS version. 7 | func testSecureCanvasExists() throws { 8 | let textField = UITextField() 9 | textField.isSecureTextEntry = true 10 | XCTAssertNotNil( 11 | textField.canvasView, 12 | "Canvas view not found. Screenshot protection will not work on this iOS version." 13 | ) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/markiv/SwiftUI-Unscreenshottable/a733dd92213e09a2e69fe68cca0d564a0d58c32b/demo.gif --------------------------------------------------------------------------------