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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 0 additions & 9 deletions Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

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, then re-open the editor to restore full functionality."
case .lockdownModeLearnMore: "Learn More"
case .lockdownModeDismiss: "Dismiss"
}
}

Expand Down
42 changes: 34 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,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)

Expand All @@ -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
Expand Down Expand Up @@ -587,6 +592,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 @@ -716,20 +736,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 @@ -766,9 +789,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 @@ -799,6 +824,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
179 changes: 179 additions & 0 deletions ios/Sources/GutenbergKit/Sources/Services/LockdownModeMonitor.swift
Original file line number Diff line number Diff line change
@@ -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 = [.large()]
sheet.prefersGrabberVisible = false
}

presentingViewController.present(hostingController, animated: true)
Comment thread
dcalhoun marked this conversation as resolved.
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