From 840926fd230a7c8d8dd033e8d8e682c19d443ff8 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 1/2] 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 --- Package.resolved | 11 +- .../Sources/EditorLocalization.swift | 12 + .../Sources/EditorViewController.swift | 42 ++- .../Services/LockdownModeMonitor.swift | 179 ++++++++++++ .../Sources/Views/LockdownModeSheet.swift | 79 ++++++ .../Services/LockdownModeMonitorTests.swift | 258 ++++++++++++++++++ 6 files changed, 563 insertions(+), 18 deletions(-) create mode 100644 ios/Sources/GutenbergKit/Sources/Services/LockdownModeMonitor.swift create mode 100644 ios/Sources/GutenbergKit/Sources/Views/LockdownModeSheet.swift create mode 100644 ios/Tests/GutenbergKitTests/Services/LockdownModeMonitorTests.swift diff --git a/Package.resolved b/Package.resolved index 62c421983..276dce0b9 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "b5958ced5a4c7d544f45cfa6cdc8cd0441f5e176874baac30922b53e6cc5aefc", + "originHash" : "c32e016069801ed394dc3903d2fe2eb6082b812eac4efb8b8c62b5d6de294a5d", "pins" : [ { "identity" : "svgview", @@ -18,15 +18,6 @@ "revision" : "aa85ee96017a730031bafe411cde24a08a17a9c9", "version" : "2.8.8" } - }, - { - "identity" : "wordpress-rs", - "kind" : "remoteSourceControl", - "location" : "https://github.com/Automattic/wordpress-rs", - "state" : { - "branch" : "alpha-20260313", - "revision" : "cde2fda82257f4ac7b81543d5b831bb267d4e52c" - } } ], "version" : 3 diff --git a/ios/Sources/GutenbergKit/Sources/EditorLocalization.swift b/ios/Sources/GutenbergKit/Sources/EditorLocalization.swift index 6f11ad9ae..4791bb51e 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, then re-open the editor 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..22b92e698 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,9 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro controller.delegate = self webView.navigationDelegate = controller + // Set up Lockdown Mode monitoring with foreground detection + lockdownModeMonitor.setup(presentingViewController: self) + // FIXME: implement with CSS (bottom toolbar) webView.scrollView.verticalScrollIndicatorInsets = UIEdgeInsets(top: 0, left: 0, bottom: 47, right: 0) @@ -223,7 +228,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 +588,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 +732,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 +785,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 +820,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..5e05dbba0 --- /dev/null +++ b/ios/Sources/GutenbergKit/Sources/Services/LockdownModeMonitor.swift @@ -0,0 +1,179 @@ +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 } +} + +/// Extension to make WKWebView conform to LockdownModeDetectable. +extension WKWebView: LockdownModeDetectable { + var isLockdownModeEnabled: Bool { + configuration.defaultWebpagePreferences.isLockdownModeEnabled + } +} + +/// 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 re-checking on foreground. + private weak var detectable: LockdownModeDetectable? + + 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. + /// + /// - Parameter viewController: The view controller to use for sheet presentation. + public func setup(presentingViewController viewController: UIViewController) { + self.presentingViewController = viewController + + // Observe foreground notifications to re-check Lockdown Mode + NotificationCenter.default.addObserver( + self, + selector: #selector(handleWillEnterForeground), + name: UIApplication.willEnterForegroundNotification, + object: nil + ) + } + + @objc private func handleWillEnterForeground() { + guard let detectable else { return } + + let newValue = detectable.isLockdownModeEnabled + guard newValue != self.isLockdownModeEnabled else { return } + + Logger.navigation.debug("Lockdown Mode changed on foreground: \(newValue)") + + // Re-run detection to update state and trigger sheet if needed + resetForForegroundCheck() + detectLockdownMode(for: detectable) + + if shouldShowSheet { + presentSheetIfNeeded(onDismiss: {}) + } else { + // Lockdown Mode was disabled — dismiss the sheet if showing + dismissSheetIfPresented() + } + } + + /// 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/LockdownModeSheet.swift b/ios/Sources/GutenbergKit/Sources/Views/LockdownModeSheet.swift new file mode 100644 index 000000000..023f40de0 --- /dev/null +++ b/ios/Sources/GutenbergKit/Sources/Views/LockdownModeSheet.swift @@ -0,0 +1,79 @@ +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(spacing: 0) { + ScrollView { + VStack(alignment: .leading, spacing: 20) { + Image(systemName: "exclamationmark.triangle.fill") + .font(.largeTitle) + .foregroundColor(.orange) + .accessibilityHidden(true) + + Text(EditorLocalization.localize(.lockdownModeTitle)) + .font(.title2) + .fontWeight(.bold) + .accessibilityAddTraits(.isHeader) + + Text(EditorLocalization.localize(.lockdownModeWarning)) + .font(.body) + .foregroundColor(.secondary) + + Text(EditorLocalization.localize(.lockdownModeExcludeHint)) + .font(.body) + .foregroundColor(.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(24) + } + + 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) + } + } +} + +#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..f36cd8423 --- /dev/null +++ b/ios/Tests/GutenbergKitTests/Services/LockdownModeMonitorTests.swift @@ -0,0 +1,258 @@ +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 + + init(isLockdownModeEnabled: Bool) { + self.isLockdownModeEnabled = isLockdownModeEnabled + } + } + + // 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 accepts a presenting view controller") + func setupAcceptsPresentingViewController() { + let monitor = makeMonitor() + let viewController = UIViewController() + + monitor.setup(presentingViewController: viewController) + + // Should not crash or change lockdown state + #expect(monitor.isLockdownModeEnabled == false) + } + + // 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) + + 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) + monitor.setup(presentingViewController: vc2) + + // 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) + } +} From 88b6c6a57704e3624aa0b5a1e2a2a27e14e6d3e8 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Thu, 9 Apr 2026 11:49:46 -0600 Subject: [PATCH 2/2] fix: use large sheet detent for Lockdown Mode warning --- .../GutenbergKit/Sources/Services/LockdownModeMonitor.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ios/Sources/GutenbergKit/Sources/Services/LockdownModeMonitor.swift b/ios/Sources/GutenbergKit/Sources/Services/LockdownModeMonitor.swift index 5e05dbba0..fa400b079 100644 --- a/ios/Sources/GutenbergKit/Sources/Services/LockdownModeMonitor.swift +++ b/ios/Sources/GutenbergKit/Sources/Services/LockdownModeMonitor.swift @@ -147,7 +147,7 @@ class LockdownModeMonitor: ObservableObject { hostingController.isModalInPresentation = true if let sheet = hostingController.sheetPresentationController { - sheet.detents = [.medium()] + sheet.detents = [.large()] sheet.prefersGrabberVisible = false }