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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions ios/Sources/GutenbergKit/Sources/EditorLocalization.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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"
}
}

Expand Down
47 changes: 39 additions & 8 deletions ios/Sources/GutenbergKit/Sources/EditorViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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)

Expand All @@ -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
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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) {
Expand Down
201 changes: 201 additions & 0 deletions ios/Sources/GutenbergKit/Sources/Services/LockdownModeMonitor.swift
Original file line number Diff line number Diff line change
@@ -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()
}
Comment on lines +128 to +135
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Type some text then switch to settings and back – the contents of the editor are lost.

I'm unsure this is an acceptable trade-off for ensuring the editor state is always accurate regarding Lockdown Mode. This results in the editor always reloading when foregrounded, regardless of whether Lockdown Mode is modified, enabled, or disabled. This feels like a significant downgrade in the UX.

At best, the editor flashes while reloading and loses any transient state (open modals, popover placement, text selection, etc). At worst, content is loss. Presumably the latter will not occur if proper content syncing occurs in the host app, but still...

Editor reload flash
ScreenRecording_04-03-2026.08-16-08_1.MP4

Instead of reloading, should we consider updating the copy to include something like "and re-open the editor" after excluding the app?

What are your thoughts on this?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Noting that Jeremy and I discussed this some on a call...

The content loss in the iOS demo app likely occurs because the demo app does not implement a persistence layer alongside using the library's editorDidRequestLatestContent function—WordPress-iOS does.

Regardless, reloading the editor on every foreground is a bug; it should only reload when Lockdown Mode status changed.

We should address the unexpected reload, or replace the automatic reload with a note directing the user to re-open the editor.

}

/// 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)
Comment on lines +167 to +176
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm unsure what leads to this, but the sheet is not all that accessible via VoiceOver. When it opens, the focus is moved to the status bar instead of the sheet itself. Afterwards, you cannot swipe to move focus to the sheet; you can only tap or drag your finger atop the sheet to begin reading its contents.

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
Loading
Loading