From 16f3b4dcfdae9ca57a52269cb1a9c50eaf68406b Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Thu, 2 Apr 2026 15:13:27 -0600 Subject: [PATCH] feat: add Lockdown Mode warning sheet for iOS editor When iOS Lockdown Mode is enabled, WebKit JIT compilation is disabled, which can degrade the editor's performance and functionality. This adds detection via WKWebpagePreferences and presents a warning sheet with a link to Apple's support article on excluding apps from Lockdown Mode. - Detect lockdown mode after WebView navigation completes - Present dismissible half-sheet with warning and "Learn More" link - Re-check on foreground return to handle Settings changes - Skip editor autofocus when lockdown mode is active - All strings routed through EditorLocalization for host app overrides Co-Authored-By: Claude Opus 4.6 --- .../Sources/EditorLocalization.swift | 12 + .../Sources/EditorViewController.swift | 47 ++- .../Services/LockdownModeMonitor.swift | 201 +++++++++++++ .../Views/LockdownModeBannerView.swift | 82 ++++++ .../Services/LockdownModeMonitorTests.swift | 271 ++++++++++++++++++ 5 files changed, 605 insertions(+), 8 deletions(-) create mode 100644 ios/Sources/GutenbergKit/Sources/Services/LockdownModeMonitor.swift create mode 100644 ios/Sources/GutenbergKit/Sources/Views/LockdownModeBannerView.swift create mode 100644 ios/Tests/GutenbergKitTests/Services/LockdownModeMonitorTests.swift diff --git a/ios/Sources/GutenbergKit/Sources/EditorLocalization.swift b/ios/Sources/GutenbergKit/Sources/EditorLocalization.swift index 6f11ad9ae..8d8173d83 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorLocalization.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorLocalization.swift @@ -21,6 +21,13 @@ public enum EditorLocalizableString { // MARK: - Editor Loading case loadingEditor case editorError + + // MARK: - Lockdown Mode + case lockdownModeTitle + case lockdownModeWarning + case lockdownModeExcludeHint + case lockdownModeLearnMore + case lockdownModeDismiss } /// Provides localized strings for the editor. @@ -46,6 +53,11 @@ public final class EditorLocalization { case .patternsCategoryAll: "All" case .loadingEditor: "Loading Editor" case .editorError: "Editor Error" + case .lockdownModeTitle: "Lockdown Mode Detected" + case .lockdownModeWarning: "Lockdown Mode is enabled. The editor may not work correctly." + case .lockdownModeExcludeHint: "You can exclude this app from Lockdown Mode in Settings to restore full functionality." + case .lockdownModeLearnMore: "Learn More" + case .lockdownModeDismiss: "Dismiss" } } diff --git a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift index bfd11bc82..38d9bdeb6 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift @@ -109,6 +109,7 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro private let mediaPicker: MediaPickerController? private let controller: GutenbergEditorController private let bundleProvider: EditorAssetBundleProvider + private let lockdownModeMonitor: LockdownModeMonitor // MARK: - Private Properties (UI) @@ -170,7 +171,8 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro ) self.bundleProvider = EditorAssetBundleProvider(httpClient: httpClient) self.mediaPicker = mediaPicker - self.controller = GutenbergEditorController(configuration: configuration) + self.lockdownModeMonitor = LockdownModeMonitor() + self.controller = GutenbergEditorController(configuration: configuration, lockdownModeMonitor: self.lockdownModeMonitor) // The `allowFileAccessFromFileURLs` allows the web view to access the // files from the local filesystem. @@ -211,6 +213,14 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro controller.delegate = self webView.navigationDelegate = controller + // Set up Lockdown Mode monitoring with foreground detection + lockdownModeMonitor.setup( + presentingViewController: self, + onResetReadyState: { [weak self] in + self?.isReady = false + } + ) + // FIXME: implement with CSS (bottom toolbar) webView.scrollView.verticalScrollIndicatorInsets = UIEdgeInsets(top: 0, left: 0, bottom: 47, right: 0) @@ -223,7 +233,7 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro webView.bottomAnchor.constraint(equalTo: view.keyboardLayoutGuide.topAnchor) ]) - // WebView starts hidden; fades in when editor is ready (see didLoadEditor()) + // WebView starts hidden and fades in when editor navigation completes webView.alpha = 0 // Warmup mode - load HTML without dependencies for WebKit prewarming @@ -583,6 +593,21 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro evaluate("editor.appendTextAtCursor(decodeURIComponent('\(escapedText)'));") } + /// Sets focus to the editor if the content is empty. + /// + /// This method programmatically focuses the editor, placing the cursor in the content area + /// so the user can begin typing immediately. Focus is only applied when the editor is displaying + /// empty content to avoid disrupting existing content editing. + /// + /// Use the `force` parameter to apply focus even when there is content in the editor. + /// + /// - Parameter force: When true, applies focus even when there is content in the editor. + public func focus(force: Bool = false) { + if force || configuration.content.isEmpty { + evaluate("editor.focus();") + } + } + // MARK: - Navigation Overlay private func setupNavigationOverlay() { @@ -712,20 +737,23 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro self.hideActivityView() self.isReady = true - // Fade in the WebView - it was hidden (alpha = 0) since viewDidLoad() + // Fade in the WebView now that navigation is complete UIView.animate(withDuration: 0.2, delay: 0.1, options: [.allowUserInteraction]) { self.webView.alpha = 1 } + // If lockdown mode was detected, show the sheet — skip autofocus entirely + // since the editor may not function correctly with Lockdown Mode restrictions. + if !lockdownModeMonitor.isLockdownModeEnabled { + self.focus() + } + lockdownModeMonitor.presentSheetIfNeeded(onDismiss: {}) + // Log performance timing for monitoring let duration = CFAbsoluteTimeGetCurrent() - timestampInit print("gutenbergkit-measure_editor-first-render:", duration) delegate?.editorDidLoad(self) - - if configuration.content.isEmpty { - evaluate("editor.focus();") - } } // MARK: - Warmup @@ -762,9 +790,11 @@ private final class GutenbergEditorController: NSObject, WKNavigationDelegate, W weak var delegate: GutenbergEditorControllerDelegate? let configuration: EditorConfiguration private let navigationPolicy: EditorNavigationPolicy + private let lockdownModeMonitor: LockdownModeMonitor - init(configuration: EditorConfiguration) { + init(configuration: EditorConfiguration, lockdownModeMonitor: LockdownModeMonitor) { self.configuration = configuration + self.lockdownModeMonitor = lockdownModeMonitor let devServerURL = ProcessInfo.processInfo.environment["GUTENBERG_EDITOR_URL"].flatMap(URL.init) self.navigationPolicy = EditorNavigationPolicy(devServerURL: devServerURL) super.init() @@ -795,6 +825,7 @@ private final class GutenbergEditorController: NSObject, WKNavigationDelegate, W func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { NSLog("navigation: \(String(describing: navigation))") + lockdownModeMonitor.detectLockdownMode(for: webView) } func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { diff --git a/ios/Sources/GutenbergKit/Sources/Services/LockdownModeMonitor.swift b/ios/Sources/GutenbergKit/Sources/Services/LockdownModeMonitor.swift new file mode 100644 index 000000000..94e67b4f9 --- /dev/null +++ b/ios/Sources/GutenbergKit/Sources/Services/LockdownModeMonitor.swift @@ -0,0 +1,201 @@ +import Foundation +import SwiftUI +import WebKit +import OSLog + +#if canImport(UIKit) +import UIKit + +/// Protocol for objects that can be checked for Lockdown Mode status. +/// +/// This protocol enables testability by allowing mock implementations +/// that simulate different Lockdown Mode states. +@MainActor +protocol LockdownModeDetectable: AnyObject { + /// Returns `true` if Lockdown Mode is enabled for this object. + var isLockdownModeEnabled: Bool { get } + + /// Reloads the content if supported by this object. + func reloadForLockdownMode() +} + +/// Extension to make WKWebView conform to LockdownModeDetectable. +extension WKWebView: LockdownModeDetectable { + var isLockdownModeEnabled: Bool { + configuration.defaultWebpagePreferences.isLockdownModeEnabled + } + + // WKWebView.reload() returns WKNavigation?, which doesn't satisfy the + // Void-returning protocol requirement. This wrapper discards the result. + func reloadForLockdownMode() { + _ = reload() as WKNavigation? + } +} + +/// Monitors Lockdown Mode status and presents warning UI when needed. +/// +/// This class handles detection of iOS Lockdown Mode in the WebView and manages +/// the presentation of a warning sheet to inform users about potential editor limitations. +@MainActor +class LockdownModeMonitor: ObservableObject { + + @Published + public var isLockdownModeEnabled: Bool + + /// Indicates whether the Lockdown Mode sheet has been shown to the user. + private var hasShownSheet = false + + /// Indicates whether we should show the lockdown sheet on next editor load. + private var shouldShowSheet = false + + /// Weak reference to the view controller that will present the sheet. + private weak var presentingViewController: UIViewController? + + /// Weak reference to the detectable object for reloading on foreground. + private weak var detectable: LockdownModeDetectable? + + /// Callback invoked when the editor needs to reset its ready state. + private var onResetReadyState: (() -> Void)? + + init(isLockdownModeEnabled: Bool = false) { + self.isLockdownModeEnabled = isLockdownModeEnabled + } + + deinit { + NotificationCenter.default.removeObserver(self) + } + + /// Detects Lockdown Mode status in the detectable object and triggers sheet presentation if needed. + /// + /// - Parameter detectable: The object to check for Lockdown Mode status. + public func detectLockdownMode(for detectable: LockdownModeDetectable) { + Logger.navigation.debug("Detecting Lockdown Mode") + + // Store weak reference to detectable object for later use (foreground reloads) + self.detectable = detectable + + let wasEnabled = self.isLockdownModeEnabled + self.isLockdownModeEnabled = detectable.isLockdownModeEnabled + + // Handle transition from disabled to enabled: show sheet + if self.isLockdownModeEnabled && !wasEnabled && !hasShownSheet { + shouldShowSheet = true + } + + // Handle transition from enabled to disabled: clear sheet state + // This happens when user excludes app from Lockdown Mode + if !self.isLockdownModeEnabled && wasEnabled { + hasShownSheet = false + shouldShowSheet = false + } + } + + /// Sets up the monitor with required dependencies and starts observing foreground notifications. + /// + /// - Parameters: + /// - viewController: The view controller to use for sheet presentation. + /// - onResetReadyState: Callback invoked when the editor should reset its ready state. + public func setup( + presentingViewController viewController: UIViewController, + onResetReadyState: @escaping () -> Void + ) { + self.presentingViewController = viewController + self.onResetReadyState = onResetReadyState + + // Observe foreground notifications to re-check Lockdown Mode + NotificationCenter.default.addObserver( + self, + selector: #selector(handleWillEnterForeground), + name: UIApplication.willEnterForegroundNotification, + object: nil + ) + } + + @objc private func handleWillEnterForeground() { + Logger.navigation.debug("Will enter foreground") + + // Always re-check on foreground to detect Lockdown Mode state changes. + // This handles both: + // 1. User excluded app from Lockdown Mode while in Settings (enabled -> disabled) + // 2. User enabled Lockdown Mode for app while in Settings (disabled -> enabled) + + // Reset the monitor to allow re-detection and potentially show sheet again + resetForForegroundCheck() + + // Reset the editor's ready state so it goes through loading flow again + onResetReadyState?() + + // Dismiss the sheet if presented, then reload to re-run detection + dismissSheetIfPresented { [weak self] in + // Reload triggers navigation delegate which calls detectLockdownMode() + // If Lockdown Mode state changed: + // - Now enabled: sheet will be shown + // - Now disabled: editor will fade in normally without sheet + self?.detectable?.reloadForLockdownMode() + } + } + + /// Presents the Lockdown Mode warning sheet if needed. + /// + /// - Parameters: + /// - onDismiss: Callback invoked when the user dismisses the sheet. + /// - Returns: `true` if the sheet was presented, `false` otherwise. + @discardableResult + public func presentSheetIfNeeded(onDismiss: @escaping () -> Void) -> Bool { + guard shouldShowSheet, let presentingViewController else { + return false + } + + hasShownSheet = true + shouldShowSheet = false + + let sheetView = LockdownModeSheet( + onDismiss: { [weak presentingViewController] in + guard let presentingViewController else { return } + presentingViewController.dismiss(animated: true) { + onDismiss() + } + }, + onLearnMore: { + // Open support article directly to the exclusion section using text fragment + if let url = URL(string: "https://support.apple.com/en-us/105120#:~:text=How%20to%20exclude%20apps%20or%20websites%20from%20Lockdown%20Mode") { + UIApplication.shared.open(url) + } + } + ) + + let hostingController = UIHostingController(rootView: sheetView) + hostingController.modalPresentationStyle = .pageSheet + hostingController.isModalInPresentation = true + + if let sheet = hostingController.sheetPresentationController { + sheet.detents = [.medium()] + sheet.prefersGrabberVisible = false + } + + presentingViewController.present(hostingController, animated: true) + return true + } + + /// Resets the monitor state to re-check Lockdown Mode status. + /// + /// Call this when the app returns from background to re-evaluate Lockdown Mode + /// and potentially show the sheet again if it's still enabled. + public func resetForForegroundCheck() { + hasShownSheet = false + } + + /// Dismisses the sheet if it's currently presented. + /// + /// - Parameter completion: Optional callback invoked after dismissal completes. + public func dismissSheetIfPresented(completion: (() -> Void)? = nil) { + guard let presentingViewController, presentingViewController.presentedViewController != nil else { + completion?() + return + } + + presentingViewController.dismiss(animated: false, completion: completion) + } +} + +#endif diff --git a/ios/Sources/GutenbergKit/Sources/Views/LockdownModeBannerView.swift b/ios/Sources/GutenbergKit/Sources/Views/LockdownModeBannerView.swift new file mode 100644 index 000000000..3a60a0cd8 --- /dev/null +++ b/ios/Sources/GutenbergKit/Sources/Views/LockdownModeBannerView.swift @@ -0,0 +1,82 @@ +import SwiftUI + +#if canImport(UIKit) +import UIKit + +/// A sheet that warns users about Lockdown Mode potentially affecting editor functionality. +/// +/// Lockdown Mode applies additional security restrictions to WebKit that can +/// impact the performance and functionality of the Gutenberg editor. +struct LockdownModeSheet: View { + let onDismiss: () -> Void + let onLearnMore: () -> Void + + var body: some View { + VStack { + VStack(spacing: 20) { + HStack { + Image(systemName: "exclamationmark.triangle.fill") + .font(.title2) + .foregroundColor(.orange) + + Text(EditorLocalization.localize(.lockdownModeTitle)) + .font(.headline) + + Spacer() + } + + VStack(alignment: .leading, spacing: 12) { + Text(EditorLocalization.localize(.lockdownModeWarning)) + .font(.body) + .foregroundColor(.secondary) + + Text(EditorLocalization.localize(.lockdownModeExcludeHint)) + .font(.body) + .foregroundColor(.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + }.padding(24) + + Spacer(minLength: 0) + + VStack(spacing: 12) { + Button { + onLearnMore() + } label: { + Text(EditorLocalization.localize(.lockdownModeLearnMore)) + .font(.body) + .fontWeight(.semibold) + .foregroundStyle(.white) + .frame(maxWidth: .infinity) + .padding(.vertical, 14) + .background(Color.accentColor, in: RoundedRectangle(cornerRadius: 10)) + } + .buttonStyle(.plain) + + Button { + onDismiss() + } label: { + Text(EditorLocalization.localize(.lockdownModeDismiss)) + .font(.body) + .foregroundStyle(.primary) + .frame(maxWidth: .infinity) + .padding(.vertical, 14) + } + .buttonStyle(.plain) + } + .padding(24) + .background(Color(UIColor.systemBackground)) + }.ignoresSafeArea(edges: .bottom) + } +} + +#Preview { + NavigationStack { + VStack {}.navigationTitle("Demo") + }.sheet(isPresented: .constant(true)) { + LockdownModeSheet(onDismiss: {}, onLearnMore: {}) + .presentationDetents([.medium]) + } +} + +#endif diff --git a/ios/Tests/GutenbergKitTests/Services/LockdownModeMonitorTests.swift b/ios/Tests/GutenbergKitTests/Services/LockdownModeMonitorTests.swift new file mode 100644 index 000000000..c158a1b76 --- /dev/null +++ b/ios/Tests/GutenbergKitTests/Services/LockdownModeMonitorTests.swift @@ -0,0 +1,271 @@ +import Foundation +import Testing +import WebKit +import UIKit + +@testable import GutenbergKit + +@Suite +@MainActor +struct LockdownModeMonitorTests { + + // MARK: - Test Helpers + + private func makeMonitor() -> LockdownModeMonitor { + return LockdownModeMonitor() + } + + /// Mock implementation of LockdownModeDetectable for testing. + @MainActor + private class MockLockdownModeDetectable: LockdownModeDetectable { + var isLockdownModeEnabled: Bool + var reloadCallCount = 0 + + init(isLockdownModeEnabled: Bool) { + self.isLockdownModeEnabled = isLockdownModeEnabled + } + + func reloadForLockdownMode() { + reloadCallCount += 1 + } + } + + // MARK: - Initialization Tests + + @Test("Monitor initializes with Lockdown Mode disabled") + func monitorInitializesWithLockdownModeDisabled() { + let monitor = makeMonitor() + #expect(monitor.isLockdownModeEnabled == false) + } + + @Test("Monitor can be initialized with Lockdown Mode enabled") + func monitorCanBeInitializedWithLockdownModeEnabled() { + let monitor = LockdownModeMonitor(isLockdownModeEnabled: true) + #expect(monitor.isLockdownModeEnabled == true) + } + + // MARK: - Setup Tests + + @Test("Setup stores callback without invoking it") + func setupStoresCallbackWithoutInvokingIt() { + let monitor = makeMonitor() + let viewController = UIViewController() + var callbackInvoked = false + + monitor.setup( + presentingViewController: viewController, + onResetReadyState: { + callbackInvoked = true + } + ) + + #expect(!callbackInvoked) + } + + // MARK: - Detection Logic Tests + + @Test("detectLockdownMode updates isLockdownModeEnabled property") + func detectLockdownModeUpdatesProperty() { + let monitor = makeMonitor() + let webView = WKWebView(frame: .zero, configuration: WKWebViewConfiguration()) + + monitor.detectLockdownMode(for: webView) + + #expect(monitor.isLockdownModeEnabled == webView.configuration.defaultWebpagePreferences.isLockdownModeEnabled) + } + + @Test("State transitions from enabled to disabled clear internal flags") + func stateTransitionFromEnabledToDisabledClearsFlags() { + let monitor = LockdownModeMonitor(isLockdownModeEnabled: true) + let webView = WKWebView(frame: .zero, configuration: WKWebViewConfiguration()) + + monitor.detectLockdownMode(for: webView) + + #expect(monitor.isLockdownModeEnabled == webView.configuration.defaultWebpagePreferences.isLockdownModeEnabled) + } + + // MARK: - Sheet Presentation Tests + + @Test("presentSheetIfNeeded returns false when not needed") + func presentSheetIfNeededReturnsFalseWhenNotNeeded() { + let monitor = makeMonitor() + + let result = monitor.presentSheetIfNeeded {} + + #expect(result == false) + } + + @Test("presentSheetIfNeeded returns false without presenting view controller") + func presentSheetIfNeededReturnsFalseWithoutViewController() { + let monitor = makeMonitor() + + let result = monitor.presentSheetIfNeeded {} + + #expect(result == false) + } + + // MARK: - Foreground Handling Tests + + @Test("dismissSheetIfPresented calls completion immediately when no sheet") + func dismissSheetCallsCompletionImmediatelyWithoutSheet() { + let monitor = makeMonitor() + let viewController = UIViewController() + + monitor.setup( + presentingViewController: viewController, + onResetReadyState: {} + ) + + var completionCalled = false + monitor.dismissSheetIfPresented { + completionCalled = true + } + + #expect(completionCalled == true) + } + + // MARK: - Scenario Tests + + @Test("Scenario: User excludes app from Lockdown Mode") + func scenarioUserExcludesAppFromLockdownMode() { + let monitor = LockdownModeMonitor(isLockdownModeEnabled: true) + + let webView = WKWebView(frame: .zero, configuration: WKWebViewConfiguration()) + monitor.detectLockdownMode(for: webView) + + #expect(monitor.isLockdownModeEnabled == false) + } + + @Test("Scenario: Lockdown Mode remains enabled after detection") + func scenarioLockdownModeRemainsEnabled() { + let monitor = LockdownModeMonitor(isLockdownModeEnabled: true) + + #expect(monitor.isLockdownModeEnabled == true) + } + + // MARK: - Edge Case Tests + + @Test("Multiple detectLockdownMode calls handle gracefully") + func multipleDetectCallsHandleGracefully() { + let monitor = makeMonitor() + let webView = WKWebView(frame: .zero, configuration: WKWebViewConfiguration()) + + monitor.detectLockdownMode(for: webView) + monitor.detectLockdownMode(for: webView) + monitor.detectLockdownMode(for: webView) + + #expect(monitor.isLockdownModeEnabled == webView.configuration.defaultWebpagePreferences.isLockdownModeEnabled) + } + + @Test("Setup can be called multiple times") + func setupCanBeCalledMultipleTimes() { + let monitor = makeMonitor() + let vc1 = UIViewController() + let vc2 = UIViewController() + + monitor.setup(presentingViewController: vc1, onResetReadyState: {}) + monitor.setup(presentingViewController: vc2, onResetReadyState: {}) + + // Should handle gracefully + } + + // MARK: - Mock-Based Detection Tests + + @Test("Mock detectable with Lockdown Mode enabled updates monitor state") + func mockDetectableWithLockdownEnabledUpdatesState() { + let monitor = makeMonitor() + let mockDetectable = MockLockdownModeDetectable(isLockdownModeEnabled: true) + + monitor.detectLockdownMode(for: mockDetectable) + + #expect(monitor.isLockdownModeEnabled == true) + } + + @Test("Mock detectable with Lockdown Mode disabled updates monitor state") + func mockDetectableWithLockdownDisabledUpdatesState() { + let monitor = makeMonitor() + let mockDetectable = MockLockdownModeDetectable(isLockdownModeEnabled: false) + + monitor.detectLockdownMode(for: mockDetectable) + + #expect(monitor.isLockdownModeEnabled == false) + } + + // MARK: - State Transition Tests + // + // These tests verify the detection state machine without calling presentSheetIfNeeded, + // which would trigger UIViewController.present() and deadlock in CI (no window hierarchy). + + @Test("Disabled-to-enabled transition sets isLockdownModeEnabled") + func disabledToEnabledTransitionSetsState() { + let monitor = makeMonitor() + + let disabledMock = MockLockdownModeDetectable(isLockdownModeEnabled: false) + monitor.detectLockdownMode(for: disabledMock) + #expect(monitor.isLockdownModeEnabled == false) + + let enabledMock = MockLockdownModeDetectable(isLockdownModeEnabled: true) + monitor.detectLockdownMode(for: enabledMock) + #expect(monitor.isLockdownModeEnabled == true) + } + + @Test("Enabled-to-disabled transition clears isLockdownModeEnabled") + func enabledToDisabledTransitionClearsState() { + let monitor = makeMonitor() + + let enabledMock = MockLockdownModeDetectable(isLockdownModeEnabled: true) + monitor.detectLockdownMode(for: enabledMock) + #expect(monitor.isLockdownModeEnabled == true) + + let disabledMock = MockLockdownModeDetectable(isLockdownModeEnabled: false) + monitor.detectLockdownMode(for: disabledMock) + #expect(monitor.isLockdownModeEnabled == false) + } + + @Test("Multiple state transitions track correctly") + func multipleStateTransitionsTrackCorrectly() { + let monitor = makeMonitor() + + let enabledMock = MockLockdownModeDetectable(isLockdownModeEnabled: true) + let disabledMock = MockLockdownModeDetectable(isLockdownModeEnabled: false) + + // Disabled -> Enabled + monitor.detectLockdownMode(for: enabledMock) + #expect(monitor.isLockdownModeEnabled == true) + + // Enabled -> Disabled + monitor.detectLockdownMode(for: disabledMock) + #expect(monitor.isLockdownModeEnabled == false) + + // Disabled -> Enabled again + monitor.detectLockdownMode(for: enabledMock) + #expect(monitor.isLockdownModeEnabled == true) + } + + @Test("presentSheetIfNeeded returns false without setup even after detection") + func presentSheetReturnsFalseWithoutSetup() { + let monitor = makeMonitor() + + // Trigger a disabled-to-enabled transition (sets shouldShowSheet) + let enabledMock = MockLockdownModeDetectable(isLockdownModeEnabled: true) + monitor.detectLockdownMode(for: enabledMock) + + // Without setup(), presentingViewController is nil so this returns false + let didPresent = monitor.presentSheetIfNeeded {} + #expect(didPresent == false) + } + + @Test("resetForForegroundCheck allows re-detection") + func resetForForegroundCheckAllowsRedetection() { + let monitor = makeMonitor() + + let enabledMock = MockLockdownModeDetectable(isLockdownModeEnabled: true) + monitor.detectLockdownMode(for: enabledMock) + #expect(monitor.isLockdownModeEnabled == true) + + monitor.resetForForegroundCheck() + + // State should still reflect the last detection + #expect(monitor.isLockdownModeEnabled == true) + } +}