Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
120 changes: 97 additions & 23 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,32 +1,38 @@
# ScreenShieldKit

A Swift package to protect sensitive content from screenshots and screen recording on iOS and macOS platforms.
A Swift package that protects sensitive content from screenshots and screen recordings across Apple platforms — for UIKit, AppKit, Core Animation, and **SwiftUI**.

## Overview

ScreenShieldKit provides an easy-to-use API to prevent content from being captured in screenshots or screen recordings. This is especially useful for applications that display sensitive information such as:
ScreenShieldKit hides selected content from screenshots, screen recordings, and other system captures while it stays fully visible on the device. This is useful for screens that display sensitive information such as:

- Financial data
- Financial data (balances, card numbers)
- Personal identification information
- One-time passcodes and authentication screens
- Confidential documents
- Authentication screens

The protection is scoped: you protect only the views that contain secrets, and the rest of your UI is captured normally.

## Requirements

- iOS 14.0+ / macOS 11.0+
| Surface | Minimum OS |
| --- | --- |
| UIKit / AppKit / CALayer | iOS 14.0 · macOS 11.0 |
| SwiftUI | iOS 16.0 · macOS 13.0 · tvOS 16.0 · watchOS 11.0 · visionOS 1.0 |

- Swift 5.7+

## Installation

### Swift Package Manager

Add ScreenShieldKit to your project through Xcode's Package Dependencies:
Add ScreenShieldKit through Xcode's **File → Add Packages…** with the URL:

1. In Xcode, select "File" → "Add Packages..."
2. Enter the repository URL: `https://github.com/daangn/ScreenShieldKit.git`
3. Choose the version you want to use
```
https://github.com/daangn/ScreenShieldKit.git
```

Or add it to your `Package.swift` file:
Or add it to your `Package.swift`:

```swift
dependencies: [
Expand All @@ -36,31 +42,99 @@ dependencies: [

## Usage

### Basic Usage
### SwiftUI

Apply the `.screenShield(_:)` modifier to any view you want to hide from captures:

```swift
import ScreenShieldKit
import SwiftUI

Text(oneTimeCode)
.font(.system(.title, design: .monospaced))
.screenShield()
```

To protect several views at once, group them and apply the modifier a single time:

```swift
VStack {
AccountBalanceView()
CardNumberView()
}
.screenShield()
```

**Dynamic on/off**

Pass a `Bool` that participates in SwiftUI state to toggle protection at runtime:

```swift
struct CardView: View {
@State private var isSensitive = true

var body: some View {
VStack {
Toggle("Protect content", isOn: $isSensitive)

Text("1234 5678 9012 3456")
.screenShield(isSensitive)
}
}
}
```

Toggling protection preserves the wrapped content's identity: it never inserts or removes views based on the flag, so the content's `@State`, scroll position, focus, and in-flight animations are **not** reset when you switch protection on or off.

### UIKit

```swift
import ScreenShieldKit

// iOS
let view = UIView()
view.setScreenShield(enabled: true) // Enable protection
view.setScreenShield(enabled: true) // Enable protection
view.setScreenShield(enabled: false) // Disable protection
```

// When protection is no longer needed
view.setScreenShield(enabled: false) // Disable protection
### AppKit

```swift
import ScreenShieldKit

// macOS
let view = NSView()
view.setScreenShield(enabled: true) // Enable protection
view.setScreenShield(enabled: true) // The view must have a backing layer
```

### Core Animation

// When protection is no longer needed
view.setScreenShield(enabled: false) // Disable protection
```swift
import ScreenShieldKit

