From a738e9704d59482834a1b228bdde137b7d8b9caf Mon Sep 17 00:00:00 2001 From: OhKanghoon Date: Mon, 13 Jul 2026 21:19:45 +0900 Subject: [PATCH 1/5] feat: add SwiftUI screen-shield support Add a `.screenShield(_:)` view modifier that hides content from screenshots and screen recordings while it stays visible on device. The mechanism is selected at runtime by OS version: - iOS 18 / macOS 15 / tvOS 18 / watchOS 11 / visionOS 2+: activates SwiftUI's native capture-redaction reason in the environment (transparent to layout). - iOS 16-17 / macOS 13-14 / tvOS 16-17 / visionOS 1: bridges the content through a hosting controller and applies the Core Animation capture flag to its backing layer. Toggling protection only flips the underlying flag, so it never changes the view's identity or resets the wrapped content's state. --- .../RedactionReasons+ScreenShield.swift | 28 ++++ .../SwiftUI/Internal/ScreenShieldBridge.swift | 157 ++++++++++++++++++ .../SwiftUI/View+ScreenShield.swift | 95 +++++++++++ 3 files changed, 280 insertions(+) create mode 100644 Sources/ScreenShieldKit/SwiftUI/Internal/RedactionReasons+ScreenShield.swift create mode 100644 Sources/ScreenShieldKit/SwiftUI/Internal/ScreenShieldBridge.swift create mode 100644 Sources/ScreenShieldKit/SwiftUI/View+ScreenShield.swift diff --git a/Sources/ScreenShieldKit/SwiftUI/Internal/RedactionReasons+ScreenShield.swift b/Sources/ScreenShieldKit/SwiftUI/Internal/RedactionReasons+ScreenShield.swift new file mode 100644 index 0000000..6dd00d3 --- /dev/null +++ b/Sources/ScreenShieldKit/SwiftUI/Internal/RedactionReasons+ScreenShield.swift @@ -0,0 +1,28 @@ +// +// RedactionReasons+ScreenShield.swift +// ScreenShieldKit +// +// Created by Kanghoon Oh on 7/12/26. +// + +#if canImport(SwiftUI) +import SwiftUI + +@available(iOS 18.0, macOS 15.0, tvOS 18.0, watchOS 11.0, visionOS 2.0, *) +extension RedactionReasons { + /// SwiftUI's private "prohibit screen capture" redaction reason. + /// + /// Starting with iOS 18 (and the matching releases on other platforms), SwiftUI can hide a view + /// from screenshots, screen recordings, and other system captures by activating this redaction + /// reason in the environment while leaving the on-device rendering untouched. + /// + /// The value is constructed directly from its raw option bit (`1 << 3`) instead of binding to the + /// underlying SwiftUI SPI symbol. This produces the exact option value SwiftUI checks when it + /// redacts captured content, while keeping ScreenShieldKit free of any private symbol reference — + /// there is no selector string and no `@_silgen_name` binding to flag during static analysis. + /// + /// - Note: This relies on private platform behavior and may change in a future OS version. Callers + /// never touch this value directly; it is applied through ``View/screenShield(_:)``. + static let screenCaptureProhibited: RedactionReasons = .init(rawValue: 1 << 3) +} +#endif diff --git a/Sources/ScreenShieldKit/SwiftUI/Internal/ScreenShieldBridge.swift b/Sources/ScreenShieldKit/SwiftUI/Internal/ScreenShieldBridge.swift new file mode 100644 index 0000000..ee17fb9 --- /dev/null +++ b/Sources/ScreenShieldKit/SwiftUI/Internal/ScreenShieldBridge.swift @@ -0,0 +1,157 @@ +// +// ScreenShieldBridge.swift +// ScreenShieldKit +// +// Created by Kanghoon Oh on 7/12/26. +// +// Legacy fallback used below the OS versions that ship SwiftUI's native capture-redaction reason +// (iOS 18 / macOS 15 / tvOS 18 / visionOS 2). It hosts the SwiftUI content inside a platform hosting +// controller and applies the Core Animation capture flag (`CALayer.setScreenShield(enabled:)`) to +// the backing layer. +// +// Identity note: the hosting container is always present regardless of `isProtected`. Only the layer +// flag is toggled in the update pass, so switching protection on/off never tears down and rebuilds +// the hosted content (which would reset its state). +// + +#if canImport(SwiftUI) +import SwiftUI + +#if os(iOS) || os(tvOS) || os(visionOS) +import UIKit + +/// Bridges SwiftUI content to a `UIHostingController` whose layer carries the capture flag. +/// +/// A `UIHostingController` starts a fresh SwiftUI environment, so ancestor `@Environment` values are +/// not propagated automatically. The most impactful values are forwarded here; forward additional +/// values by reading them in a parent and passing them into the shielded content if your UI needs it. +@available(iOS 16.0, tvOS 16.0, visionOS 1.0, *) +struct ScreenShieldBridge: View { + let isProtected: Bool + let content: Content + + @Environment(\.colorScheme) private var colorScheme + @Environment(\.layoutDirection) private var layoutDirection + @Environment(\.dynamicTypeSize) private var dynamicTypeSize + + init(isProtected: Bool, content: Content) { + self.isProtected = isProtected + self.content = content + } + + var body: some View { + ScreenShieldHostingController(isProtected: isProtected) { + content + .environment(\.colorScheme, colorScheme) + .environment(\.layoutDirection, layoutDirection) + .environment(\.dynamicTypeSize, dynamicTypeSize) + } + } +} + +@available(iOS 16.0, tvOS 16.0, visionOS 1.0, *) +private struct ScreenShieldHostingController: UIViewControllerRepresentable { + let isProtected: Bool + let content: Content + + init(isProtected: Bool, @ViewBuilder content: () -> Content) { + self.isProtected = isProtected + self.content = content() + } + + func makeUIViewController(context: Context) -> UIHostingController { + let controller = UIHostingController(rootView: content) + controller.view.backgroundColor = .clear + controller.view.layer.setScreenShield(enabled: isProtected) + return controller + } + + func updateUIViewController(_ controller: UIHostingController, context: Context) { + controller.rootView = content + controller.view.layer.setScreenShield(enabled: isProtected) + } + + /// Size the representable to the hosted SwiftUI content. Relying on `sizingOptions` alone does not + /// reliably propagate height into the surrounding SwiftUI layout on iOS 16/17, so the size is + /// resolved explicitly against the proposed width (letting `maxWidth: .infinity` content fill it). + func sizeThatFits( + _ proposal: ProposedViewSize, + uiViewController: UIHostingController, + context: Context + ) -> CGSize? { + let targetWidth = proposal.width.flatMap { $0.isFinite ? $0 : nil } ?? .greatestFiniteMagnitude + return uiViewController.sizeThatFits( + in: CGSize(width: targetWidth, height: .greatestFiniteMagnitude) + ) + } +} + +#elseif os(macOS) +import AppKit + +/// Bridges SwiftUI content to an `NSHostingController` whose backing layer carries the capture flag. +/// +/// - Note: On macOS, layer-level capture hiding is best-effort and may not hide content from every +/// system capture path. Verify on the macOS versions you support. +@available(macOS 13.0, *) +struct ScreenShieldBridge: View { + let isProtected: Bool + let content: Content + + @Environment(\.colorScheme) private var colorScheme + @Environment(\.layoutDirection) private var layoutDirection + @Environment(\.dynamicTypeSize) private var dynamicTypeSize + + init(isProtected: Bool, content: Content) { + self.isProtected = isProtected + self.content = content + } + + var body: some View { + ScreenShieldHostingController(isProtected: isProtected) { + content + .environment(\.colorScheme, colorScheme) + .environment(\.layoutDirection, layoutDirection) + .environment(\.dynamicTypeSize, dynamicTypeSize) + } + } +} + +@available(macOS 13.0, *) +private struct ScreenShieldHostingController: NSViewControllerRepresentable { + let isProtected: Bool + let content: Content + + init(isProtected: Bool, @ViewBuilder content: () -> Content) { + self.isProtected = isProtected + self.content = content() + } + + func makeNSViewController(context: Context) -> NSHostingController { + let controller = NSHostingController(rootView: content) + controller.view.wantsLayer = true + controller.view.setScreenShield(enabled: isProtected) + return controller + } + + func updateNSViewController(_ controller: NSHostingController, context: Context) { + controller.rootView = content + controller.view.wantsLayer = true + controller.view.setScreenShield(enabled: isProtected) + } + + /// Size the representable to the hosted SwiftUI content, resolving against the proposed width so + /// that `maxWidth: .infinity` content fills it rather than shrink-wrapping. + func sizeThatFits( + _ proposal: ProposedViewSize, + nsViewController: NSHostingController, + context: Context + ) -> CGSize? { + let targetWidth = proposal.width.flatMap { $0.isFinite ? $0 : nil } ?? .greatestFiniteMagnitude + return nsViewController.sizeThatFits( + in: CGSize(width: targetWidth, height: .greatestFiniteMagnitude) + ) + } +} +#endif +#endif diff --git a/Sources/ScreenShieldKit/SwiftUI/View+ScreenShield.swift b/Sources/ScreenShieldKit/SwiftUI/View+ScreenShield.swift new file mode 100644 index 0000000..abc7a9f --- /dev/null +++ b/Sources/ScreenShieldKit/SwiftUI/View+ScreenShield.swift @@ -0,0 +1,95 @@ +// +// View+ScreenShield.swift +// ScreenShieldKit +// +// Created by Kanghoon Oh on 7/12/26. +// + +#if canImport(SwiftUI) +import SwiftUI + +extension View { + /// Hides this view from screenshots, screen recordings, and other system captures while it stays + /// visible on device. + /// + /// This is the primitive that powers ScreenShieldKit's SwiftUI support. + /// + /// ```swift + /// Text(oneTimeCode) + /// .screenShield() // always protected + /// + /// CardView() + /// .screenShield(isSensitive) // protection driven by state + /// ``` + /// + /// ## Dynamic protection + /// + /// Pass a `Bool` that participates in SwiftUI state to toggle protection at runtime. The modifier + /// keeps a stable view identity across toggles: it never inserts or removes views based on + /// `isProtected`, it only flips the underlying capture flag. As a result, toggling protection does + /// **not** reset the `@State`, scroll position, focus, or in-flight animations of the wrapped + /// content. + /// + /// ## Implementation + /// + /// The mechanism is selected at runtime by OS version: + /// + /// - On iOS 18 / macOS 15 / tvOS 18 / watchOS 11 / visionOS 2 and later, it activates SwiftUI's + /// native capture-redaction reason in the environment. This path is fully transparent to layout + /// and does not introduce a hosting boundary. + /// - On earlier releases (down to iOS 16 / macOS 13 / tvOS 16 / visionOS 1) it bridges the content + /// through a hosting controller and applies the Core Animation capture flag to its backing layer. + /// Because this path re-hosts the content, some ancestor `@Environment` values are not propagated + /// automatically; the most common ones (color scheme, layout direction, Dynamic Type size) are + /// forwarded for you. + /// + /// - Parameter isProtected: A Boolean value that determines whether the content is hidden from + /// captures. Defaults to `true`. + /// - Returns: A view whose capture visibility is configured. + /// + /// - Important: ScreenShieldKit relies on private platform behavior. If the API is unavailable on a + /// given device the call becomes a safe no-op. Verify capture protection on every OS version you + /// support. + @available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 11.0, visionOS 1.0, *) + @ViewBuilder + public func screenShield(_ isProtected: Bool = true) -> some View { + if #available(iOS 18.0, macOS 15.0, tvOS 18.0, watchOS 11.0, visionOS 2.0, *) { + modifier(ScreenCaptureRedactionModifier(isProtected: isProtected)) + } else { + #if os(watchOS) + // watchOS ships the capture-redaction reason only from watchOS 11, which is this API's floor, + // so this legacy branch is never reached there; render unchanged to stay exhaustive. + self + #else + ScreenShieldBridge(isProtected: isProtected, content: self) + #endif + } + } +} + +/// Applies SwiftUI's native capture-redaction reason to the wrapped content. +/// +/// Implemented as a single `ViewModifier` — rather than an `if hidden { … } else { … }` branch — so +/// that flipping `isProtected` only changes a stored value and never changes the view's identity. +/// +/// `privacySensitive(false)` is applied so the content keeps rendering normally on device: activating +/// a redaction reason would otherwise redact any privacy-sensitive descendant in the on-device output +/// too. The capture pipeline still honors ``RedactionReasons/screenCaptureProhibited`` independently, +/// so the content remains hidden from screenshots and recordings. +@available(iOS 18.0, macOS 15.0, tvOS 18.0, watchOS 11.0, visionOS 2.0, *) +private struct ScreenCaptureRedactionModifier: ViewModifier { + var isProtected: Bool + + func body(content: Content) -> some View { + content + .privacySensitive(false) + .transformEnvironment(\.redactionReasons) { reasons in + if isProtected { + reasons.insert(.screenCaptureProhibited) + } else { + reasons.remove(.screenCaptureProhibited) + } + } + } +} +#endif From 298dd3fb77e1103b2759efd8a1c7121476e7ffae Mon Sep 17 00:00:00 2001 From: OhKanghoon Date: Mon, 13 Jul 2026 21:19:52 +0900 Subject: [PATCH 2/5] feat(sample): add SwiftUI demo tab and expand UIKit demo Add a SwiftUI demo screen showcasing the `.screenShield(_:)` modifier with state-driven on/off protection, surface it through a UIKit/SwiftUI tab bar, and expand the existing UIKit demo. Lower the sample's deployment target to iOS 16.0 so both the legacy and native SwiftUI paths can be exercised. --- Sample/Sample.xcodeproj/project.pbxproj | 4 +- Sample/Sample/SceneDelegate.swift | 27 +++- Sample/Sample/SwiftUIDemoView.swift | 86 ++++++++++++ Sample/Sample/ViewController.swift | 166 ++++++++++++++++++++---- 4 files changed, 254 insertions(+), 29 deletions(-) create mode 100644 Sample/Sample/SwiftUIDemoView.swift diff --git a/Sample/Sample.xcodeproj/project.pbxproj b/Sample/Sample.xcodeproj/project.pbxproj index 865e342..4533dc2 100644 --- a/Sample/Sample.xcodeproj/project.pbxproj +++ b/Sample/Sample.xcodeproj/project.pbxproj @@ -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; @@ -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; diff --git a/Sample/Sample/SceneDelegate.swift b/Sample/Sample/SceneDelegate.swift index f1ac2ed..b218ee0 100644 --- a/Sample/Sample/SceneDelegate.swift +++ b/Sample/Sample/SceneDelegate.swift @@ -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 { @@ -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 + } } diff --git a/Sample/Sample/SwiftUIDemoView.swift b/Sample/Sample/SwiftUIDemoView.swift new file mode 100644 index 0000000..45365cd --- /dev/null +++ b/Sample/Sample/SwiftUIDemoView.swift @@ -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() + } +} diff --git a/Sample/Sample/ViewController.swift b/Sample/Sample/ViewController.swift index 4e67f4d..ac0dd97 100644 --- a/Sample/Sample/ViewController.swift +++ b/Sample/Sample/ViewController.swift @@ -2,59 +2,175 @@ // ViewController.swift // Sample // -// Created by Ray on 5/12/25. +// Created by Kanghoon Oh on 5/12/25. // -import UIKit - import ScreenShieldKit +import UIKit +/// Demonstrates ScreenShieldKit's UIKit surface via `UIView.setScreenShield(enabled:)`, driving +/// protection from a switch. Mirrors the SwiftUI demo so both tabs read consistently. final class ViewController: UIViewController { - private let screenShieldSwitch = UISwitch() + private let protectSwitch = UISwitch() + + private let balanceCard = GradientCardView() + + private let balanceTitleLabel: UILabel = { + let label = UILabel() + label.text = "Account balance" + label.font = .preferredFont(forTextStyle: .caption1) + label.textColor = UIColor.white.withAlphaComponent(0.8) + return label + }() + + private let balanceValueLabel: UILabel = { + let label = UILabel() + label.text = "$ 128,400.00" + label.font = .systemFont(ofSize: 34, weight: .bold) + label.textColor = .white + label.adjustsFontSizeToFitWidth = true + label.minimumScaleFactor = 0.6 + return label + }() - private let redView: UIView = { + private let cardNumberContainer: UIView = { let view = UIView() - view.backgroundColor = .systemRed + view.backgroundColor = .secondarySystemBackground + view.layer.cornerRadius = 12 + view.layer.cornerCurve = .continuous return view }() + private let cardNumberLabel: UILabel = { + let label = UILabel() + label.text = "1234 5678 9012 3456" + label.font = .monospacedSystemFont(ofSize: 22, weight: .regular) + label.textAlignment = .center + label.adjustsFontSizeToFitWidth = true + label.minimumScaleFactor = 0.7 + return label + }() + override func viewDidLoad() { super.viewDidLoad() + title = "UIKit" view.backgroundColor = .systemBackground setupUI() - addTargets() + protectSwitch.isOn = true + protectSwitch.addTarget(self, action: #selector(protectSwitchChanged(_:)), for: .valueChanged) + applyProtection(protectSwitch.isOn) } private func setupUI() { - view.addSubview(screenShieldSwitch) - view.addSubview(redView) + let protectLabel = UILabel() + protectLabel.text = "Protect content" + protectLabel.font = .preferredFont(forTextStyle: .body) + protectLabel.setContentHuggingPriority(.defaultLow, for: .horizontal) - screenShieldSwitch.translatesAutoresizingMaskIntoConstraints = false - redView.translatesAutoresizingMaskIntoConstraints = false + let protectRow = UIStackView(arrangedSubviews: [protectLabel, protectSwitch]) + protectRow.axis = .horizontal + protectRow.alignment = .center + + let cardStack = UIStackView(arrangedSubviews: [balanceTitleLabel, balanceValueLabel]) + cardStack.axis = .vertical + cardStack.spacing = 6 + cardStack.translatesAutoresizingMaskIntoConstraints = false + balanceCard.addSubview(cardStack) + NSLayoutConstraint.activate([ + cardStack.topAnchor.constraint(equalTo: balanceCard.topAnchor, constant: 20), + cardStack.leadingAnchor.constraint(equalTo: balanceCard.leadingAnchor, constant: 20), + cardStack.trailingAnchor.constraint(equalTo: balanceCard.trailingAnchor, constant: -20), + cardStack.bottomAnchor.constraint(equalTo: balanceCard.bottomAnchor, constant: -20), + ]) + cardNumberLabel.translatesAutoresizingMaskIntoConstraints = false + cardNumberContainer.addSubview(cardNumberLabel) NSLayoutConstraint.activate([ - screenShieldSwitch.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 60), - screenShieldSwitch.centerXAnchor.constraint(equalTo: view.centerXAnchor), + cardNumberLabel.topAnchor.constraint(equalTo: cardNumberContainer.topAnchor, constant: 18), + cardNumberLabel.leadingAnchor.constraint(equalTo: cardNumberContainer.leadingAnchor, constant: 16), + cardNumberLabel.trailingAnchor.constraint(equalTo: cardNumberContainer.trailingAnchor, constant: -16), + cardNumberLabel.bottomAnchor.constraint(equalTo: cardNumberContainer.bottomAnchor, constant: -18), + ]) - redView.topAnchor.constraint(equalTo: screenShieldSwitch.bottomAnchor, constant: 20), - redView.widthAnchor.constraint(equalToConstant: 200), - redView.heightAnchor.constraint(equalToConstant: 200), - redView.centerXAnchor.constraint(equalTo: view.centerXAnchor), + let footnote = UILabel() + footnote.text = "Take a screenshot or start a screen recording. The shielded content is hidden from the capture but stays visible here." + footnote.font = .preferredFont(forTextStyle: .footnote) + footnote.textColor = .secondaryLabel + footnote.numberOfLines = 0 + + let mainStack = UIStackView(arrangedSubviews: [ + protectRow, + makeSection(title: "Balance card (setScreenShield)", content: balanceCard), + makeSection(title: "Card number (setScreenShield)", content: cardNumberContainer), + footnote, + ]) + mainStack.axis = .vertical + mainStack.spacing = 28 + mainStack.translatesAutoresizingMaskIntoConstraints = false + + let scrollView = UIScrollView() + scrollView.translatesAutoresizingMaskIntoConstraints = false + scrollView.alwaysBounceVertical = true + view.addSubview(scrollView) + scrollView.addSubview(mainStack) + + NSLayoutConstraint.activate([ + scrollView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), + scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + + mainStack.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor, constant: 20), + mainStack.bottomAnchor.constraint(equalTo: scrollView.contentLayoutGuide.bottomAnchor, constant: -20), + mainStack.leadingAnchor.constraint(equalTo: scrollView.frameLayoutGuide.leadingAnchor, constant: 20), + mainStack.trailingAnchor.constraint(equalTo: scrollView.frameLayoutGuide.trailingAnchor, constant: -20), ]) } - private func addTargets() { - screenShieldSwitch.addTarget( - self, - action: #selector(screenShieldSwitchValueChanged(_:)), - for: .valueChanged - ) + private func makeSection(title: String, content: UIView) -> UIStackView { + let label = UILabel() + label.text = title + label.font = .preferredFont(forTextStyle: .subheadline) + label.textColor = .secondaryLabel + + let stack = UIStackView(arrangedSubviews: [label, content]) + stack.axis = .vertical + stack.spacing = 8 + return stack + } + + private func applyProtection(_ enabled: Bool) { + balanceCard.setScreenShield(enabled: enabled) + cardNumberContainer.setScreenShield(enabled: enabled) } @objc - private func screenShieldSwitchValueChanged(_ sender: UISwitch) { - redView.setScreenShield(enabled: sender.isOn) + private func protectSwitchChanged(_ sender: UISwitch) { + applyProtection(sender.isOn) + } +} + +/// A rounded card whose backing layer is a blue → purple gradient. +private final class GradientCardView: UIView { + override class var layerClass: AnyClass { CAGradientLayer.self } + + private var gradientLayer: CAGradientLayer { + layer as! CAGradientLayer + } + + override init(frame: CGRect) { + super.init(frame: frame) + gradientLayer.colors = [UIColor.systemBlue.cgColor, UIColor.systemPurple.cgColor] + gradientLayer.startPoint = CGPoint(x: 0, y: 0) + gradientLayer.endPoint = CGPoint(x: 1, y: 1) + gradientLayer.cornerRadius = 16 + gradientLayer.cornerCurve = .continuous + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") } } From a746a97de5ae0d1b1dd297fa05286262782e46d6 Mon Sep 17 00:00:00 2001 From: OhKanghoon Date: Mon, 13 Jul 2026 21:20:01 +0900 Subject: [PATCH 3/5] docs: expand README with SwiftUI usage and mechanism Document the `.screenShield(_:)` modifier, dynamic on/off protection, and the per-OS mechanism table, alongside the existing UIKit/AppKit/CALayer usage. --- README.md | 120 +++++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 97 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index f7c4858..5ce9e5b 100644 --- a/README.md +++ b/README.md @@ -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: [ @@ -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, so some ancestor `@Environment` values are not propagated automatically; the most common ones (color scheme, layout direction, Dynamic Type size) are forwarded for you. + +## 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 From a29f1d68e36141ad5a6d114c5473e2d3b979282a Mon Sep 17 00:00:00 2001 From: OhKanghoon Date: Mon, 13 Jul 2026 21:20:10 +0900 Subject: [PATCH 4/5] chore: update file header author Update the author line in existing file headers. --- Sample/Sample/AppDelegate.swift | 2 +- Sources/ScreenShieldKit/AppKit/NSView+ScreenShield.swift | 2 +- Sources/ScreenShieldKit/CALayer+ScreenShield.swift | 2 +- Sources/ScreenShieldKit/Logger.swift | 2 +- Sources/ScreenShieldKit/UIKit/UIView+ScreenShield.swift | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Sample/Sample/AppDelegate.swift b/Sample/Sample/AppDelegate.swift index 3034659..aad89a8 100644 --- a/Sample/Sample/AppDelegate.swift +++ b/Sample/Sample/AppDelegate.swift @@ -2,7 +2,7 @@ // AppDelegate.swift // Sample // -// Created by Ray on 5/12/25. +// Created by Kanghoon Oh on 5/12/25. // import UIKit diff --git a/Sources/ScreenShieldKit/AppKit/NSView+ScreenShield.swift b/Sources/ScreenShieldKit/AppKit/NSView+ScreenShield.swift index caf9763..55a3ed6 100644 --- a/Sources/ScreenShieldKit/AppKit/NSView+ScreenShield.swift +++ b/Sources/ScreenShieldKit/AppKit/NSView+ScreenShield.swift @@ -2,7 +2,7 @@ // NSView+ScreenShield.swift // ScreenShieldKit // -// Created by Ray on 5/12/25. +// Created by Kanghoon Oh on 5/12/25. // #if os(macOS) diff --git a/Sources/ScreenShieldKit/CALayer+ScreenShield.swift b/Sources/ScreenShieldKit/CALayer+ScreenShield.swift index b6f0e4b..33029f6 100644 --- a/Sources/ScreenShieldKit/CALayer+ScreenShield.swift +++ b/Sources/ScreenShieldKit/CALayer+ScreenShield.swift @@ -2,7 +2,7 @@ // CALayer+ScreenShield.swift // ScreenShieldKit // -// Created by Ray on 5/12/25. +// Created by Kanghoon Oh on 5/12/25. // import os.log diff --git a/Sources/ScreenShieldKit/Logger.swift b/Sources/ScreenShieldKit/Logger.swift index c733840..17458b5 100644 --- a/Sources/ScreenShieldKit/Logger.swift +++ b/Sources/ScreenShieldKit/Logger.swift @@ -2,7 +2,7 @@ // Logger.swift // ScreenShieldKit // -// Created by Ray on 5/12/25. +// Created by Kanghoon Oh on 5/12/25. // import os.log diff --git a/Sources/ScreenShieldKit/UIKit/UIView+ScreenShield.swift b/Sources/ScreenShieldKit/UIKit/UIView+ScreenShield.swift index b2ebaef..0560fb1 100644 --- a/Sources/ScreenShieldKit/UIKit/UIView+ScreenShield.swift +++ b/Sources/ScreenShieldKit/UIKit/UIView+ScreenShield.swift @@ -2,7 +2,7 @@ // UIView+ScreenShield.swift // ScreenShieldKit // -// Created by Ray on 5/12/25. +// Created by Kanghoon Oh on 5/12/25. // #if os(iOS) From b0b7d03e18e573e3d74d160201ef0a9e22590e43 Mon Sep 17 00:00:00 2001 From: OhKanghoon Date: Tue, 14 Jul 2026 21:02:53 +0900 Subject: [PATCH 5/5] refactor: drop redundant environment forwarding in legacy SwiftUI bridge SwiftUI already propagates the ancestor environment across the representable-managed hosting controller in ScreenShieldBridge: @Environment values (including custom EnvironmentKeys) and @EnvironmentObject dependencies all reach the hosted content, and runtime changes stay live. Verified on an iOS 16.4 simulator with a probe (static value, override, custom key, object, and dynamic update) and by running the real SwiftUIDemoView. Remove the manual colorScheme/layoutDirection/dynamicTypeSize forwarding, which was redundant and incomplete, and correct the docs that claimed the ancestor environment is not propagated across the hosting boundary. --- README.md | 2 +- .../SwiftUI/Internal/ScreenShieldBridge.swift | 24 ++++++------------- .../SwiftUI/View+ScreenShield.swift | 5 ++-- 3 files changed, 10 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 5ce9e5b..8a87037 100644 --- a/README.md +++ b/README.md @@ -123,7 +123,7 @@ ScreenShieldKit selects a mechanism per platform and OS version: | 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, so some ancestor `@Environment` values are not propagated automatically; the most common ones (color scheme, layout direction, Dynamic Type size) are forwarded for you. +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 diff --git a/Sources/ScreenShieldKit/SwiftUI/Internal/ScreenShieldBridge.swift b/Sources/ScreenShieldKit/SwiftUI/Internal/ScreenShieldBridge.swift index ee17fb9..fb2e3b2 100644 --- a/Sources/ScreenShieldKit/SwiftUI/Internal/ScreenShieldBridge.swift +++ b/Sources/ScreenShieldKit/SwiftUI/Internal/ScreenShieldBridge.swift @@ -22,18 +22,14 @@ import UIKit /// Bridges SwiftUI content to a `UIHostingController` whose layer carries the capture flag. /// -/// A `UIHostingController` starts a fresh SwiftUI environment, so ancestor `@Environment` values are -/// not propagated automatically. The most impactful values are forwarded here; forward additional -/// values by reading them in a parent and passing them into the shielded content if your UI needs it. +/// SwiftUI keeps propagating the ancestor environment across this representable-managed hosting +/// controller, so `@Environment` values (including custom `EnvironmentKey`s) and `@EnvironmentObject` +/// dependencies reach the hosted content without any manual forwarding. @available(iOS 16.0, tvOS 16.0, visionOS 1.0, *) struct ScreenShieldBridge: View { let isProtected: Bool let content: Content - @Environment(\.colorScheme) private var colorScheme - @Environment(\.layoutDirection) private var layoutDirection - @Environment(\.dynamicTypeSize) private var dynamicTypeSize - init(isProtected: Bool, content: Content) { self.isProtected = isProtected self.content = content @@ -42,9 +38,6 @@ struct ScreenShieldBridge: View { var body: some View { ScreenShieldHostingController(isProtected: isProtected) { content - .environment(\.colorScheme, colorScheme) - .environment(\.layoutDirection, layoutDirection) - .environment(\.dynamicTypeSize, dynamicTypeSize) } } } @@ -91,6 +84,10 @@ import AppKit /// Bridges SwiftUI content to an `NSHostingController` whose backing layer carries the capture flag. /// +/// SwiftUI keeps propagating the ancestor environment across this representable-managed hosting +/// controller, so `@Environment` values and `@EnvironmentObject` dependencies reach the hosted +/// content without any manual forwarding. +/// /// - Note: On macOS, layer-level capture hiding is best-effort and may not hide content from every /// system capture path. Verify on the macOS versions you support. @available(macOS 13.0, *) @@ -98,10 +95,6 @@ struct ScreenShieldBridge: View { let isProtected: Bool let content: Content - @Environment(\.colorScheme) private var colorScheme - @Environment(\.layoutDirection) private var layoutDirection - @Environment(\.dynamicTypeSize) private var dynamicTypeSize - init(isProtected: Bool, content: Content) { self.isProtected = isProtected self.content = content @@ -110,9 +103,6 @@ struct ScreenShieldBridge: View { var body: some View { ScreenShieldHostingController(isProtected: isProtected) { content - .environment(\.colorScheme, colorScheme) - .environment(\.layoutDirection, layoutDirection) - .environment(\.dynamicTypeSize, dynamicTypeSize) } } } diff --git a/Sources/ScreenShieldKit/SwiftUI/View+ScreenShield.swift b/Sources/ScreenShieldKit/SwiftUI/View+ScreenShield.swift index abc7a9f..c688a33 100644 --- a/Sources/ScreenShieldKit/SwiftUI/View+ScreenShield.swift +++ b/Sources/ScreenShieldKit/SwiftUI/View+ScreenShield.swift @@ -39,9 +39,8 @@ extension View { /// and does not introduce a hosting boundary. /// - On earlier releases (down to iOS 16 / macOS 13 / tvOS 16 / visionOS 1) it bridges the content /// through a hosting controller and applies the Core Animation capture flag to its backing layer. - /// Because this path re-hosts the content, some ancestor `@Environment` values are not propagated - /// automatically; the most common ones (color scheme, layout direction, Dynamic Type size) are - /// forwarded for you. + /// SwiftUI keeps propagating the ancestor environment across this boundary, so `@Environment` + /// values and `@EnvironmentObject` dependencies still reach the shielded content. /// /// - Parameter isProtected: A Boolean value that determines whether the content is hidden from /// captures. Defaults to `true`.