layer.setScreenShield(enabled: true)
```

## Important Notes
## How it works

ScreenShieldKit selects a mechanism per platform and OS version:

| Path | OS | Mechanism |
| --- | --- | --- |
| SwiftUI (native) | iOS 18 / macOS 15 / tvOS 18 / watchOS 11 / visionOS 2+ | Activates SwiftUI's native capture-redaction reason in the environment. Fully transparent to layout; no hosting boundary. |
| SwiftUI (legacy) | iOS 16–17 / macOS 13–14 / tvOS 16–17 / visionOS 1 | Hosts the content in a hosting controller and applies the Core Animation capture flag to its backing layer. |
| UIKit / AppKit / CALayer | iOS 14+ / macOS 11+ | Applies the Core Animation capture flag directly to the layer. |

The SwiftUI native path is preferred where available because it composes cleanly with SwiftUI layout and state. The legacy path re-hosts the content, but SwiftUI keeps propagating the ancestor environment across the hosting boundary, so `@Environment` values and `@EnvironmentObject` dependencies still reach the shielded content.

## Important notes

- **Private platform behavior.** ScreenShieldKit relies on private platform behavior that can change between OS releases. When the mechanism is unavailable on a device, the call becomes a safe no-op (it never crashes). **Verify capture protection on every OS version you support** — with screenshots, screen recordings, AirPlay mirroring, and any in-app capture flow that matters for your product.
- **App Store.** The library avoids private selectors and private symbol references: the SwiftUI native path constructs the capture-redaction option value directly, and the Core Animation path resolves its property key at runtime. Even so, using private platform behavior carries review risk you should evaluate for your app.
- **`privacySensitive` interaction (SwiftUI native path).** On the native path (iOS 18 / macOS 15 / tvOS 18 / watchOS 11 / visionOS 2+), the shielded subtree is forced non-privacy-sensitive — even when `isProtected` is `false` — so activating the capture-redaction reason never blanks the content on device. Avoid relying on `.privacySensitive()` inside a `.screenShield()` boundary. The legacy path does not apply this, since it hides via the layer flag rather than a redaction reason.
- **macOS is best-effort.** On macOS, layer-level capture hiding may not hide content from every system capture path.

## Sample

- This library uses private APIs which may not be approved for App Store submission
- The protection mechanism may change in future OS versions
- For macOS, the view must have a backing layer for the protection to work
The `Sample/` app has two tabs — a UIKit demo and a SwiftUI demo — both driving protection from a toggle. The sample targets iOS 16 so you can exercise the legacy and native SwiftUI paths on the OS versions you support.

## License

Expand Down
4 changes: 2 additions & 2 deletions Sample/Sample.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -251,7 +251,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 18.2;
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
Expand Down Expand Up @@ -308,7 +308,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 18.2;
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
Expand Down
2 changes: 1 addition & 1 deletion Sample/Sample/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
// AppDelegate.swift
// Sample
//
// Created by Ray on 5/12/25.
// Created by Kanghoon Oh on 5/12/25.
//

import UIKit
Expand Down
27 changes: 25 additions & 2 deletions Sample/Sample/SceneDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@
// SceneDelegate.swift
// Sample
//
// Created by Ray on 5/12/25.
// Created by Kanghoon Oh on 5/12/25.
//

import SwiftUI
import UIKit

final class SceneDelegate: UIResponder, UIWindowSceneDelegate {
Expand All @@ -17,8 +18,30 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate {
) {
guard let windowScene = (scene as? UIWindowScene) else { return }
let window = UIWindow(windowScene: windowScene)
window.rootViewController = ViewController()
window.rootViewController = makeRootViewController()
window.makeKeyAndVisible()
self.window = window
}

private func makeRootViewController() -> UIViewController {
let uikitTab = UINavigationController(rootViewController: ViewController())
uikitTab.tabBarItem = UITabBarItem(
title: "UIKit",
image: UIImage(systemName: "square.dashed"),
tag: 0
)

let swiftuiTab = UINavigationController(
rootViewController: UIHostingController(rootView: SwiftUIDemoView())
)
swiftuiTab.tabBarItem = UITabBarItem(
title: "SwiftUI",
image: UIImage(systemName: "swift"),
tag: 1
)

let tabBarController = UITabBarController()
tabBarController.viewControllers = [uikitTab, swiftuiTab]
return tabBarController
}
}
86 changes: 86 additions & 0 deletions Sample/Sample/SwiftUIDemoView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
//
// SwiftUIDemoView.swift
// Sample
//
// Created by Kanghoon Oh on 7/12/26.
//

import ScreenShieldKit
import SwiftUI

/// Demonstrates ScreenShieldKit's SwiftUI surface: the ``View/screenShield(_:)`` modifier applied to
/// both a composed card and inline text, with dynamic on/off protection driven by `@State`.
///
/// Take a screenshot or start a screen recording while `Protect content` is on: the shielded views
/// disappear from the capture while staying visible on the device.
struct SwiftUIDemoView: View {
@State private var isProtected = true

var body: some View {
ScrollView {
VStack(spacing: 28) {
Toggle("Protect content", isOn: $isProtected)

// 1) Modifier on a composed view.
VStack(alignment: .leading, spacing: 8) {
sectionTitle(".screenShield(_:) on a card")
balanceCard
.screenShield(isProtected)
}

// 2) Modifier on inline text.
VStack(alignment: .leading, spacing: 8) {
sectionTitle(".screenShield(_:) on inline text")
Text("1234 5678 9012 3456")
.font(.system(.title3, design: .monospaced))
.frame(maxWidth: .infinity)
.padding()
.background(RoundedRectangle(cornerRadius: 12).fill(Color.secondary.opacity(0.12)))
.screenShield(isProtected)
}

Text(
"Take a screenshot or start a screen recording. The shielded content is hidden from the capture but stays visible here. Toggling protection keeps the views' state intact."
)
.font(.footnote)
.foregroundColor(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
}
.padding()
}
.navigationTitle("SwiftUI")
}

private func sectionTitle(_ text: String) -> some View {
Text(text)
.font(.subheadline.weight(.semibold))
.foregroundColor(.secondary)
}

private var balanceCard: some View {
VStack(alignment: .leading, spacing: 6) {
Text("Account balance")
.font(.caption)
.foregroundColor(.white.opacity(0.8))
Text("$ 128,400.00")
.font(.largeTitle.bold())
.foregroundColor(.white)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding()
.background(
RoundedRectangle(cornerRadius: 16)
.fill(LinearGradient(
colors: [.blue, .purple],
startPoint: .topLeading,
endPoint: .bottomTrailing
))
)
}
}

#Preview {
NavigationView {
SwiftUIDemoView()
}
}
Loading
Loading