From 50f1b1d7c910501959a8e0038e66f6bf3027be75 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Mon, 8 Dec 2025 23:24:59 -0700 Subject: [PATCH 01/25] Add Preloader Logic --- .../xcshareddata/swiftpm/Package.resolved | 3 +- .../GutenbergKit/Sources/Constants.swift | 15 + .../Sources/EditorConfiguration.swift | 376 ------------- .../Sources/EditorDependencies.swift | 22 - .../Sources/EditorHTTPClient.swift | 104 ++++ .../GutenbergKit/Sources/EditorLogging.swift | 54 +- .../Sources/EditorViewController.swift | 358 +++++++++--- .../Sources/Extensions/Foundation.swift | 120 ++++ .../Extensions/String+Extensions.swift | 23 - .../Sources/Model/EditorAssetBundle.swift | 200 +++++++ .../Sources/Model/EditorAssetManifest.swift | 241 ++++++++ .../Sources/Model/EditorCachePolicy.swift | 104 ++++ .../Sources/Model/EditorConfiguration.swift | 386 +++++++++++++ .../Sources/Model/EditorDependencies.swift | 34 ++ .../Sources/Model/EditorPreloadList.swift | 134 +++++ .../Sources/Model/EditorProgress.swift | 38 ++ .../Sources/Model/EditorSettings.swift | 71 +++ .../Sources/Model/GBKitGlobal.swift | 114 ++++ .../Model/HTTP/EditorHTTPHeaders.swift | 103 ++++ .../Sources/Model/HTTP/EditorHttpMethod.swift | 9 + .../Model/HTTP/EditorURLResponse.swift | 69 +++ .../GutenbergKit/Sources/Model/JSON.swift | 203 +++++++ ios/Sources/GutenbergKit/Sources/Paths.swift | 15 + .../Sources/RESTAPIRepository.swift | 148 +++++ .../Service/CachedAssetSchemeHandler.swift | 54 -- .../Sources/Service/EditorAssetsLibrary.swift | 21 - .../Service/EditorAssetsManifest.swift | 106 ---- .../Service/EditorAssetsProvider.swift | 30 - .../Sources/Service/EditorService.swift | 524 ------------------ .../Services/EditorAssetBundleProvider.swift | 140 +++++ .../Sources/Services/EditorService.swift | 266 +++++++++ .../Sources/Stores/EditorAssetLibrary.swift | 238 ++++++++ .../Sources/Stores/EditorKeyValueCache.swift | 179 ++++++ .../Sources/Stores/EditorURLCache.swift | 183 ++++++ .../GutenbergKit/Sources/Strings.swift | 9 + ios/Sources/GutenbergKit/Sources/Timing.swift | 34 ++ .../Sources/Views/EditorErrorView.swift | 61 ++ .../Sources/Views/EditorProgressView.swift | 114 ++++ src/utils/api-fetch.js | 14 +- 39 files changed, 3677 insertions(+), 1240 deletions(-) create mode 100644 ios/Sources/GutenbergKit/Sources/Constants.swift delete mode 100644 ios/Sources/GutenbergKit/Sources/EditorConfiguration.swift delete mode 100644 ios/Sources/GutenbergKit/Sources/EditorDependencies.swift create mode 100644 ios/Sources/GutenbergKit/Sources/EditorHTTPClient.swift create mode 100644 ios/Sources/GutenbergKit/Sources/Extensions/Foundation.swift delete mode 100644 ios/Sources/GutenbergKit/Sources/Extensions/String+Extensions.swift create mode 100644 ios/Sources/GutenbergKit/Sources/Model/EditorAssetBundle.swift create mode 100644 ios/Sources/GutenbergKit/Sources/Model/EditorAssetManifest.swift create mode 100644 ios/Sources/GutenbergKit/Sources/Model/EditorCachePolicy.swift create mode 100644 ios/Sources/GutenbergKit/Sources/Model/EditorConfiguration.swift create mode 100644 ios/Sources/GutenbergKit/Sources/Model/EditorDependencies.swift create mode 100644 ios/Sources/GutenbergKit/Sources/Model/EditorPreloadList.swift create mode 100644 ios/Sources/GutenbergKit/Sources/Model/EditorProgress.swift create mode 100644 ios/Sources/GutenbergKit/Sources/Model/EditorSettings.swift create mode 100644 ios/Sources/GutenbergKit/Sources/Model/GBKitGlobal.swift create mode 100644 ios/Sources/GutenbergKit/Sources/Model/HTTP/EditorHTTPHeaders.swift create mode 100644 ios/Sources/GutenbergKit/Sources/Model/HTTP/EditorHttpMethod.swift create mode 100644 ios/Sources/GutenbergKit/Sources/Model/HTTP/EditorURLResponse.swift create mode 100644 ios/Sources/GutenbergKit/Sources/Model/JSON.swift create mode 100644 ios/Sources/GutenbergKit/Sources/Paths.swift create mode 100644 ios/Sources/GutenbergKit/Sources/RESTAPIRepository.swift delete mode 100644 ios/Sources/GutenbergKit/Sources/Service/CachedAssetSchemeHandler.swift delete mode 100644 ios/Sources/GutenbergKit/Sources/Service/EditorAssetsLibrary.swift delete mode 100644 ios/Sources/GutenbergKit/Sources/Service/EditorAssetsManifest.swift delete mode 100644 ios/Sources/GutenbergKit/Sources/Service/EditorAssetsProvider.swift delete mode 100644 ios/Sources/GutenbergKit/Sources/Service/EditorService.swift create mode 100644 ios/Sources/GutenbergKit/Sources/Services/EditorAssetBundleProvider.swift create mode 100644 ios/Sources/GutenbergKit/Sources/Services/EditorService.swift create mode 100644 ios/Sources/GutenbergKit/Sources/Stores/EditorAssetLibrary.swift create mode 100644 ios/Sources/GutenbergKit/Sources/Stores/EditorKeyValueCache.swift create mode 100644 ios/Sources/GutenbergKit/Sources/Stores/EditorURLCache.swift create mode 100644 ios/Sources/GutenbergKit/Sources/Strings.swift create mode 100644 ios/Sources/GutenbergKit/Sources/Timing.swift create mode 100644 ios/Sources/GutenbergKit/Sources/Views/EditorErrorView.swift create mode 100644 ios/Sources/GutenbergKit/Sources/Views/EditorProgressView.swift diff --git a/ios/Demo-iOS/Gutenberg.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ios/Demo-iOS/Gutenberg.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 0ea20e7dc..909f39e33 100644 --- a/ios/Demo-iOS/Gutenberg.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/ios/Demo-iOS/Gutenberg.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,4 +1,5 @@ { + "originHash" : "2aee256c0b38622aa9bb903b83d446aa51554bf7f6d1f43aca6d447f82d2a906", "pins" : [ { "identity" : "svgview", @@ -28,5 +29,5 @@ } } ], - "version" : 2 + "version" : 3 } diff --git a/ios/Sources/GutenbergKit/Sources/Constants.swift b/ios/Sources/GutenbergKit/Sources/Constants.swift new file mode 100644 index 000000000..aabd0c323 --- /dev/null +++ b/ios/Sources/GutenbergKit/Sources/Constants.swift @@ -0,0 +1,15 @@ +import Foundation + +public struct Constants { + + public struct EditorAssetLibrary { + public static let urlScheme = "gbk-cache-https" + } + + public struct API { + public static let editorSettingsPath = "/wp-block-editor/v1/settings" + public static let activeThemePath = "/wp/v2/themes?context=edit&status=active" + public static let siteSettingsPath = "/wp/v2/settings" + public static let postTypesPath = "/wp/v2/types?context=view" + } +} diff --git a/ios/Sources/GutenbergKit/Sources/EditorConfiguration.swift b/ios/Sources/GutenbergKit/Sources/EditorConfiguration.swift deleted file mode 100644 index 93694e46b..000000000 --- a/ios/Sources/GutenbergKit/Sources/EditorConfiguration.swift +++ /dev/null @@ -1,376 +0,0 @@ -import Foundation - -public struct EditorConfiguration: Sendable { - /// Initial title for populating the editor - public let title: String - /// Initial content for populating the editor - public let content: String - - /// ID of the post being edited - public let postID: Int? - /// Type of the post being edited - public let postType: String? - /// Toggles application of theme styles - public let shouldUseThemeStyles: Bool - /// Toggles loading plugin-provided editor assets - public let shouldUsePlugins: Bool - /// Toggles visibility of the title field - public let shouldHideTitle: Bool - /// Root URL for the site - public let siteURL: String - /// Root URL for the site API - public let siteApiRoot: String - /// Namespaces for the site API - public let siteApiNamespace: [String] - /// Paths excluded from API namespacing - public let namespaceExcludedPaths: [String] - /// Authorization header - public let authHeader: String - /// Raw block editor settings from the WordPress REST API - public let editorSettings: String - /// Locale used for translations - public let locale: String - /// Enables the native inserter UI in the editor - public let isNativeInserterEnabled: Bool - /// Endpoint for loading editor settings - public let editorSettingsEndpoint: URL? - /// Endpoint for loading editor assets, used when enabling `shouldUsePlugins` - public let editorAssetsEndpoint: URL? - /// Logs emitted at or above this level will be printed to the debug console - public let logLevel: EditorLogLevel - /// Enables logging of all network requests/responses to the native host - public let enableNetworkLogging: Bool - - /// Deliberately non-public – consumers should use `EditorConfigurationBuilder` to construct a configuration - init( - title: String, - content: String, - postID: Int?, - postType: String?, - shouldUseThemeStyles: Bool, - shouldUsePlugins: Bool, - shouldHideTitle: Bool, - siteURL: String, - siteApiRoot: String, - siteApiNamespace: [String], - namespaceExcludedPaths: [String], - authHeader: String, - editorSettings: String, - locale: String, - isNativeInserterEnabled: Bool, - editorSettingsEndpoint: URL?, - editorAssetsEndpoint: URL?, - logLevel: EditorLogLevel, - enableNetworkLogging: Bool = false - ) { - self.title = title - self.content = content - self.postID = postID - self.postType = postType - self.shouldUseThemeStyles = shouldUseThemeStyles - self.shouldUsePlugins = shouldUsePlugins - self.shouldHideTitle = shouldHideTitle - self.siteURL = siteURL - self.siteApiRoot = siteApiRoot - self.siteApiNamespace = siteApiNamespace - self.namespaceExcludedPaths = namespaceExcludedPaths - self.authHeader = authHeader - self.editorSettings = editorSettings - self.locale = locale - self.isNativeInserterEnabled = isNativeInserterEnabled - self.editorSettingsEndpoint = editorSettingsEndpoint - self.editorAssetsEndpoint = editorAssetsEndpoint - self.logLevel = logLevel - self.enableNetworkLogging = enableNetworkLogging - } - - public func toBuilder() -> EditorConfigurationBuilder { - return EditorConfigurationBuilder( - title: title, - content: content, - postID: postID, - postType: postType, - shouldUseThemeStyles: shouldUseThemeStyles, - shouldUsePlugins: shouldUsePlugins, - shouldHideTitle: shouldHideTitle, - siteURL: siteURL, - siteApiRoot: siteApiRoot, - siteApiNamespace: siteApiNamespace, - namespaceExcludedPaths: namespaceExcludedPaths, - authHeader: authHeader, - editorSettings: editorSettings, - locale: locale, - isNativeInserterEnabled: isNativeInserterEnabled, - editorSettingsEndpoint: editorSettingsEndpoint, - editorAssetsEndpoint: editorAssetsEndpoint, - logLevel: logLevel, - enableNetworkLogging: enableNetworkLogging - ) - } - - var escapedTitle: String { - title.addingPercentEncoding(withAllowedCharacters: .alphanumerics)! - } - - var escapedContent: String { - content.addingPercentEncoding(withAllowedCharacters: .alphanumerics)! - } - - public static let `default` = EditorConfigurationBuilder().build() -} - -public struct EditorConfigurationBuilder { - private var title: String - private var content: String - private var postID: Int? - private var postType: String? - private var shouldUseThemeStyles: Bool - private var shouldUsePlugins: Bool - private var shouldHideTitle: Bool - private var siteURL: String - private var siteApiRoot: String - private var siteApiNamespace: [String] - private var namespaceExcludedPaths: [String] - private var authHeader: String - private var editorSettings: String - private var locale: String - private var isNativeInserterEnabled: Bool - private var editorSettingsEndpoint: URL? - private var editorAssetsEndpoint: URL? - private var logLevel: EditorLogLevel - private var enableNetworkLogging: Bool - - public init( - title: String = "", - content: String = "", - postID: Int? = nil, - postType: String? = nil, - shouldUseThemeStyles: Bool = false, - shouldUsePlugins: Bool = false, - shouldHideTitle: Bool = false, - siteURL: String = "", - siteApiRoot: String = "", - siteApiNamespace: [String] = [], - namespaceExcludedPaths: [String] = [], - authHeader: String = "", - editorSettings: String = "undefined", - locale: String = "en", - isNativeInserterEnabled: Bool = false, - editorSettingsEndpoint: URL? = nil, - editorAssetsEndpoint: URL? = nil, - logLevel: EditorLogLevel = .error, - enableNetworkLogging: Bool = false - ){ - self.title = title - self.content = content - self.postID = postID - self.postType = postType - self.shouldUseThemeStyles = shouldUseThemeStyles - self.shouldUsePlugins = shouldUsePlugins - self.shouldHideTitle = shouldHideTitle - self.siteURL = siteURL - self.siteApiRoot = siteApiRoot - self.siteApiNamespace = siteApiNamespace - self.namespaceExcludedPaths = namespaceExcludedPaths - self.authHeader = authHeader - self.editorSettings = editorSettings - self.locale = locale - self.isNativeInserterEnabled = isNativeInserterEnabled - self.editorSettingsEndpoint = editorSettingsEndpoint - self.editorAssetsEndpoint = editorAssetsEndpoint - self.logLevel = logLevel - self.enableNetworkLogging = enableNetworkLogging - } - - public func setTitle(_ title: String) -> EditorConfigurationBuilder { - var copy = self - copy.title = title - return copy - } - - public func setContent(_ content: String) -> EditorConfigurationBuilder { - var copy = self - copy.content = content - return copy - } - - public func setPostID(_ postID: Int?) -> EditorConfigurationBuilder { - var copy = self - copy.postID = postID - return copy - } - - public func setPostType(_ postType: String?) -> EditorConfigurationBuilder { - var copy = self - copy.postType = postType - return copy - } - - public func setShouldUseThemeStyles(_ shouldUseThemeStyles: Bool) -> EditorConfigurationBuilder { - var copy = self - copy.shouldUseThemeStyles = shouldUseThemeStyles - return copy - } - - public func setShouldUsePlugins(_ shouldUsePlugins: Bool) -> EditorConfigurationBuilder { - var copy = self - copy.shouldUsePlugins = shouldUsePlugins - return copy - } - - public func setShouldHideTitle(_ shouldHideTitle: Bool) -> EditorConfigurationBuilder { - var copy = self - copy.shouldHideTitle = shouldHideTitle - return copy - } - - public func setSiteUrl(_ siteUrl: String) -> EditorConfigurationBuilder { - var copy = self - copy.siteURL = siteUrl - return copy - } - - public func setSiteApiRoot(_ siteApiRoot: String) -> EditorConfigurationBuilder { - var copy = self - copy.siteApiRoot = siteApiRoot - return copy - } - - public func setSiteApiNamespace(_ siteApiNamespace: [String]) -> EditorConfigurationBuilder { - var copy = self - copy.siteApiNamespace = siteApiNamespace - return copy - } - - public func setNamespaceExcludedPaths(_ namespaceExcludedPaths: [String]) -> EditorConfigurationBuilder { - var copy = self - copy.namespaceExcludedPaths = namespaceExcludedPaths - return copy - } - - public func setAuthHeader(_ authHeader: String) -> EditorConfigurationBuilder { - var copy = self - copy.authHeader = authHeader - return copy - } - - public func setEditorSettings(_ editorSettings: String) -> EditorConfigurationBuilder { - var copy = self - copy.editorSettings = editorSettings - return copy - } - - public func setLocale(_ locale: String) -> EditorConfigurationBuilder { - var copy = self - copy.locale = locale - return copy - } - - public func setNativeInserterEnabled(_ isNativeInserterEnabled: Bool = true) -> EditorConfigurationBuilder { - var copy = self - copy.isNativeInserterEnabled = isNativeInserterEnabled - return copy - } - - public func setEditorSettingsEndpoint(_ editorSettingsEndpoint: URL?) -> EditorConfigurationBuilder { - var copy = self - copy.editorSettingsEndpoint = editorSettingsEndpoint - return copy - } - - public func setEditorAssetsEndpoint(_ editorAssetsEndpoint: URL?) -> EditorConfigurationBuilder { - var copy = self - copy.editorAssetsEndpoint = editorAssetsEndpoint - return copy - } - - public func setLogLevel(_ logLevel: EditorLogLevel) -> EditorConfigurationBuilder { - var copy = self - copy.logLevel = logLevel - return copy - } - - public func setEnableNetworkLogging(_ enableNetworkLogging: Bool) -> EditorConfigurationBuilder { - var copy = self - copy.enableNetworkLogging = enableNetworkLogging - return copy - } - - /// Simplify conditionally applying a configuration change - /// - /// Sample Code: - /// ```swift - /// // Before - /// let configurationBuilder = EditorConfigurationBuilder() - /// if let postID = post.id { - /// configurationBuilder = configurationBuilder.setPostID(postID) - /// } - /// - /// // After - /// let configurationBuilder = EditorConfigurationBuilder() - /// .apply(post.id, { $0.setPostID($1) } ) - /// ``` - public func apply(_ value: T?, _ closure: (EditorConfigurationBuilder, T) -> EditorConfigurationBuilder) -> Self { - guard let value else { - return self - } - - return closure(self, value) - } - - public func build() -> EditorConfiguration { - EditorConfiguration( - title: title, - content: content, - postID: postID, - postType: postType, - shouldUseThemeStyles: shouldUseThemeStyles, - shouldUsePlugins: shouldUsePlugins, - shouldHideTitle: shouldHideTitle, - siteURL: siteURL, - siteApiRoot: siteApiRoot, - siteApiNamespace: siteApiNamespace, - namespaceExcludedPaths: namespaceExcludedPaths, - authHeader: authHeader, - editorSettings: editorSettings, - locale: locale, - isNativeInserterEnabled: isNativeInserterEnabled, - editorSettingsEndpoint: editorSettingsEndpoint, - editorAssetsEndpoint: editorAssetsEndpoint, - logLevel: logLevel, - enableNetworkLogging: enableNetworkLogging - ) - } -} - -public typealias EditorSettings = [String: Encodable] - -// MARK: - EditorConfiguration Extensions - -extension EditorConfiguration { - /// Extracts CSS styles from the editor settings JSON string - func extractThemeStyles() -> String? { - guard editorSettings != "undefined", - let data = editorSettings.data(using: .utf8), - let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let styles = json["styles"] as? [[String: Any]] else { - return nil - } - - // Concatenate all CSS from the styles array - let cssArray = styles.compactMap { $0["css"] as? String } - return cssArray.isEmpty ? nil : cssArray.joined(separator: "\n") - } -} - -// String escaping extension -private extension String { - var escaped: String { - return self.replacingOccurrences(of: "\"", with: "\\\"") - .replacingOccurrences(of: "\n", with: "\\n") - .replacingOccurrences(of: "\r", with: "\\r") - .replacingOccurrences(of: "\t", with: "\\t") - .replacingOccurrences(of: "\u{8}", with: "\\b") - .replacingOccurrences(of: "\u{12}", with: "\\f") - } -} diff --git a/ios/Sources/GutenbergKit/Sources/EditorDependencies.swift b/ios/Sources/GutenbergKit/Sources/EditorDependencies.swift deleted file mode 100644 index 80f66e3bf..000000000 --- a/ios/Sources/GutenbergKit/Sources/EditorDependencies.swift +++ /dev/null @@ -1,22 +0,0 @@ -import Foundation - -/// Dependencies fetched from the WordPress REST API required for the editor -struct EditorDependencies: Sendable { - /// Raw block editor settings from the WordPress REST API - var editorSettings: String? - - /// Extracts CSS styles from the editor settings JSON string - func extractThemeStyles() -> String? { - guard let editorSettings = editorSettings, - editorSettings != "undefined", - let data = editorSettings.data(using: .utf8), - let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let styles = json["styles"] as? [[String: Any]] else { - return nil - } - - // Concatenate all CSS from the styles array - let cssArray = styles.compactMap { $0["css"] as? String } - return cssArray.isEmpty ? nil : cssArray.joined(separator: "\n") - } -} diff --git a/ios/Sources/GutenbergKit/Sources/EditorHTTPClient.swift b/ios/Sources/GutenbergKit/Sources/EditorHTTPClient.swift new file mode 100644 index 000000000..73a6102f0 --- /dev/null +++ b/ios/Sources/GutenbergKit/Sources/EditorHTTPClient.swift @@ -0,0 +1,104 @@ +import Foundation +import OSLog + +/// A protocol for making authenticated HTTP requests to the WordPress REST API. +public protocol EditorHTTPClientProtocol: Sendable { + func GET(url: URL) async throws -> (Data, HTTPURLResponse) + func OPTIONS(url: URL) async throws -> (Data, HTTPURLResponse) + func download(url: URL) async throws -> (URL, HTTPURLResponse) +} + +/// A delegate for observing HTTP requests made by the editor. +/// +/// Implement this protocol to inspect or log all network requests. +public protocol EditorHTTPClientDelegate { + func didPerformRequest(_ request: URLRequest, response: URLResponse, data: Data) +} + +/// A WordPress REST API error response. +struct WPError: Decodable { + let code: String + let message: String +} + +/// An HTTP client for making authenticated requests to the WordPress REST API. +/// +/// This actor handles request signing, error parsing, and response validation. +/// All requests are automatically authenticated using the provided authorization header. +public actor EditorHTTPClient: EditorHTTPClientProtocol { + + /// Errors that can occur during HTTP requests. + enum ClientError: Error { + /// The server returned a WordPress-formatted error response. + case wpError(WPError) + /// A file download failed with the given HTTP status code. + case downloadFailed(statusCode: Int) + /// An unexpected error occurred with the given response data and status code. + case unknown(response: Data, statusCode: Int) + } + + private let urlSession: URLSession + private let authHeader: String + private let delegate: EditorHTTPClientDelegate? + + public init( + urlSession: URLSession, + authHeader: String, + delegate: EditorHTTPClientDelegate? = nil + ) { + self.urlSession = urlSession + self.authHeader = authHeader + self.delegate = delegate + } + + public func GET(url: URL) async throws -> (Data, HTTPURLResponse) { + var request = URLRequest(url: url) + request.httpMethod = "GET" + return try await self.perform(request: request) + } + + public func OPTIONS(url: URL) async throws -> (Data, HTTPURLResponse) { + var request = URLRequest(url: url) + request.httpMethod = "OPTIONS" + return try await self.perform(request: request) + } + + public func download(url: URL) async throws -> (URL, HTTPURLResponse) { + var request = URLRequest(url: url) + request.addValue(self.authHeader, forHTTPHeaderField: "Authorization") + + let (url, response) = try await self.urlSession.download(for: request) + + let httpResponse = response as! HTTPURLResponse + + guard 200...299 ~= httpResponse.statusCode else { + throw ClientError.downloadFailed(statusCode: httpResponse.statusCode) + } + + return (url, response as! HTTPURLResponse) + } + + private func perform(request: URLRequest) async throws -> (Data, HTTPURLResponse) { + var signedRequest = request + signedRequest.setValue(self.authHeader, forHTTPHeaderField: "Authorization") + signedRequest.timeoutInterval = 60 + + let (data, response) = try await self.urlSession.data(for: signedRequest) + self.delegate?.didPerformRequest(signedRequest, response: response, data: data) + + let httpResponse = response as! HTTPURLResponse + + guard 200...299 ~= httpResponse.statusCode else { + Logger.http.error("📡 HTTP error fetching \(request.url!.absoluteString): \(httpResponse.statusCode)") + + if let wpError = try? JSONDecoder().decode(WPError.self, from: data) { + throw ClientError.wpError(wpError) + } + + throw ClientError.unknown(response: data, statusCode: httpResponse.statusCode) + } + + return (data, httpResponse) + } + +} diff --git a/ios/Sources/GutenbergKit/Sources/EditorLogging.swift b/ios/Sources/GutenbergKit/Sources/EditorLogging.swift index 77ff2e464..d51e4f0ea 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorLogging.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorLogging.swift @@ -1,4 +1,5 @@ import Foundation +import OSLog /// Protocol for logging editor-related messages. public protocol EditorLogging: Sendable { @@ -6,6 +7,52 @@ public protocol EditorLogging: Sendable { func log(_ level: EditorLogLevel, _ message: String) } +extension Logger { + + public static let performance = OSSignposter(subsystem: "GutenbergKit", category: "timing") + + /// Logs timings for performance optimization + public static let timing = Logger(subsystem: "GutenbergKit", category: "timing") + + /// Logs editor asset library activity + public static let assetLibrary = Logger(subsystem: "GutenbergKit", category: "asset-library") + + /// Logs editor HTTP activity + public static let http = Logger(subsystem: "GutenbergKit", category: "http") +} + +public struct SignpostMonitor: Sendable { + private let id: OSSignpostID + private let logger: OSSignposter + + private var subtasks: [String: OSSignpostIntervalState] = [:] + + public init(for logger: OSSignposter) { + + self.logger = logger + self.id = logger.makeSignpostID() + } + + public mutating func startTask(_ event: StaticString = #function) { + self.subtasks["\(event)"] = self.logger.beginInterval(event, id: id) + } + + public mutating func endTask(_ event: StaticString = #function) { + precondition(self.subtasks["\(event)"] != nil) + self.logger.endInterval(event, self.subtasks["\(event)"]!) + } + + public func measure(_ name: StaticString = #function, _ work: () throws -> T) rethrows -> T { + try self.logger.withIntervalSignpost(name, id: self.id, around: work) + } + + public func measure(_ name: StaticString = #function, _ work: @Sendable () async throws -> T) async rethrows -> T { + let handle = self.logger.beginInterval(name) + defer { self.logger.endInterval(name, handle) } + return try await work() + } +} + /// Global logger for GutenbergKit. /// /// - warning: The shared properties are nonisolated and should be set once @@ -13,14 +60,15 @@ public protocol EditorLogging: Sendable { public enum EditorLogger { /// The shared logger instance used throughout GutenbergKit. public nonisolated(unsafe) static var shared: EditorLogging? - /// The log level. Messages below this level are ignored. public nonisolated(unsafe) static var logLevel: EditorLogLevel = .error } -func log(_ level: EditorLogLevel, _ message: @autoclosure () -> String) { + +public func log(_ level: EditorLogLevel, _ message: @autoclosure () -> String) { guard level.priority >= EditorLogger.logLevel.priority, - let logger = EditorLogger.shared else { + let logger = EditorLogger.shared + else { return } logger.log(level, message()) diff --git a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift index b2f5e0694..570a9334a 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift @@ -1,31 +1,150 @@ @preconcurrency import WebKit import SwiftUI -import Combine -import CryptoKit +import OSLog #if canImport(UIKit) import UIKit @MainActor public final class EditorViewController: UIViewController, GutenbergEditorControllerDelegate, UIAdaptivePresentationControllerDelegate, UIPopoverPresentationControllerDelegate, UISheetPresentationControllerDelegate { + + /// Represents the lifecycle state of the editor view controller. + /// + /// The editor progresses through these states as it initializes: + /// + /// ``` + /// +-------+ No deps +---------+ Fetch complete +--------+ + /// | start | -----------------> | loading | -------------------> | loaded | + /// +-------+ +---------+ +--------+ + /// | | + /// | Has deps | + /// +--------------------------------------------------------------+ + /// | + /// JS initialized + /// | + /// v + /// +-------+ + /// | ready | + /// +-------+ + /// ``` + /// + /// Any state can transition to ``error(_:)`` if a fatal error occurs. + /// + /// ## State Descriptions + /// + /// - ``start``: Initial state before `viewDidLoad`. The editor has not begun initialization. + /// - ``loading(_:)``: Fetching dependencies from the network. A progress bar is displayed. + /// - ``loaded(_:)``: Dependencies are available and the WebView is loading HTML/JS. An activity indicator is shown. + /// - ``ready(_:)``: The editor is fully initialized. JavaScript APIs (e.g., `editor.getContent()`) are now safe to call. + /// - ``error(_:)``: A fatal error occurred. The error view is displayed and the delegate is notified. + /// + /// ## UI Behavior + /// + /// Each state transition triggers corresponding UI updates: + /// - `start` -> `loading`: Shows progress bar + /// - `loading` -> `loaded`: Hides progress bar, shows activity indicator + /// - `loaded` -> `ready`: Hides activity indicator, reveals editor + /// - Any -> `error`: Shows error view + /// + enum ViewState: Sendable, Equatable { + + /// Initial state before the view has loaded. + /// + /// This is the default state when the view controller is created. The editor + /// transitions out of this state in `viewDidLoad`. + case start + + /// Fetching editor dependencies from the network. + /// + /// The associated task represents the async work being performed. A progress + /// bar is displayed to the user during this state. + /// + /// - Parameter task: The task fetching dependencies via `EditorService.prepare()`. + case loading(Task) + + /// Dependencies are loaded and the WebView is initializing. + /// + /// The editor HTML and JavaScript are being loaded into the WebView. An + /// indeterminate activity indicator is shown during this brief phase. + /// + /// - Parameter dependencies: The pre-fetched editor dependencies. + case loaded(EditorDependencies) + + /// The editor is fully initialized and ready for use. + /// + /// JavaScript APIs like `editor.getContent()`, `editor.setContent()`, `editor.undo()`, + /// etc. are now safe to call. The editor UI is visible and interactive. + /// + /// - Parameter dependencies: The editor dependencies used for initialization. + case ready(EditorDependencies) + + /// A fatal error occurred during initialization. + /// + /// The error view is displayed and the delegate's `editor(_:didEncounterCriticalError:)` + /// method is called. The editor cannot recover from this state. + /// + /// - Parameter error: The error that caused initialization to fail. + case error(Error) + + static func == (lhs: EditorViewController.ViewState, rhs: EditorViewController.ViewState) -> Bool { + switch (lhs, rhs) { + case (.start, .start): return true + case (.loading, .loading): return true + case (.loaded, .loaded): return true + case (.ready, .ready): return true + case (.error, .error): return true + default: return false + } + } + } + + @MainActor + private var viewState: ViewState = .start { + willSet { + if newValue == self.viewState { + preconditionFailure("Invalid transition from `\(self.viewState)` to `\(newValue)") + } + } + didSet { + switch viewState { + case .start: + preconditionFailure("viewState should never transition back to `start`") + case .loading: + self.displayProgressView() + case .loaded: + self.hideProgressView() + self.displayActivityView() + case .ready: + self.hideActivityView() + case .error(let error): + self.displayError(error) + self.delegate?.editor(self, didEncounterCriticalError: error) + } + } + } + public let webView: WKWebView - let service: EditorService - let assetsLibrary: EditorAssetsLibrary public var configuration: EditorConfiguration - private var dependencies: EditorDependencies? - private var _isEditorRendered = false - private var _isEditorSetup = false + private let editorService: EditorService + private let mediaPicker: MediaPickerController? private let controller: GutenbergEditorController private let timestampInit = CFAbsoluteTimeGetCurrent() + private let bundleProvider = EditorAssetBundleProvider() + + /// Displays a progress bar indicating loading status + private let progressView = UIEditorProgressView(loadingText: Strings.loadingEditor) + + /// Displays an indeterminate indicator while WebKit loads the JS code + private let waitingView = UIActivityIndicatorView(style: .medium) + + private let errorViewController = EditorErrorViewController() public private(set) var state = EditorState() public weak var delegate: EditorViewControllerDelegate? - private var cancellables: [AnyCancellable] = [] - /// Stores the contextId from the most recent openMediaLibrary call /// to pass back to JavaScript when media is selected private var currentMediaContextId: String? @@ -44,18 +163,27 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro }() /// HTML Preview Manager instance for rendering pattern previews - private(set) lazy var htmlPreviewManager = HTMLPreviewManager(themeStyles: dependencies?.extractThemeStyles()) + /// + /// It is a fatal error to attempt to access this before the editor state is `ready`. + /// + private lazy var htmlPreviewManager: HTMLPreviewManager = { + guard case .ready(let dependencies) = viewState else { + preconditionFailure("Editor is not in a `.ready` state, cannot create HTMLPreviewManager") + } + + return HTMLPreviewManager(themeStyles: dependencies.editorSettings.themeStyles) + }() /// Initalizes the editor with the initial content (Gutenberg). public init( - configuration: EditorConfiguration = .default, + configuration: EditorConfiguration, + dependencies: EditorDependencies? = nil, mediaPicker: MediaPickerController? = nil, isWarmupMode: Bool = false ) { - self.service = EditorService.shared(for: configuration.siteURL) self.configuration = configuration + self.editorService = EditorService(configuration: configuration) self.mediaPicker = mediaPicker - self.assetsLibrary = EditorAssetsLibrary(service: service, configuration: configuration) self.controller = GutenbergEditorController(configuration: configuration) // The `allowFileAccessFromFileURLs` allows the web view to access the @@ -70,11 +198,7 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro // This is important so they user can't select anything but text across blocks. config.selectionGranularity = .character - let schemeHandler = CachedAssetSchemeHandler(service: service) - for scheme in CachedAssetSchemeHandler.supportedURLSchemes { - config.setURLSchemeHandler(schemeHandler, forURLScheme: scheme) - } - config.setURLSchemeHandler(MediaFileSchemeHandler(), forURLScheme: MediaFileSchemeHandler.scheme) + self.bundleProvider.bind(to: config) self.webView = GBWebView(frame: .zero, configuration: config) self.webView.scrollView.keyboardDismissMode = .interactive @@ -82,6 +206,10 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro self.isWarmupMode = isWarmupMode super.init(nibName: nil, bundle: nil) + + if let dependencies { + self.viewState = .loaded(dependencies) + } } required init?(coder: NSCoder) { @@ -109,7 +237,21 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro webView.alpha = 0 if isWarmupMode { - startEditorSetup() + self.loadEditorWithoutDependencies() + } + + // If we don't have dependencies yet, we need to load them + if case .start = viewState { + self.viewState = .loading(self.loadEditorTask) + } + + // If we already have the dependencies, we can just load the editor right away + if case .loaded(let editorDependencies) = viewState { + do { + try self.loadEditor(dependencies: editorDependencies) + } catch { + self.viewState = .error(error) + } } } @@ -123,19 +265,30 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro removeNavigationOverlay() } - private func setUpEditor() { - let webViewConfiguration = webView.configuration - let userContentController = webViewConfiguration.userContentController - let editorInitialConfig = getEditorConfiguration() - userContentController.addUserScript(editorInitialConfig) + @MainActor + private var loadEditorTask: Task { + Task(priority: .userInitiated) { + do { + let dependencies = try await self.editorService.prepare { @MainActor progress in + self.progressView.setProgress(progress, animated: true) + } + try self.loadEditor(dependencies: dependencies) + + self.viewState = .loaded(dependencies) + + } catch { + self.viewState = .error(error) + } + } } - private func loadEditor() { - webView.configuration.userContentController.addScriptMessageHandler( - EditorAssetsProvider(library: assetsLibrary), - contentWorld: .page, - name: "loadFetchedEditorAssets" - ) + @MainActor + private func loadEditor(dependencies: EditorDependencies) throws { + self.bundleProvider.set(bundle: dependencies.assetBundle) + + // Register the handler that provides the editor configuration + let editorConfig = try buildEditorConfiguration(dependencies: dependencies) + webView.configuration.userContentController.addUserScript(editorConfig) if let editorURL = ProcessInfo.processInfo.environment["GUTENBERG_EDITOR_URL"].flatMap(URL.init) { webView.load(URLRequest(url: editorURL)) @@ -145,41 +298,35 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro } } - private func getEditorConfiguration() -> WKUserScript { - let jsCode = """ - window.GBKit = { - siteURL: '\(configuration.siteURL)', - siteApiRoot: '\(configuration.siteApiRoot)', - siteApiNamespace: \(Array(configuration.siteApiNamespace)), - namespaceExcludedPaths: \(Array(configuration.namespaceExcludedPaths)), - authHeader: '\(configuration.authHeader)', - themeStyles: \(configuration.shouldUseThemeStyles), - plugins: \(configuration.shouldUsePlugins), - enableNativeBlockInserter: \(configuration.isNativeInserterEnabled), - hideTitle: \(configuration.shouldHideTitle), - editorSettings: \(dependencies?.editorSettings ?? "undefined"), - locale: '\(configuration.locale)', - post: { - id: \(configuration.postID ?? -1), - title: '\(configuration.escapedTitle)', - content: '\(configuration.escapedContent)' - }, - logLevel: '\(configuration.logLevel)', - enableNetworkLogging: \(configuration.enableNetworkLogging) - }; + /// Load the editor without any external dependencies – this is useful for prewarming the JS + /// + private func loadEditorWithoutDependencies() { + let indexURL = Bundle.module.url(forResource: "index", withExtension: "html", subdirectory: "Gutenberg")! + webView.loadFileURL(indexURL, allowingReadAccessTo: Bundle.module.resourceURL!) + } - localStorage.setItem('GBKit', JSON.stringify(window.GBKit)); + private func buildEditorConfiguration(dependencies: EditorDependencies) throws -> WKUserScript { + let gbkitGlobal = try GBKitGlobal(configuration: self.configuration, dependencies: dependencies) + let stringValue = try gbkitGlobal.toString() + let jsCode = """ + window.GBKit = \(stringValue); + localStorage.setItem('GBKit', JSON.stringify(window.GBKit)); "done"; """ - let editorScript = WKUserScript(source: jsCode, injectionTime: .atDocumentStart, forMainFrameOnly: true) - return editorScript + return WKUserScript(source: jsCode, injectionTime: .atDocumentStart, forMainFrameOnly: true) } /// Deletes all cached editor data for all sites public static func deleteAllData() throws { - try EditorService.deleteAllData() + if FileManager.default.directoryExists(at: Paths.defaultCacheRoot) { + try FileManager.default.removeItem(at: Paths.defaultCacheRoot) + } + + if FileManager.default.directoryExists(at: Paths.defaultStorageRoot) { + try FileManager.default.removeItem(at: Paths.defaultStorageRoot) + } } // MARK: - Public API @@ -191,7 +338,9 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro } private func _setContent(_ content: String) { - guard _isEditorRendered else { return } + guard case .ready = viewState else { + return + } let escapedString = content.addingPercentEncoding(withAllowedCharacters: .alphanumerics)! evaluate("editor.setContent('\(escapedString)');", isCritical: true) @@ -248,18 +397,6 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro self.configuration = newConfiguration } - /// Starts the editor setup process - public func startEditorSetup() { - guard !_isEditorSetup else { return } - _isEditorSetup = true - - Task { @MainActor in - dependencies = await service.dependencies(for: configuration, isWarmup: isWarmupMode) - setUpEditor() - loadEditor() - } - } - // MARK: - Internal (JavaScript) private func evaluate(_ javascript: String, isCritical: Bool = false) { @@ -511,8 +648,20 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro // Only after this point it's safe to use JS `editor` API. private func didLoadEditor() { - guard !_isEditorRendered else { return } - _isEditorRendered = true + + // If the editor uses `location.reload`, we'll end up here more than once + guard case .loaded(let editorDependencies) = viewState else { + return + } + + self.viewState = .ready(editorDependencies) + + // If the editor uses `location.reload`, we'll end up here more than once + guard case .loaded(let editorDependencies) = viewState else { + return + } + + self.viewState = .ready(editorDependencies) UIView.animate(withDuration: 0.2, delay: 0.1, options: [.allowUserInteraction]) { self.webView.alpha = 1 @@ -599,5 +748,72 @@ private final class GutenbergEditorController: NSObject, WKNavigationDelegate, W } } +//MARK: - View Transformation +extension EditorViewController { + + @MainActor + func displayError(_ error: Error) { + self.displayAndCenterView(errorViewController.view!) + self.errorViewController.didMove(toParent: self) + errorViewController.error = error + } + + @MainActor + func hideError() { + self.errorViewController.view.removeFromSuperview() + } + + @MainActor + func displayProgressView() { + self.progressView.layer.opacity = 0 + self.displayAndCenterView(self.progressView) + + UIView.animate(withDuration: 0.2, delay: 0.2) { + self.progressView.layer.opacity = 1 + } + } + + @MainActor + func hideProgressView() { + UIView.animate(withDuration: 0.2) { + self.progressView.layer.opacity = 0 + } completion: { _ in + self.progressView.removeFromSuperview() + } + } + + @MainActor + func displayActivityView() { + self.waitingView.layer.opacity = 0 + self.displayAndCenterView(self.waitingView) + self.waitingView.startAnimating() + + UIView.animate(withDuration: 0.2) { + self.waitingView.layer.opacity = 1 + } + } + + @MainActor + func hideActivityView() { + UIView.animate(withDuration: 0.2) { + self.waitingView.layer.opacity = 0 + } completion: { _ in + self.waitingView.stopAnimating() + self.waitingView.removeFromSuperview() + } + } + + private func displayAndCenterView(_ newView: UIView) { + newView.translatesAutoresizingMaskIntoConstraints = false + self.view.addSubview(newView) + self.view.bringSubviewToFront(newView) + NSLayoutConstraint.activate([ + newView.centerXAnchor.constraint(equalTo: self.view.centerXAnchor), + newView.centerYAnchor.constraint(equalTo: self.view.centerYAnchor), + newView.leadingAnchor.constraint(equalTo: self.view.leadingAnchor), + newView.trailingAnchor.constraint(equalTo: self.view.trailingAnchor), + ]) + } +} #endif diff --git a/ios/Sources/GutenbergKit/Sources/Extensions/Foundation.swift b/ios/Sources/GutenbergKit/Sources/Extensions/Foundation.swift new file mode 100644 index 000000000..4e14de016 --- /dev/null +++ b/ios/Sources/GutenbergKit/Sources/Extensions/Foundation.swift @@ -0,0 +1,120 @@ +import CryptoKit +import Foundation + +// MARK: - FileManager Extensions + +extension FileManager { + /// Checks whether a file exists at the given URL. + /// + /// - Parameter url: The file URL to check. + /// - Returns: `true` if a file exists at the URL, `false` otherwise. + public func fileExists(at url: URL) -> Bool { + return self.fileExists(atPath: url.path(percentEncoded: false)) + } + + /// Checks whether a directory exists at the given URL. + /// + /// - Parameter url: The directory URL to check. + /// - Returns: `true` if a directory exists at the URL, `false` otherwise. + /// + /// Returns `false` if a file (not a directory) exists at the URL. + public func directoryExists(at url: URL) -> Bool { + var isDirectory: ObjCBool = true + let exists = self.fileExists(atPath: url.path(percentEncoded: false), isDirectory: &isDirectory) + return exists && isDirectory.boolValue + } +} + +// MARK: - URL Extensions + +extension URL { + /// Returns the path and query components of the URL as a single string. + /// + /// If the URL has a query string, returns the path followed by `?` and the query. + /// Otherwise, returns just the path. + /// + /// Example: For `https://example.com/api/posts?page=1`, returns `/api/posts?page=1`. + var pathAndQuery: String { + if let query = self.query(percentEncoded: false) { + return self.path() + "?" + query + } + + return self.path(percentEncoded: false) + } + + /// Appends a raw path string to the URL without percent-encoding. + /// + /// This method handles slash normalization between the base URL and the path being appended, + /// ensuring exactly one slash separates them. + /// + /// - Parameter rawPath: The path to append. May or may not start with a slash. + /// - Returns: A new URL with the path appended. + func appending(rawPath: String) -> URL { + let urlString = self.absoluteString + + if urlString.hasSuffix("/") && rawPath.hasPrefix("/") { + return URL(string: urlString + rawPath.trimmingPrefix("/"))! + } + + if !urlString.hasSuffix("/") && !rawPath.hasPrefix("/") { + return URL(string: urlString + "/" + rawPath)! + } + + return URL(string: urlString + rawPath)! + } + + func replacing(scheme: String) -> URL { + var components = URLComponents(url: self, resolvingAgainstBaseURL: false) + components!.scheme = scheme + return components!.url! + } +} + +// MARK: - URLRequest Extensions + +extension URLRequest { + /// Creates a URL request with the specified URL and HTTP method. + /// + /// - Parameters: + /// - url: The URL for the request. + /// - method: The HTTP method to use. + init(url: URL, method: EditorHttpMethod) { + var request = URLRequest(url: url) + request.httpMethod = method.rawValue + self = request + } +} + +// MARK: - Data Extensions + +extension Data { + /// Computes a SHA-256 hash of the data and returns it as a hexadecimal string. + /// + /// - Returns: A 64-character lowercase hexadecimal string representing the SHA-256 hash. + func hash() -> String { + SHA256.hash(data: self).compactMap { String(format: "%02x", $0) }.joined() + } +} + +// MARK: - String Extensions + +extension String { + /// Calculates SHA1 from the given string and returns its hex representation. + /// + /// ```swift + /// print("http://test.com".sha1) + /// // prints "50334ee0b51600df6397ce93ceed4728c37fee4e" + /// ``` + var sha1: String { + guard let input = self.data(using: .utf8) else { + assertionFailure("Failed to generate data for the string") + return "" // The conversion to .utf8 should never fail + } + let digest = Insecure.SHA1.hash(data: input) + var output = "" + for byte in digest { + output.append(String(format: "%02x", byte)) + } + return output + } +} diff --git a/ios/Sources/GutenbergKit/Sources/Extensions/String+Extensions.swift b/ios/Sources/GutenbergKit/Sources/Extensions/String+Extensions.swift deleted file mode 100644 index d579be302..000000000 --- a/ios/Sources/GutenbergKit/Sources/Extensions/String+Extensions.swift +++ /dev/null @@ -1,23 +0,0 @@ -import Foundation -import CryptoKit - -extension String { - /// Calculates SHA1 from the given string and returns its hex representation. - /// - /// ```swift - /// print("http://test.com".sha1) - /// // prints "50334ee0b51600df6397ce93ceed4728c37fee4e" - /// ``` - var sha1: String { - guard let input = self.data(using: .utf8) else { - assertionFailure("Failed to generate data for the string") - return "" // The conversion to .utf8 should never fail - } - let digest = Insecure.SHA1.hash(data: input) - var output = "" - for byte in digest { - output.append(String(format: "%02x", byte)) - } - return output - } -} diff --git a/ios/Sources/GutenbergKit/Sources/Model/EditorAssetBundle.swift b/ios/Sources/GutenbergKit/Sources/Model/EditorAssetBundle.swift new file mode 100644 index 000000000..b4d36d843 --- /dev/null +++ b/ios/Sources/GutenbergKit/Sources/Model/EditorAssetBundle.swift @@ -0,0 +1,200 @@ +import CryptoKit +import Foundation +import SwiftSoup + +/// A cached collection of editor assets downloaded from a remote manifest. +/// +/// An `EditorAssetBundle` represents an on-disk cache of JavaScript and CSS assets +/// required by WordPress plugins and themes. The bundle is created by downloading +/// all assets specified in a server-provided manifest and storing them locally. +/// +/// Bundles are identified by their manifest checksum, ensuring that different +/// versions of plugin/theme assets are stored separately. The `downloadDate` +/// property allows the system to prefer newer bundles over older ones. +/// +/// Assets are accessed via URL lookup - the bundle maintains a mapping from +/// original remote URLs to local file paths. +public struct EditorAssetBundle: Sendable, Equatable, Hashable { + + + /// The EditorRepresentation has the exact same format as `RemoteEditorAssetManifest.RawManifest` – what we're passing to Gutenberg + /// looks exactly like what it'd get if it called `/wpcom/v2/editor-assets` directly. + /// + /// The difference is that we've rewritten all of the URLs to reference local files with our custom URL scheme so they can be provided from the on-disk cache. + typealias EditorRepresentation = RemoteEditorAssetManifest.RawManifest + + /// Errors that can occur when working with asset bundles. + enum Errors: Error, Equatable { + /// The requested asset URL is not part of this bundle's manifest. + case invalidRequest + + /// An asset with the same key already exists in the lookup table. + case assetAlreadyExists(String) + } + + /// The data structure stored on-disk + struct RawAssetBundle: Codable { + let manifest: LocalEditorAssetManifest + let downloadDate: Date + } + + /// The bundle's unique identifier, derived from its manifest checksum. + /// + /// Two bundles with the same ID have identical manifests and _should_ contain identical assets. This may not be + /// true if a site is under development, so asset bundles should have some mechanism for being re-downloaded entirely. + public var id: String { + manifest.checksum + } + + /// The manifest that defines which assets belong to this bundle. + let manifest: LocalEditorAssetManifest + + /// The date this bundle was created by downloading the manifest contents. + /// + /// Used to determine which bundle is most recent when multiple bundles exist. + let downloadDate: Date + + /// The number of assets stored in this bundle. + public var assetCount: Int { + manifest.assetUrls.count + } + + let bundleRoot: URL + + init(raw: RawAssetBundle, bundleRoot: URL) { + self.manifest = raw.manifest + self.downloadDate = raw.downloadDate + self.bundleRoot = bundleRoot + } + + init(manifest: LocalEditorAssetManifest, downloadDate: Date = Date(), bundleRoot: URL) throws { + self.manifest = manifest + self.downloadDate = downloadDate + self.bundleRoot = bundleRoot + } + + /// Loads a bundle from a JSON file on disk. + /// + /// - Parameter url: The file URL of the bundle's `manifest.json`. + /// - Throws: An error if the file cannot be read or decoded. + init(url: URL) throws { + self = try EditorAssetBundle(data: Data(contentsOf: url), bundleRoot: url.deletingLastPathComponent()) + } + + init(data: Data, bundleRoot: URL) throws { + let rawBundle = try JSONDecoder().decode(RawAssetBundle.self, from: data) + self = EditorAssetBundle( + raw: rawBundle, + bundleRoot: bundleRoot + ) + } + + /// Checks whether this bundle contains cached data for the given asset URL. + /// + /// - Parameter url: The original remote URL of the asset. + /// - Returns: `true` if the asset is cached in this bundle, `false` otherwise. + public func hasAssetData(for url: URL) -> Bool { + FileManager.default.fileExists(at: self.assetDataPath(for: url)) + } + + /// Returns the local file path for a cached asset. + /// + /// - Parameter url: The original remote URL of the asset. + /// - Returns: The local file URL where the asset is stored. + /// - Throws: `Errors.invalidRequest` if the asset is not in this bundle. + public func assetDataPath(for url: URL) -> URL { + let path = url.path(percentEncoded: false) + let bundlePath = self.bundleRoot.appending(rawPath: path) + return bundlePath + } + + /// Reads and returns the cached data for an asset. + /// + /// - Parameter url: The original remote URL of the asset. + /// - Returns: The asset's file contents. + /// - Throws: `Errors.invalidRequest` if the asset is not in this bundle, + /// or a file system error if the file cannot be read. + public func assetData(for url: URL) throws -> Data { + let fileURL = assetDataPath(for: url) + return try Data(contentsOf: fileURL) + } + + /// Reads the editor representation as a strongly-typed struct. + /// + /// The editor representation contains the rewritten script and style tags + /// with URLs pointing to the local cache via the custom URL scheme. + /// + /// - Throws: An error if the file doesn't exist or cannot be decoded. + func getEditorRepresentation() throws -> EditorRepresentation { + let path = self.bundleRoot.appending(path: "editor-representation.json") + let data = try Data(contentsOf: path) + return try JSONDecoder().decode(EditorRepresentation.self, from: data) + } + + /// Reads the editor representation as a JSON-serializable dictionary. + /// + /// Use this overload when you need to pass the representation to JavaScript. + /// + /// - Throws: An error if the file doesn't exist or cannot be parsed. + func getEditorRepresentation() throws -> Any { + let path = self.bundleRoot.appending(path: "editor-representation.json") + let data = try Data(contentsOf: path) + return try JSONSerialization.jsonObject(with: data) + } + + /// Saves the editor representation to disk. + /// + /// - Parameter representation: The processed script/style tags with rewritten URLs. + /// - Throws: An error if encoding or writing fails. + func setEditorRepresentation(_ representation: EditorRepresentation) throws { + let path = self.bundleRoot.appending(path: "editor-representation.json") + try JSONEncoder().encode(representation).write(to: path, options: .atomic) + } + + /// Returns the bundle's manifest as JSON data for storage. + func dataRepresentation() throws -> Data { + try JSONEncoder().encode(RawAssetBundle( + manifest: self.manifest, + downloadDate: self.downloadDate + )) + } + + /// Writes the bundle's JSON representation to disk. + /// + /// - Parameter path: The file URL where the bundle should be saved. + /// - Throws: An error if encoding fails or the file cannot be written. + func writeManifest(to path: URL? = nil) throws { + try FileManager.default.createDirectory(at: self.bundleRoot, withIntermediateDirectories: true) + let destination = path ?? self.bundleRoot.appendingPathComponent("manifest.json") + try self.dataRepresentation().write(to: destination, options: .atomic) + } + + /// Copies the bundle to the given directoy. + /// + /// APFS makes this instant and zero-cost. + func copy(to destination: URL) throws -> EditorAssetBundle { + + // Don't bother persisting an empty bundle + guard self != .empty else { + return .empty + } + + if FileManager.default.directoryExists(at: destination) { + try FileManager.default.removeItem(at: destination) + } + + let destinationParent = destination.deletingLastPathComponent() + try FileManager.default.createDirectory(at: destinationParent, withIntermediateDirectories: true) + try FileManager.default.copyItem(at: self.bundleRoot, to: destination) + + return try EditorAssetBundle(url: destination.appending(path: "manifest.json")) + } + + static let empty: EditorAssetBundle = EditorAssetBundle( + raw: RawAssetBundle( + manifest: .empty, + downloadDate: Date() + ), + bundleRoot: URL.temporaryDirectory + ) +} diff --git a/ios/Sources/GutenbergKit/Sources/Model/EditorAssetManifest.swift b/ios/Sources/GutenbergKit/Sources/Model/EditorAssetManifest.swift new file mode 100644 index 000000000..869cf2b3c --- /dev/null +++ b/ios/Sources/GutenbergKit/Sources/Model/EditorAssetManifest.swift @@ -0,0 +1,241 @@ +import Foundation +import SwiftSoup + +/// A raw manifest response from the WordPress editor-assets API endpoint. +/// +/// This struct represents the unprocessed server response containing HTML script and style +/// tags as raw strings. It computes a checksum of the original data to detect changes. +/// +/// Use `LocalEditorAssetManifest` for a processed version with parsed URLs. +struct RemoteEditorAssetManifest: Codable, Equatable { + + /// The JSON structure returned by the server. + struct RawManifest: Codable, Equatable { + let scripts: String + let styles: String + let allowedBlockTypes: [String] + + enum CodingKeys: String, CodingKey { + case scripts + case styles + case allowedBlockTypes = "allowed_block_types" + } + + static let empty = RawManifest(scripts: "", styles: "", allowedBlockTypes: []) + } + + /// The raw HTML containing `"# - let manifest = EditorAssetsManifest(scripts: scriptHTML, styles: "", allowedBlockTypes: []) - #expect(try manifest.parseAssetLinks(defaultScheme: "http") == ["http://w.org/lib.js"]) - #expect(try manifest.parseAssetLinks(defaultScheme: "https") == ["https://w.org/lib.js"]) - } - - private func json(named name: String) throws -> Data { - let json = Bundle.module.url(forResource: name, withExtension: "json")! - return try Data(contentsOf: json) - } -} diff --git a/ios/Tests/GutenbergKitTests/EditorServiceTests.swift b/ios/Tests/GutenbergKitTests/EditorServiceTests.swift deleted file mode 100644 index 3c4323792..000000000 --- a/ios/Tests/GutenbergKitTests/EditorServiceTests.swift +++ /dev/null @@ -1,352 +0,0 @@ -import Foundation -import Testing -@testable import GutenbergKit - -@Suite("Editor Service Tests") -struct EditorServiceTests { - - @Test("Successfully loads editor dependencies") - func successfullyLoadsDependencies() async throws { - let context = try TestContext(manifestResource: "manifest-test-case-2") - context.session.mockSettings() - context.session.mockManifest(context.manifestData) - context.session.mockAllAssets(context.assetURLs) - - let service = context.createService() - let configuration = context.createConfiguration() - - // WHEN - let dependencies = await service.dependencies(for: configuration) - - // THEN dependencies are loaded and editor settings are returned as is - #expect(dependencies.editorSettings == #"{"alignWide": true}"#) - - // THEN assets are available on disk and can be loaded - for assetURL in context.assetURLs { - let cachedURL = try #require(CachedAssetSchemeHandler.cachedURL(forWebLink: assetURL)) - let gbkURL = try #require(URL(string: cachedURL)) - let (response, data) = try await service.getCachedAsset(from: gbkURL) - #expect(response.url == gbkURL) - #expect(!data.isEmpty) - } - } - - @Test("Loads settings but not manifest when asset download fails") - func loadsSettingsWhenAssetFails() async throws { - let context = try TestContext(manifestResource: "manifest-test-case-2") - context.session.mockSettings() - context.session.mockManifest(context.manifestData) - context.session.mockAllAssets(Array(context.assetURLs[0...1])) - context.session.mockFailedAssets(Array(context.assetURLs[2...4])) - - let service = context.createService() - let configuration = context.createConfiguration() - - let dependencies = await service.dependencies(for: configuration) - - // THEN settings are loaded - #expect(dependencies.editorSettings == #"{"alignWide": true}"#) - } - - @Test("Upgrades manifest and assets when version changes") - func upgradesManifestOnVersionChange() async throws { - let context = try TestContext(manifestResource: "manifest-test-case-2") - context.session.mockSettings() - context.session.mockManifest(context.manifestData) - context.session.mockAllAssets(context.assetURLs) - - let service = context.createService() - let configuration = context.createConfiguration() - - let initialDependencies = await service.dependencies(for: configuration) - #expect(initialDependencies.editorSettings == #"{"alignWide": true}"#) - - // WHEN new manifest is returned with updated assets - let upgradedContext = try TestContext(manifestResource: "manifest-test-case-3") - context.session.mockManifest(upgradedContext.manifestData) - context.session.mockAllAssets(upgradedContext.assetURLs) - - // Force refresh with new manifest - await service.refresh(configuration: configuration) - - let upgradedDependencies = await service.dependencies(for: configuration, isWarmup: true) - - // THEN settings are still available - #expect(upgradedDependencies.editorSettings == #"{"alignWide": true}"#) - - // THEN upgraded assets are available on disk - for assetURL in upgradedContext.assetURLs { - let cachedURL = try #require(CachedAssetSchemeHandler.cachedURL(forWebLink: assetURL)) - let gbkURL = try #require(URL(string: cachedURL)) - let (response, data) = try await service.getCachedAsset(from: gbkURL) - #expect(response.url == gbkURL) - #expect(!data.isEmpty) - } - } - - @Test("Handles concurrent refresh requests correctly") - func concurrentRefreshRequests() async throws { - let context = try TestContext(manifestResource: "manifest-test-case-2") - context.session.mockSettings() - context.session.mockManifest(context.manifestData) - context.session.mockAllAssets(context.assetURLs) - - let service = context.createService() - let configuration = context.createConfiguration() - - // Trigger multiple refreshes concurrently - async let refresh1: Void = service.refresh(configuration: configuration) - async let refresh2: Void = service.refresh(configuration: configuration) - async let refresh3: Void = service.refresh(configuration: configuration) - - _ = await (refresh1, refresh2, refresh3) - - // Verify network was only called once despite 3 refresh calls - #expect(context.session.requestCount(for: "wp-block-editor/v1/settings") == 1) - #expect(context.session.requestCount(for: "wpcom/v2/editor-assets") == 1) - } - - @Test("Successfully loads cached asset from disk") - func getCachedAssetSuccess() async throws { - let context = try TestContext(manifestResource: "manifest-test-case-2") - context.session.mockSettings() - context.session.mockManifest(context.manifestData) - context.session.mockAllAssets(context.assetURLs) - - let service = context.createService() - let configuration = context.createConfiguration() - - // Load dependencies to cache assets - _ = await service.dependencies(for: configuration) - - // Now try to load a cached asset - let testAssetURL = context.assetURLs[0] - let cachedURL = try #require(CachedAssetSchemeHandler.cachedURL(forWebLink: testAssetURL)) - let gbkURL = try #require(URL(string: cachedURL)) - - let (response, data) = try await service.getCachedAsset(from: gbkURL) - - // Verify response - #expect(response.url == gbkURL) - #expect(!data.isEmpty) - #expect(response.mimeType == "application/javascript") - } - - @Test("Skips refresh when data is fresh (< 30s)") - func refreshNotNeededWithin30Seconds() async throws { - let context = try TestContext(manifestResource: "manifest-test-case-2") - context.session.mockSettings() - context.session.mockManifest(context.manifestData) - context.session.mockAllAssets(context.assetURLs) - - let service = context.createService() - let configuration = context.createConfiguration() - - // Initial load - _ = await service.dependencies(for: configuration) - - // Wait briefly to allow background refresh task to start (but not complete 30s threshold) - try await Task.sleep(for: .milliseconds(100)) - - // Second load within 30 seconds with warmup flag - should not trigger refresh - _ = await service.dependencies(for: configuration, isWarmup: true) - - // Wait to ensure background refresh logic has time to evaluate (but not execute) - try await Task.sleep(for: .milliseconds(100)) - - // Verify network was only called once - #expect(context.session.requestCount(for: "wp-block-editor/v1/settings") == 1) - #expect(context.session.requestCount(for: "wpcom/v2/editor-assets") == 1) - } - - @Test("Handles invalid siteApiRoot URL gracefully") - func invalidSiteApiRootURL() async throws { - let context = try TestContext(manifestResource: "manifest-test-case-2") - let service = context.createService() - - // Create configuration with invalid URL - let configuration = EditorConfigurationBuilder() - .setSiteUrl("https://example.com") - .setSiteApiRoot("not a valid url!") - .setAuthHeader("Bearer test-token") - .build() - - // Should not crash, just log error and return empty dependencies - let dependencies = await service.dependencies(for: configuration) - - // Dependencies should be empty since refresh failed - #expect(dependencies.editorSettings == nil) - } - - @Test("Returns error when cached asset doesn't exist") - func getCachedAssetNotFound() async throws { - let context = try TestContext(manifestResource: "manifest-test-case-2") - let service = context.createService() - - // Try to load an asset that was never cached - let fakeURL = URL(string: "gbk-cache-https://example.com/missing.js")! - - // Should throw file not found error - await #expect(throws: URLError.self) { - try await service.getCachedAsset(from: fakeURL) - } - } - - @Test("Successfully loads processed manifest") - func getProcessedManifestSuccess() async throws { - let context = try TestContext(manifestResource: "manifest-test-case-2") - context.session.mockSettings() - context.session.mockManifest(context.manifestData) - context.session.mockAllAssets(context.assetURLs) - - let service = context.createService() - let configuration = context.createConfiguration() - - // Load dependencies to create and cache the processed manifest - _ = await service.dependencies(for: configuration) - - // Now get the processed manifest - let manifest = try await service.getProcessedManifest() - - // Verify manifest is valid JSON string - #expect(!manifest.isEmpty) - - // Verify it contains gbk-cache scheme URLs (processed format) - #if canImport(UIKit) - #expect(manifest.contains("gbk-cache-https:")) - #endif - - // Verify it contains expected asset references - #expect(manifest.contains("jetpack")) - } - - @Test("Returns error when processed manifest doesn't exist") - func getProcessedManifestNotFound() async throws { - let context = try TestContext(manifestResource: "manifest-test-case-2") - let service = context.createService() - - // Try to get manifest before it's been created - await #expect(throws: Error.self) { - try await service.getProcessedManifest() - } - } - - @Test("Cleans up orphaned assets after upgrade") - func cleansUpOrphanedAssets() async throws { - let context = try TestContext(manifestResource: "manifest-test-case-2") - context.session.mockSettings() - context.session.mockManifest(context.manifestData) - context.session.mockAllAssets(context.assetURLs) - - let service = context.createService() - let configuration = context.createConfiguration() - - // Load initial v13.9 dependencies - _ = await service.dependencies(for: configuration) - - // Verify all v13.9 assets exist on disk - let assetsDir = context.testDir.appendingPathComponent("assets") - let initialFiles = try FileManager.default.contentsOfDirectory(atPath: assetsDir.path) - #expect(initialFiles.count == 5) // All 5 v13.9 assets - - // Upgrade to v14.0 (which removes slideshow, upgrades forms, adds ai-assistant) - let upgradedContext = try TestContext(manifestResource: "manifest-test-case-3") - context.session.mockManifest(upgradedContext.manifestData) - context.session.mockAllAssets(upgradedContext.assetURLs) - context.makeStateFileOld() - await service.refresh(configuration: configuration) - - // Run cleanup - try await service.cleanupOrphanedAssets() - - // Verify orphaned assets (slideshow v13.9, old versions) are deleted - let filesAfterCleanup = try FileManager.default.contentsOfDirectory(atPath: assetsDir.path) - #expect(filesAfterCleanup.count == 4) // Only 4 v14.0 assets remain - - // Verify slideshow assets are gone - let slideshowFilename = service.cachedFilename(for: "https://example.com/wp-content/plugins/jetpack/_inc/blocks/slideshow/editor.js?ver=13.9") - #expect(!filesAfterCleanup.contains(slideshowFilename)) - - // Verify v14.0 assets are retained - for assetURL in upgradedContext.assetURLs { - let filename = service.cachedFilename(for: assetURL) - #expect(filesAfterCleanup.contains(filename)) - } - } -} - -// MARK: - Test Helpers - -private struct TestContext { - let session = MockURLSession() - let testDir: URL - let manifestData: Data - let assetURLs: [String] - - init(manifestResource: String) throws { - let manifestURL = Bundle.module.url(forResource: manifestResource, withExtension: "json")! - self.manifestData = try Data(contentsOf: manifestURL) - - let manifest = try JSONDecoder().decode(EditorAssetsManifest.self, from: manifestData) - self.assetURLs = try manifest.parseAssetLinks() - - self.testDir = FileManager.default.temporaryDirectory - .appendingPathComponent(UUID().uuidString, isDirectory: true) - try FileManager.default.createDirectory(at: testDir, withIntermediateDirectories: true) - } - - func createService() -> EditorService { - EditorService(siteURL: "https://example.com", storeURL: testDir, networkSession: session) - } - - func createConfiguration() -> EditorConfiguration { - EditorConfigurationBuilder() - .setSiteUrl("https://example.com") - .setSiteApiRoot("https://example.com") - .setAuthHeader("Bearer test-token") - .build() - } - - func makeStateFileOld() { - let stateFileURL = testDir.appendingPathComponent("state.json") - let oldDate = Date().addingTimeInterval(-31) // 31 seconds ago - let state = EditorService.State(refreshDate: oldDate) - try? JSONEncoder().encode(state).write(to: stateFileURL) - } -} - -private extension MockURLSession { - func mockSettings() { - mockResponse( - for: "https://example.com/wp-block-editor/v1/settings", - data: """ - {"alignWide": true} - """.data(using: .utf8)!, - statusCode: 200 - ) - } - - func mockManifest(_ data: Data) { - mockResponse( - for: "https://example.com/wpcom/v2/editor-assets?exclude=core,gutenberg", - data: data, - statusCode: 200 - ) - } - - func mockAllAssets(_ assetURLs: [String]) { - for assetURL in assetURLs { - mockResponse( - for: assetURL, - data: assetURL.data(using: .utf8)!, - statusCode: 200 - ) - } - } - - func mockFailedAssets(_ assetURLs: [String]) { - for assetURL in assetURLs { - mockResponse(for: assetURL, data: Data(), statusCode: 404) - } - } -} From c95bc89047d5b5b7b4b669ec5d942b3cc0d8d377 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Tue, 9 Dec 2025 09:22:09 -0700 Subject: [PATCH 06/25] Fix rebase issue --- .../GutenbergKit/Sources/EditorViewController.swift | 7 ------- 1 file changed, 7 deletions(-) diff --git a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift index 570a9334a..1327e279c 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift @@ -656,13 +656,6 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro self.viewState = .ready(editorDependencies) - // If the editor uses `location.reload`, we'll end up here more than once - guard case .loaded(let editorDependencies) = viewState else { - return - } - - self.viewState = .ready(editorDependencies) - UIView.animate(withDuration: 0.2, delay: 0.1, options: [.allowUserInteraction]) { self.webView.alpha = 1 } From 6aa25bc77c452509e7bcd93b6dc2e636528f5bc2 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Tue, 9 Dec 2025 12:56:32 -0700 Subject: [PATCH 07/25] fix visibility issue --- ios/Sources/GutenbergKit/Sources/EditorLogging.swift | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/ios/Sources/GutenbergKit/Sources/EditorLogging.swift b/ios/Sources/GutenbergKit/Sources/EditorLogging.swift index d51e4f0ea..f83e40dbb 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorLogging.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorLogging.swift @@ -64,8 +64,7 @@ public enum EditorLogger { public nonisolated(unsafe) static var logLevel: EditorLogLevel = .error } - -public func log(_ level: EditorLogLevel, _ message: @autoclosure () -> String) { +func log(_ level: EditorLogLevel, _ message: @autoclosure () -> String) { guard level.priority >= EditorLogger.logLevel.priority, let logger = EditorLogger.shared else { From b1b9a853f23d994f2970e055bd366ced72e5bf26 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Tue, 9 Dec 2025 14:52:18 -0700 Subject: [PATCH 08/25] Address HTTPClient feedback --- .../Sources/EditorHTTPClient.swift | 86 +++++++++---------- .../Sources/Extensions/Foundation.swift | 2 +- .../Sources/RESTAPIRepository.swift | 28 +++--- .../Sources/Stores/EditorAssetLibrary.swift | 6 +- .../Sources/Stores/EditorURLCache.swift | 6 +- 5 files changed, 60 insertions(+), 68 deletions(-) diff --git a/ios/Sources/GutenbergKit/Sources/EditorHTTPClient.swift b/ios/Sources/GutenbergKit/Sources/EditorHTTPClient.swift index 73a6102f0..6973288da 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorHTTPClient.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorHTTPClient.swift @@ -3,9 +3,8 @@ import OSLog /// A protocol for making authenticated HTTP requests to the WordPress REST API. public protocol EditorHTTPClientProtocol: Sendable { - func GET(url: URL) async throws -> (Data, HTTPURLResponse) - func OPTIONS(url: URL) async throws -> (Data, HTTPURLResponse) - func download(url: URL) async throws -> (URL, HTTPURLResponse) + func perform(_ urlRequest: URLRequest) async throws -> (Data, HTTPURLResponse) + func download(_ urlRequest: URLRequest) async throws -> (URL, HTTPURLResponse) } /// A delegate for observing HTTP requests made by the editor. @@ -40,65 +39,58 @@ public actor EditorHTTPClient: EditorHTTPClientProtocol { private let urlSession: URLSession private let authHeader: String private let delegate: EditorHTTPClientDelegate? - + private let requestTimeout: TimeInterval + public init( urlSession: URLSession, authHeader: String, - delegate: EditorHTTPClientDelegate? = nil + delegate: EditorHTTPClientDelegate? = nil, + requestTimeout: TimeInterval = 60 // `URLRequest` default ) { self.urlSession = urlSession self.authHeader = authHeader self.delegate = delegate + self.requestTimeout = requestTimeout } - - public func GET(url: URL) async throws -> (Data, HTTPURLResponse) { - var request = URLRequest(url: url) - request.httpMethod = "GET" - return try await self.perform(request: request) - } - - public func OPTIONS(url: URL) async throws -> (Data, HTTPURLResponse) { - var request = URLRequest(url: url) - request.httpMethod = "OPTIONS" - return try await self.perform(request: request) - } - - public func download(url: URL) async throws -> (URL, HTTPURLResponse) { - var request = URLRequest(url: url) - request.addValue(self.authHeader, forHTTPHeaderField: "Authorization") - - let (url, response) = try await self.urlSession.download(for: request) - - let httpResponse = response as! HTTPURLResponse - - guard 200...299 ~= httpResponse.statusCode else { - throw ClientError.downloadFailed(statusCode: httpResponse.statusCode) - } - - return (url, response as! HTTPURLResponse) - } - - private func perform(request: URLRequest) async throws -> (Data, HTTPURLResponse) { - var signedRequest = request - signedRequest.setValue(self.authHeader, forHTTPHeaderField: "Authorization") - signedRequest.timeoutInterval = 60 - - let (data, response) = try await self.urlSession.data(for: signedRequest) - self.delegate?.didPerformRequest(signedRequest, response: response, data: data) - + + public func perform(_ urlRequest: URLRequest) async throws -> (Data, HTTPURLResponse) { + var mutableRequest = urlRequest + mutableRequest.setValue(self.authHeader, forHTTPHeaderField: "Authorization") + mutableRequest.timeoutInterval = self.requestTimeout + + let (data, response) = try await self.urlSession.data(for: mutableRequest) + self.delegate?.didPerformRequest(mutableRequest, response: response, data: data) + let httpResponse = response as! HTTPURLResponse - + guard 200...299 ~= httpResponse.statusCode else { - Logger.http.error("📡 HTTP error fetching \(request.url!.absoluteString): \(httpResponse.statusCode)") - + Logger.http.error("📡 HTTP error fetching \(mutableRequest.url!.absoluteString): \(httpResponse.statusCode)") + if let wpError = try? JSONDecoder().decode(WPError.self, from: data) { throw ClientError.wpError(wpError) } - + throw ClientError.unknown(response: data, statusCode: httpResponse.statusCode) } - + return (data, httpResponse) } - + + public func download(_ urlRequest: URLRequest) async throws -> (URL, HTTPURLResponse) { + var mutableRequest = urlRequest + mutableRequest.addValue(self.authHeader, forHTTPHeaderField: "Authorization") + mutableRequest.timeoutInterval = self.requestTimeout + + let (url, response) = try await self.urlSession.download(for: mutableRequest) + + let httpResponse = response as! HTTPURLResponse + + guard 200...299 ~= httpResponse.statusCode else { + Logger.http.error("📡 HTTP error fetching \(mutableRequest.url!.absoluteString): \(httpResponse.statusCode)") + + throw ClientError.downloadFailed(statusCode: httpResponse.statusCode) + } + + return (url, response as! HTTPURLResponse) + } } diff --git a/ios/Sources/GutenbergKit/Sources/Extensions/Foundation.swift b/ios/Sources/GutenbergKit/Sources/Extensions/Foundation.swift index 4e14de016..aa76bb7c4 100644 --- a/ios/Sources/GutenbergKit/Sources/Extensions/Foundation.swift +++ b/ios/Sources/GutenbergKit/Sources/Extensions/Foundation.swift @@ -78,7 +78,7 @@ extension URLRequest { /// - Parameters: /// - url: The URL for the request. /// - method: The HTTP method to use. - init(url: URL, method: EditorHttpMethod) { + init(method: EditorHttpMethod, url: URL) { var request = URLRequest(url: url) request.httpMethod = method.rawValue self = request diff --git a/ios/Sources/GutenbergKit/Sources/RESTAPIRepository.swift b/ios/Sources/GutenbergKit/Sources/RESTAPIRepository.swift index 53448bd52..a65df4d10 100644 --- a/ios/Sources/GutenbergKit/Sources/RESTAPIRepository.swift +++ b/ios/Sources/GutenbergKit/Sources/RESTAPIRepository.swift @@ -44,7 +44,8 @@ public struct RESTAPIRepository: Sendable { // MARK: Post @discardableResult public func fetchPost(id: Int) async throws -> EditorURLResponse { - let response = try await self.httpClient.GET(url: buildPostUrl(id: id)) + let request = URLRequest(method: .GET, url: self.buildPostUrl(id: id)) + let response = try await self.httpClient.perform(request) return EditorURLResponse(response) } @@ -68,7 +69,9 @@ public struct RESTAPIRepository: Sendable { return .undefined } - let response = try await self.httpClient.GET(url: editorSettingsUrl) + let request = URLRequest(method: .GET, url: editorSettingsUrl) + let response = try await self.httpClient.perform(request) + let editorSettings = EditorSettings(data: response.0) let urlResponse = EditorURLResponse((try JSONEncoder().encode(editorSettings), response.1)) @@ -87,7 +90,7 @@ public struct RESTAPIRepository: Sendable { // MARK: GET Post Type @discardableResult package func fetchPostType(for type: String) async throws -> EditorURLResponse { - try await GET(url: buildPostTypeUrl(type: type)) + try await self.perform(method: .GET, url: self.buildPostTypeUrl(type: type)) } package func readPostType(for type: String) throws -> EditorURLResponse? { @@ -105,7 +108,7 @@ public struct RESTAPIRepository: Sendable { // MARK: GET Active Theme @discardableResult package func fetchActiveTheme() async throws -> EditorURLResponse { - try await GET(url: self.activeThemeUrl) + try await self.perform(method: .GET, url: self.activeThemeUrl) } package func readActiveTheme() throws -> EditorURLResponse? { @@ -115,7 +118,7 @@ public struct RESTAPIRepository: Sendable { // MARK: OPTIONS Settings @discardableResult package func fetchSettingsOptions() async throws -> EditorURLResponse { - try await OPTIONS(url: self.siteSettingsUrl) + try await self.perform(method: .OPTIONS, url: self.siteSettingsUrl) } package func readSettingsOptions() throws -> EditorURLResponse? { @@ -125,24 +128,19 @@ public struct RESTAPIRepository: Sendable { // MARK: Post Types @discardableResult package func fetchPostTypes() async throws -> EditorURLResponse { - try await self.GET(url: self.postTypesUrl) + try await self.perform(method: .GET, url: self.postTypesUrl) } package func readPostTypes() throws -> EditorURLResponse? { try self.cache.response(for: self.postTypesUrl, httpMethod: .GET) } - private func GET(url: URL) async throws -> EditorURLResponse { - let response = try await self.httpClient.GET(url: url) + private func perform(method: EditorHttpMethod, url: URL) async throws -> EditorURLResponse { + let request = URLRequest(method: method, url: url) + let response = try await self.httpClient.perform(request) let urlResponse = EditorURLResponse(response) - try self.cache.store(urlResponse, for: url, httpMethod: .GET) + try self.cache.store(urlResponse, for: url, httpMethod: method) return urlResponse } - private func OPTIONS(url: URL) async throws -> EditorURLResponse { - let response = try await self.httpClient.OPTIONS(url: url) - let urlResponse = EditorURLResponse(response) - try self.cache.store(urlResponse, for: url, httpMethod: .OPTIONS) - return urlResponse - } } diff --git a/ios/Sources/GutenbergKit/Sources/Stores/EditorAssetLibrary.swift b/ios/Sources/GutenbergKit/Sources/Stores/EditorAssetLibrary.swift index 9a70b45f5..ffaaf7595 100644 --- a/ios/Sources/GutenbergKit/Sources/Stores/EditorAssetLibrary.swift +++ b/ios/Sources/GutenbergKit/Sources/Stores/EditorAssetLibrary.swift @@ -38,7 +38,9 @@ public actor EditorAssetLibrary { package func fetchManifest(cachePolicy: CachePolicy = .useExisting) async throws -> LocalEditorAssetManifest { guard configuration.shouldUsePlugins else { return .empty } - let data = try await httpClient.GET(url: self.editorAssetsUrl(for: self.configuration)).0 + let data = try await httpClient.perform( + URLRequest(method: .GET, url: self.editorAssetsUrl(for: self.configuration)) + ).0 let remoteManifest = try RemoteEditorAssetManifest(data: data) if case .useExisting = cachePolicy, @@ -146,7 +148,7 @@ public actor EditorAssetLibrary { /// private func fetchAsset(url: URL, into bundle: EditorAssetBundle) async throws -> URL { let tempUrl = try await logExecutionTime("Downloading \(url.lastPathComponent)") { - try await httpClient.download(url: url).0 + try await httpClient.download(URLRequest(method: .GET, url: url)).0 } let destinationPath = bundle.bundleRoot.appending(path: url.path(percentEncoded: false)) diff --git a/ios/Sources/GutenbergKit/Sources/Stores/EditorURLCache.swift b/ios/Sources/GutenbergKit/Sources/Stores/EditorURLCache.swift index 694ed6adc..286094f5d 100644 --- a/ios/Sources/GutenbergKit/Sources/Stores/EditorURLCache.swift +++ b/ios/Sources/GutenbergKit/Sources/Stores/EditorURLCache.swift @@ -55,7 +55,7 @@ public struct EditorURLCache: Sendable { storagePolicy: .allowed ) - self.cache.storeCachedResponse(response, for: URLRequest(url: url, method: httpMethod)) + self.cache.storeCachedResponse(response, for: URLRequest(method: httpMethod, url: url)) Thread.sleep(forTimeInterval: 0.05) // Hack to make `URLCache` work } @@ -100,7 +100,7 @@ public struct EditorURLCache: Sendable { storagePolicy: .allowed ) - self.cache.storeCachedResponse(response, for: URLRequest(url: url, method: httpMethod)) + self.cache.storeCachedResponse(response, for: URLRequest(method: httpMethod, url: url)) Thread.sleep(forTimeInterval: 0.05) // Hack to make `URLCache` work } @@ -137,7 +137,7 @@ public struct EditorURLCache: Sendable { ) throws -> EditorURLResponse? { performanceMonitor.measure { () -> EditorURLResponse? in guard - let response = self.cache.cachedResponse(for: URLRequest(url: url, method: httpMethod)), + let response = self.cache.cachedResponse(for: URLRequest(method: httpMethod, url: url)), let storageDate = response.userInfo?["storageDate"] as? Date, self.cachePolicy.allowsResponseWith(date: storageDate, currentDate: currentDate) else { From c8d4f112929ddd3428b92fb9ee1aff567dd78d87 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Tue, 9 Dec 2025 15:31:18 -0700 Subject: [PATCH 09/25] Remove EditorKeyValueCache --- .../Sources/Stores/EditorKeyValueCache.swift | 179 ------------------ 1 file changed, 179 deletions(-) delete mode 100644 ios/Sources/GutenbergKit/Sources/Stores/EditorKeyValueCache.swift diff --git a/ios/Sources/GutenbergKit/Sources/Stores/EditorKeyValueCache.swift b/ios/Sources/GutenbergKit/Sources/Stores/EditorKeyValueCache.swift deleted file mode 100644 index 77087cc9d..000000000 --- a/ios/Sources/GutenbergKit/Sources/Stores/EditorKeyValueCache.swift +++ /dev/null @@ -1,179 +0,0 @@ -import Foundation - -/// A protocol defining a simple key-value storage interface for caching editor data. -/// -/// Implementations of this protocol provide persistent or in-memory storage for arbitrary data -/// indexed by string keys. This is used internally by the editor to cache API responses and other data. -public protocol EditorKeyValueCache: Sendable { - /// Returns all keys currently stored in the cache. - /// - /// - Returns: A set of all stored keys. - /// - Throws: An error if the keys cannot be retrieved. - func allKeys() throws -> Set - - /// Checks whether data exists for the given key. - /// - /// - Parameter key: The key to check. - /// - Returns: `true` if data exists for this key, `false` otherwise. - /// - Throws: An error if the check cannot be performed. - func hasData(for key: String) throws -> Bool - - /// Stores data for the given key. - /// - /// If data already exists for this key, it will be overwritten. - /// - /// - Parameters: - /// - data: The data to store. - /// - key: The key to associate with the data. - /// - Throws: An error if the data cannot be stored. - func store(data: Data, for key: String) throws - - /// Stores the contents of a file URL for the given key. - /// - /// If data already exists for this key, it will be overwritten. - /// - /// - Parameters: - /// - url: The file URL whose contents should be stored. - /// - key: The key to associate with the data. - /// - Throws: An error if the file cannot be read or the data cannot be stored. - func store(contentsOf url: URL, for key: String) throws - - /// Removes the data associated with the given key. - /// - /// If no data exists for this key, this method does not throw. - /// - /// - Parameter key: The key whose data should be removed. - /// - Throws: An error if the data cannot be removed. - func remove(key: String) throws - - /// Retrieves the data associated with the given key. - /// - /// - Parameter key: The key to look up. - /// - Returns: The stored data, or `nil` if no data exists for this key. - /// - Throws: An error if the data cannot be retrieved. - func data(for key: String) throws -> Data? - - /// Removes all data from the cache. - /// - /// - Throws: An error if the cache cannot be cleared. - func clear() throws -} - -/// An in-memory implementation of `EditorKeyValueCache`. -/// -/// This cache stores all data in memory and is thread-safe. Data is lost when the -/// process terminates. Useful for testing or temporary caching scenarios. -final class InMemoryKeyValueCache: EditorKeyValueCache, @unchecked Sendable { - - private var cache = [String: Data]() - private let lock = NSLock() - - public func allKeys() throws -> Set { - self.lock.withLock { Set(self.cache.keys) } - } - - public func hasData(for key: String) throws -> Bool { - self.lock.withLock { self.cache[key] != nil } - } - - public func store(data: Data, for key: String) throws { - self.lock.withLock { self.cache[key] = data } - } - - public func store(contentsOf url: URL, for key: String) throws { - try self.lock.withLock { self.cache[key] = try Data(contentsOf: url) } - } - - public func remove(key: String) throws { - self.lock.withLock { _ = self.cache.removeValue(forKey: key) } - } - - public func data(for key: String) throws -> Data? { - self.lock.withLock { self.cache[key] } - } - - public func clear() throws { - self.lock.withLock { self.cache = [:] } - } -} - -/// A disk-based implementation of `EditorKeyValueCache`. -/// -/// This cache persists data to the file system, with each key stored as a separate file -/// in the specified root directory. Data survives process termination and device restarts. -public struct DiskKeyValueCache: EditorKeyValueCache { - - private let rootDirectory: URL - - /// Creates a new disk-based cache. - /// - /// - Parameter rootDirectory: The directory where cache files will be stored. - /// The directory will be created if it doesn't exist. - public init(rootDirectory: URL) { - self.rootDirectory = rootDirectory - } - - public func allKeys() throws -> Set { - guard FileManager.default.directoryExists(at: rootDirectory) else { - return [] - } - - let allKeys = try FileManager.default.contentsOfDirectory(atPath: rootDirectory.path()).sorted() - - return Set(allKeys) - } - - public func hasData(for key: String) throws -> Bool { - FileManager.default.fileExists(at: filePath(for: key)) - } - - public func store(data: Data, for key: String) throws { - try FileManager.default.createDirectory(at: rootDirectory, withIntermediateDirectories: true) - try data.write(to: filePath(for: key), options: .atomic) - } - - public func store(contentsOf url: URL, for key: String) throws { - try FileManager.default.createDirectory(at: rootDirectory, withIntermediateDirectories: true) - let newPath = filePath(for: key) - - if FileManager.default.fileExists(at: newPath) { - try FileManager.default.removeItem(at: newPath) - } - try FileManager.default.copyItem(at: url, to: newPath) - } - - public func remove(key: String) throws { - let filePath = filePath(for: key) - guard FileManager.default.fileExists(at: filePath) else { - return - } - try FileManager.default.removeItem(at: filePath) - } - - public func data(for key: String) throws -> Data? { - let filePath = filePath(for: key) - guard FileManager.default.fileExists(at: filePath) else { - return nil - } - - return try Data(contentsOf: filePath) - } - - public func clear() throws { - guard FileManager.default.directoryExists(at: rootDirectory) else { - return - } - - for filename in try FileManager.default.contentsOfDirectory(atPath: rootDirectory.path()) { - try FileManager.default.removeItem(at: rootDirectory.appending(path: filename)) - } - } - - /// Returns the file path for a given cache key. - /// - /// - Parameter key: The cache key. - /// - Returns: The URL where the key's data is stored. - private func filePath(for key: String) -> URL { - rootDirectory.appending(path: key) - } -} From 1d5340704177442dfa30adac9004965d668416c9 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Tue, 9 Dec 2025 16:16:36 -0700 Subject: [PATCH 10/25] Apply suggestions from code review Co-authored-by: David Calhoun --- ios/Demo-iOS/Sources/Views/SitePreparationView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ios/Demo-iOS/Sources/Views/SitePreparationView.swift b/ios/Demo-iOS/Sources/Views/SitePreparationView.swift index a63291447..8d5950f02 100644 --- a/ios/Demo-iOS/Sources/Views/SitePreparationView.swift +++ b/ios/Demo-iOS/Sources/Views/SitePreparationView.swift @@ -85,7 +85,7 @@ struct SitePreparationView: View { } }.disabled(self.viewModel.disableButtons) - Button("Prepare Editor ignoring cache") { + Button("Prepare Editor Ignoring Cache") { withAnimation { self.viewModel.prepareEditorFromScratch() } From 5cdbc5b090dc073a576f0adbde74890cec91f194 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Tue, 9 Dec 2025 16:24:24 -0700 Subject: [PATCH 11/25] Remove `loadEditorTask` synthesizing accessor --- .../Sources/EditorViewController.swift | 31 ++++++++++--------- 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift index 1327e279c..19288d143 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift @@ -242,7 +242,9 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro // If we don't have dependencies yet, we need to load them if case .start = viewState { - self.viewState = .loading(self.loadEditorTask) + self.viewState = .loading(Task(priority: .userInitiated) { + await self.prepareEditor() + }) } // If we already have the dependencies, we can just load the editor right away @@ -265,23 +267,24 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro removeNavigationOverlay() } - @MainActor - private var loadEditorTask: Task { - Task(priority: .userInitiated) { - do { - let dependencies = try await self.editorService.prepare { @MainActor progress in - self.progressView.setProgress(progress, animated: true) - } - try self.loadEditor(dependencies: dependencies) - - self.viewState = .loaded(dependencies) - - } catch { - self.viewState = .error(error) + /// Fetch (or read from cache) everything the editor needs to launch + /// + private func prepareEditor() async { + do { + let dependencies = try await self.editorService.prepare { @MainActor progress in + self.progressView.setProgress(progress, animated: true) } + try self.loadEditor(dependencies: dependencies) + + self.viewState = .loaded(dependencies) + + } catch { + self.viewState = .error(error) } } + /// Load the editor JS into the webview + /// @MainActor private func loadEditor(dependencies: EditorDependencies) throws { self.bundleProvider.set(bundle: dependencies.assetBundle) From f2dcb1c0301b833be12f629835d1c76bcb8dcb4b Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Tue, 9 Dec 2025 16:30:42 -0700 Subject: [PATCH 12/25] Update ios/Sources/GutenbergKit/Sources/EditorLogging.swift Co-authored-by: David Calhoun --- ios/Sources/GutenbergKit/Sources/EditorLogging.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ios/Sources/GutenbergKit/Sources/EditorLogging.swift b/ios/Sources/GutenbergKit/Sources/EditorLogging.swift index f83e40dbb..bf0903304 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorLogging.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorLogging.swift @@ -9,7 +9,7 @@ public protocol EditorLogging: Sendable { extension Logger { - public static let performance = OSSignposter(subsystem: "GutenbergKit", category: "timing") + public static let performance = OSSignposter(subsystem: "GutenbergKit", category: "performance") /// Logs timings for performance optimization public static let timing = Logger(subsystem: "GutenbergKit", category: "timing") From 48e3640d35895c70bb782f3e153c8be86fe47f4b Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Tue, 9 Dec 2025 16:35:23 -0700 Subject: [PATCH 13/25] Update ios/Demo-iOS/Sources/Views/SitePreparationView.swift Co-authored-by: David Calhoun --- ios/Demo-iOS/Sources/Views/SitePreparationView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ios/Demo-iOS/Sources/Views/SitePreparationView.swift b/ios/Demo-iOS/Sources/Views/SitePreparationView.swift index 8d5950f02..fc5d924d0 100644 --- a/ios/Demo-iOS/Sources/Views/SitePreparationView.swift +++ b/ios/Demo-iOS/Sources/Views/SitePreparationView.swift @@ -111,7 +111,7 @@ struct SitePreparationView: View { @Observable class SitePreparationViewModel { - var enableNativeInserter: Bool = false + var enableNativeInserter: Bool = true var enableNetworkLogging: Bool = false From ddfe7c9f60de34e0b483305599e6349fe044b6c9 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Wed, 10 Dec 2025 10:01:00 -0700 Subject: [PATCH 14/25] Pass cache policy into EditorAssetLibrary --- .../Sources/Services/EditorService.swift | 1 + .../Sources/Stores/EditorAssetLibrary.swift | 37 +++++++++---------- 2 files changed, 19 insertions(+), 19 deletions(-) diff --git a/ios/Sources/GutenbergKit/Sources/Services/EditorService.swift b/ios/Sources/GutenbergKit/Sources/Services/EditorService.swift index 8e6d4a44b..ce15862cd 100644 --- a/ios/Sources/GutenbergKit/Sources/Services/EditorService.swift +++ b/ios/Sources/GutenbergKit/Sources/Services/EditorService.swift @@ -83,6 +83,7 @@ public actor EditorService { self.assetLibrary = EditorAssetLibrary( configuration: configuration, httpClient: httpClient, + cachePolicy: cachePolicy, storageRoot: storageRoot ?? Paths.storageRoot(for: configuration) ) } diff --git a/ios/Sources/GutenbergKit/Sources/Stores/EditorAssetLibrary.swift b/ios/Sources/GutenbergKit/Sources/Stores/EditorAssetLibrary.swift index ffaaf7595..1926612d1 100644 --- a/ios/Sources/GutenbergKit/Sources/Stores/EditorAssetLibrary.swift +++ b/ios/Sources/GutenbergKit/Sources/Stores/EditorAssetLibrary.swift @@ -3,16 +3,12 @@ import Foundation /// The Editor Asset Library is a site-specific repository of remote assets that can be downloaded to the local device to support plugins and theme styles. /// public actor EditorAssetLibrary { - - public enum CachePolicy: Sendable { - case useExisting - case forceRefresh - } - + private let configuration: EditorConfiguration private let httpClient: EditorHTTPClientProtocol private let storageRoot: URL - + private let cachePolicy: EditorCachePolicy + /// Creates a new `EditorAssetLibrary` instance. /// /// - Parameters: @@ -22,33 +18,36 @@ public actor EditorAssetLibrary { public init( configuration: EditorConfiguration, httpClient: EditorHTTPClientProtocol, + cachePolicy: EditorCachePolicy = .always, storageRoot: URL ) { self.configuration = configuration self.httpClient = httpClient self.storageRoot = storageRoot + self.cachePolicy = cachePolicy } - + // MARK: - Manifest Handling - + /// Retrieve the manifest for a given site configuration. /// /// Applications should periodically check for a new editor manifest. This can be very expensive, so this method defaults to returning an existing one on-disk. /// - package func fetchManifest(cachePolicy: CachePolicy = .useExisting) async throws - -> LocalEditorAssetManifest { + package func fetchManifest() async throws -> LocalEditorAssetManifest { guard configuration.shouldUsePlugins else { return .empty } let data = try await httpClient.perform( URLRequest(method: .GET, url: self.editorAssetsUrl(for: self.configuration)) ).0 let remoteManifest = try RemoteEditorAssetManifest(data: data) - - if case .useExisting = cachePolicy, - let existingManifest = try self.existingBundle(forManifestChecksum: remoteManifest.checksum) { - return existingManifest.manifest + + guard + let existingManifest = try self.existingBundle(forManifestChecksum: remoteManifest.checksum), + self.cachePolicy.allowsResponseWith(date: existingManifest.downloadDate) + else { + return try LocalEditorAssetManifest(remoteManifest: remoteManifest) } - - return try LocalEditorAssetManifest(remoteManifest: remoteManifest) + + return existingManifest.manifest } // MARK: - Bundle Handling @@ -72,10 +71,10 @@ public actor EditorAssetLibrary { /// - Returns: The downloaded `EditorAssetBundle` containing all cached assets. /// - Throws: An error if the manifest cannot be fetched or assets fail to download. public func downloadAssetBundle( - cachePolicy: CachePolicy = .useExisting, + cachePolicy: EditorCachePolicy = .always, progress: EditorProgressCallback? = nil ) async throws -> EditorAssetBundle { - let manifest = try await self.fetchManifest(cachePolicy: cachePolicy) + let manifest = try await self.fetchManifest() return try await self.buildBundle(for: manifest, progress: progress) } From 46b6838e40c90004395297696e995aeee006a39f Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Fri, 12 Dec 2025 14:32:57 -0700 Subject: [PATCH 15/25] Move editor start button --- .../Sources/Views/SitePreparationView.swift | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/ios/Demo-iOS/Sources/Views/SitePreparationView.swift b/ios/Demo-iOS/Sources/Views/SitePreparationView.swift index fc5d924d0..7186d99fc 100644 --- a/ios/Demo-iOS/Sources/Views/SitePreparationView.swift +++ b/ios/Demo-iOS/Sources/Views/SitePreparationView.swift @@ -22,6 +22,13 @@ struct SitePreparationView: View { ProgressView("Loading Site Configuration") } } + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Start") { + self.viewModel.buildAndLoadConfiguration(navigation: navigation) + }.buttonStyle(.borderedProminent) + } + } .navigationTitle("Editor Configuration") .onAppear { self.viewModel.startLoading() @@ -64,16 +71,6 @@ struct SitePreparationView: View { Text(error.localizedDescription) } } - - Section {} footer: { - Button(action: { - self.viewModel.buildAndLoadConfiguration(navigation: navigation) - }) { - Spacer() - Text("Start Editor") - Spacer() - }.buttonStyle(.borderedProminent) - } } } From 50800e092e781f34044c2a59fcd2f9498f4262cc Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Fri, 12 Dec 2025 14:47:10 -0700 Subject: [PATCH 16/25] Use modal editor --- ios/Demo-iOS/Sources/GutenbergApp.swift | 20 ++++++++++++++++--- ios/Demo-iOS/Sources/Views/EditorView.swift | 9 +++++++++ .../Sources/Views/SitePreparationView.swift | 7 ++++++- 3 files changed, 32 insertions(+), 4 deletions(-) diff --git a/ios/Demo-iOS/Sources/GutenbergApp.swift b/ios/Demo-iOS/Sources/GutenbergApp.swift index d4e06aaee..82f5e02ef 100644 --- a/ios/Demo-iOS/Sources/GutenbergApp.swift +++ b/ios/Demo-iOS/Sources/GutenbergApp.swift @@ -5,9 +5,18 @@ import GutenbergKit final class Navigation: ObservableObject { @Published var path = NavigationPath() + @Published var hasEditor: Bool = false + + @Published var editor: RunnableEditor? + func push(_ path: any Hashable) { self.path.append(path) } + + func present(_ editor: RunnableEditor) { + self.hasEditor = true + self.editor = editor + } } extension EnvironmentValues { @@ -39,12 +48,17 @@ struct GutenbergApp: App { WindowGroup { NavigationStack(path: $navigation.path) { AppRootView() - .navigationDestination(for: RunnableEditor.self) { editor in - EditorView(configuration: editor.configuration, dependencies: editor.dependencies) - } .navigationDestination(for: ConfigurationItem.self) { item in SitePreparationView(site: item) } + .fullScreenCover(isPresented: $navigation.hasEditor) { + let editor = navigation.editor! + + NavigationStack { + EditorView(configuration: editor.configuration, dependencies: editor.dependencies) + } + } + } } .environment(\.navigation, navigation) diff --git a/ios/Demo-iOS/Sources/Views/EditorView.swift b/ios/Demo-iOS/Sources/Views/EditorView.swift index 38313a5cf..9a5d7a613 100644 --- a/ios/Demo-iOS/Sources/Views/EditorView.swift +++ b/ios/Demo-iOS/Sources/Views/EditorView.swift @@ -7,6 +7,8 @@ struct EditorView: View { @ObservedObject private var viewModel = EditorViewModel() + @Environment(\.dismiss) var dismiss + init(configuration: EditorConfiguration, dependencies: EditorDependencies? = nil) { self.configuration = configuration self.dependencies = dependencies @@ -23,6 +25,13 @@ struct EditorView: View { @ToolbarContentBuilder private var toolbar: some ToolbarContent { + ToolbarItem(placement: .topBarLeading) { + Button { + self.dismiss() + } label: { + Image(systemName: "xmark") + } + } ToolbarItemGroup(placement: .topBarTrailing) { Group { Button { diff --git a/ios/Demo-iOS/Sources/Views/SitePreparationView.swift b/ios/Demo-iOS/Sources/Views/SitePreparationView.swift index 7186d99fc..7c4fd590e 100644 --- a/ios/Demo-iOS/Sources/Views/SitePreparationView.swift +++ b/ios/Demo-iOS/Sources/Views/SitePreparationView.swift @@ -278,7 +278,12 @@ class SitePreparationViewModel { } func buildAndLoadConfiguration(navigation: Navigation) { - navigation.push(RunnableEditor(configuration: buildConfiguration(), dependencies: self.editorDependencies)) + let editor = RunnableEditor( + configuration: buildConfiguration(), + dependencies: self.editorDependencies + ) + + navigation.present(editor) } } From 354d8f97dc4f5f0182992f900594856c22f905a8 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Fri, 12 Dec 2025 18:12:59 -0700 Subject: [PATCH 17/25] Remove editor loading state machine --- docs/preloading.md | 111 +----- .../Sources/EditorViewController.swift | 340 ++++++++++-------- 2 files changed, 198 insertions(+), 253 deletions(-) diff --git a/docs/preloading.md b/docs/preloading.md index 73324c218..a8734046e 100644 --- a/docs/preloading.md +++ b/docs/preloading.md @@ -259,115 +259,53 @@ that contain everything the editor needs, the progress bar will never be display ## EditorDependencies -`EditorDependencies` is the output of the preloading system - a container holding all pre-fetched resources needed to initialize the editor instantly. - -### What It Contains +`EditorDependencies` contains all pre-fetched resources needed to initialize the editor instantly. | Property | Type | Description | |----------|------|-------------| -| `editorSettings` | `EditorSettings` | Theme styles, colors, typography, and block settings | -| `assetBundle` | `EditorAssetBundle` | Cached JavaScript and CSS files for plugins/themes | -| `preloadList` | `EditorPreloadList?` | Pre-fetched API responses (nil if preloading disabled) | - -### How to Obtain EditorDependencies +| `editorSettings` | `EditorSettings` | Theme styles, colors, typography, block settings | +| `assetBundle` | `EditorAssetBundle` | Cached JavaScript/CSS for plugins/themes | +| `preloadList` | `EditorPreloadList?` | Pre-fetched API responses | -Use `EditorService.prepare()` to fetch dependencies asynchronously: +### Obtaining Dependencies -**Swift** ```swift -let configuration = EditorConfiguration(/* ... */) let service = EditorService(configuration: configuration) - -// Fetch dependencies with progress reporting let dependencies = try await service.prepare { progress in - // Update your UI with progress.fractionCompleted (0.0 to 1.0) loadingView.progress = progress.fractionCompleted } ``` -**Kotlin** -```kotlin -// TBD -``` +### EditorViewController Loading Flows -### Using EditorDependencies with EditorViewController +`EditorViewController` supports two loading flows based on whether dependencies are provided: -`EditorViewController` can be initialized in two ways: +#### Flow 1: Dependencies Provided (Recommended) -#### 1. With Pre-fetched Dependencies (Recommended) - -Pass `EditorDependencies` to the initializer for instant editor loading: - -**Swift** ```swift -// Fetch dependencies ahead of time (e.g., when user taps "Edit") -let dependencies = try await service.prepare { progress in - // Show loading UI -} - -// Later, create the editor with dependencies ready let editor = EditorViewController( configuration: configuration, - dependencies: dependencies // Editor loads instantly + dependencies: dependencies // Loads immediately ) ``` -When dependencies are provided: -1. The editor skips the loading/progress UI entirely -2. The WebView loads immediately with all configuration injected -3. The user sees the editor content with minimal delay - -#### 2. Without Dependencies (Lazy Loading) +The editor skips the progress UI and loads the WebView immediately. -If no dependencies are provided, the editor fetches them on-demand: +#### Flow 2: No Dependencies (Fallback) -**Swift** ```swift let editor = EditorViewController( configuration: configuration - // No dependencies - will fetch automatically + // No dependencies - fetches automatically ) ``` -When dependencies are NOT provided: -1. The editor displays a progress bar while fetching -2. `EditorService.prepare()` runs internally -3. Once complete, the editor loads and the progress bar hides - -### EditorViewController State Machine - -The editor transitions through these states based on dependency availability: - -``` -+-------+ No deps +---------+ Fetch complete +--------+ -| start | ---------------> | loading | ---------------------> | loaded | -+-------+ +---------+ +--------+ - | | - | Has deps | - +------------------------------------------------------------->+ - | - JS initialized - | - v - +-------+ - | ready | - +-------+ -``` - -| State | Description | -|-------|-------------| -| `start` | Initial state before `viewDidLoad` | -| `loading` | Fetching dependencies, showing progress bar | -| `loaded` | Dependencies ready, WebView loading HTML/JS | -| `ready` | Editor fully initialized, safe to call JS APIs | - -### Best Practices +The editor displays a progress bar while fetching, then loads once complete. -#### Prepare Dependencies Early +### Best Practice: Prepare Early -For the best user experience, fetch dependencies before the user needs the editor: +Fetch dependencies before the user needs the editor: -**Swift** ```swift class PostListViewController: UIViewController { private var editorDependencies: EditorDependencies? @@ -375,31 +313,16 @@ class PostListViewController: UIViewController { override func viewDidLoad() { super.viewDidLoad() - - // Start fetching in the background Task { self.editorDependencies = try? await editorService.prepare { _ in } } } func editPost(_ post: Post) { - let config = EditorConfiguration(post: post) let editor = EditorViewController( - configuration: config, - dependencies: editorDependencies // Already available! + configuration: EditorConfiguration(post: post), + dependencies: editorDependencies ) navigationController?.pushViewController(editor, animated: true) } } -``` - -#### Cache Dependencies Per-Site - -Dependencies are site-specific (different themes, plugins, settings). Create separate `EditorService` instances per site and cache their dependencies independently. - -#### Handle Missing Dependencies Gracefully - -Even without pre-fetched dependencies, the editor will work - it just shows a loading state first. This is useful for: -- First launch (no cache yet) -- Cache expired or cleared -- Error recovery scenarios diff --git a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift index 19288d143..3d169f9c5 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift @@ -5,176 +5,150 @@ import OSLog #if canImport(UIKit) import UIKit +// MARK: - EditorViewController Loading Process +// +// The EditorViewController manages the Gutenberg block editor on iOS. +// It supports two distinct loading flows based on whether dependencies are pre-fetched or not. +// +// ## Loading Flow +// +// ``` +// ┌───────────────────────────────────────────────────────────────────────────────┐ +// │ INITIALIZATION │ +// └───────────────────────────────────────────────────────────────────────────────┘ +// ▼ +// ┌───────────────────────────────────────────────────────────────────────────────┐ +// │ viewDidLoad() │ +// │ • Branch based on initialization parameters │ +// └───────────────────────────────────────────────────────────────────────────────┘ +// ┌───────────────────────┼───────────────────────┐ +// ▼ ▼ ▼ +// ┌────────────────────┐ ┌────────────────────┐ ┌───────────────────────────────┐ +// │ WARMUP MODE │ │ DEPENDENCIES │ │ NO DEPENDENCIES │ +// │ (isWarmupMode) │ │ PROVIDED │ │ (Async Flow) │ +// │ │ │ (Fast Path) │ │ │ +// │ Load HTML without │ │ │ │ Spawn Task to fetch │ +// │ any dependencies │ │ loadEditor() │ │ dependencies │ +// │ for prewarming │ │ immediately │ │ │ +// └────────────────────┘ └────────────────────┘ └───────────────────────────────┘ +// │ ▼ +// │ ┌───────────────────────────────┐ +// │ │ prepareEditor() │ +// │ │ • Load editor dependencies │ +// │ └───────────────────────────────┘ +// │ ▼ +// │ ┌───────────────────────────────┐ +// └─────────►│ loadEditor() │ +// │ • Load editor JS into webview│ +// └───────────────────────────────┘ +// ▼ +// ┌───────────────────────────────┐ +// │ WebView Navigation │ +// │ • JS Compiled │ +// │ • Gutenberg initialized │ +// │ • `onEditorLoaded` sent │ +// └───────────────────────────────┘ +// ▼ +// ┌───────────────────────────────┐ +// │ didLoadEditor() │ +// │ • isReady = true │ +// │ • JS methods now safe to use │ +// └───────────────────────────────┘ +// ``` +// +// ## Flow 1: Dependencies Provided (Fast Path) +// +// When `EditorDependencies` are passed to `init()`, the editor skips the async +// dependency fetching phase entirely. This is useful when: +// - Dependencies were pre-fetched by the host app +// - The app wants to control caching/fetching separately +// +// ## Flow 2: No Dependencies (Async Flow) +// +// When no dependencies are provided, the controller fetches them asynchronously. +// This is a fallback behaviour – the host app should provide the dependencies if it can, +// because it'll be a much better user experience. +// @MainActor public final class EditorViewController: UIViewController, GutenbergEditorControllerDelegate, UIAdaptivePresentationControllerDelegate, UIPopoverPresentationControllerDelegate, UISheetPresentationControllerDelegate { - /// Represents the lifecycle state of the editor view controller. - /// - /// The editor progresses through these states as it initializes: - /// - /// ``` - /// +-------+ No deps +---------+ Fetch complete +--------+ - /// | start | -----------------> | loading | -------------------> | loaded | - /// +-------+ +---------+ +--------+ - /// | | - /// | Has deps | - /// +--------------------------------------------------------------+ - /// | - /// JS initialized - /// | - /// v - /// +-------+ - /// | ready | - /// +-------+ - /// ``` - /// - /// Any state can transition to ``error(_:)`` if a fatal error occurs. - /// - /// ## State Descriptions - /// - /// - ``start``: Initial state before `viewDidLoad`. The editor has not begun initialization. - /// - ``loading(_:)``: Fetching dependencies from the network. A progress bar is displayed. - /// - ``loaded(_:)``: Dependencies are available and the WebView is loading HTML/JS. An activity indicator is shown. - /// - ``ready(_:)``: The editor is fully initialized. JavaScript APIs (e.g., `editor.getContent()`) are now safe to call. - /// - ``error(_:)``: A fatal error occurred. The error view is displayed and the delegate is notified. - /// - /// ## UI Behavior - /// - /// Each state transition triggers corresponding UI updates: - /// - `start` -> `loading`: Shows progress bar - /// - `loading` -> `loaded`: Hides progress bar, shows activity indicator - /// - `loaded` -> `ready`: Hides activity indicator, reveals editor - /// - Any -> `error`: Shows error view - /// - enum ViewState: Sendable, Equatable { - - /// Initial state before the view has loaded. - /// - /// This is the default state when the view controller is created. The editor - /// transitions out of this state in `viewDidLoad`. - case start - - /// Fetching editor dependencies from the network. - /// - /// The associated task represents the async work being performed. A progress - /// bar is displayed to the user during this state. - /// - /// - Parameter task: The task fetching dependencies via `EditorService.prepare()`. - case loading(Task) - - /// Dependencies are loaded and the WebView is initializing. - /// - /// The editor HTML and JavaScript are being loaded into the WebView. An - /// indeterminate activity indicator is shown during this brief phase. - /// - /// - Parameter dependencies: The pre-fetched editor dependencies. - case loaded(EditorDependencies) - - /// The editor is fully initialized and ready for use. - /// - /// JavaScript APIs like `editor.getContent()`, `editor.setContent()`, `editor.undo()`, - /// etc. are now safe to call. The editor UI is visible and interactive. - /// - /// - Parameter dependencies: The editor dependencies used for initialization. - case ready(EditorDependencies) - - /// A fatal error occurred during initialization. - /// - /// The error view is displayed and the delegate's `editor(_:didEncounterCriticalError:)` - /// method is called. The editor cannot recover from this state. - /// - /// - Parameter error: The error that caused initialization to fail. - case error(Error) - - static func == (lhs: EditorViewController.ViewState, rhs: EditorViewController.ViewState) -> Bool { - switch (lhs, rhs) { - case (.start, .start): return true - case (.loading, .loading): return true - case (.loaded, .loaded): return true - case (.ready, .ready): return true - case (.error, .error): return true - default: return false - } - } - } + public let webView: WKWebView + public var configuration: EditorConfiguration - @MainActor - private var viewState: ViewState = .start { - willSet { - if newValue == self.viewState { - preconditionFailure("Invalid transition from `\(self.viewState)` to `\(newValue)") - } - } + /// The current editor state (empty, has undo/redo history). + public private(set) var state = EditorState() + + /// Delegate for receiving editor lifecycle and content change callbacks. + public weak var delegate: EditorViewControllerDelegate? + + /// The fetched or provided editor dependencies (settings, assets, preload data). + private var dependencies: EditorDependencies? + private var dependencyTaskHandle: Task? + + /// Error encountered while loading dependencies. + private var error: Error? { didSet { - switch viewState { - case .start: - preconditionFailure("viewState should never transition back to `start`") - case .loading: - self.displayProgressView() - case .loaded: - self.hideProgressView() - self.displayActivityView() - case .ready: - self.hideActivityView() - case .error(let error): + if let error { self.displayError(error) - self.delegate?.editor(self, didEncounterCriticalError: error) } } } - public let webView: WKWebView + /// Indicates whether the editor JavaScript has initialized and is ready for use. + /// Set to `true` when the `onEditorLoaded` message is received from JavaScript. + /// - Important: JS `editor` APIs are only safe to call after this becomes `true`. + private var isReady: Bool = false - public var configuration: EditorConfiguration - private let editorService: EditorService + /// When `true`, loads editor HTML without dependencies for WebKit prewarming. + /// Used by `EditorViewController.warmup()` to reduce first-render latency. + private let isWarmupMode: Bool + // MARK: - Private Properties (Services) + private let editorService: EditorService private let mediaPicker: MediaPickerController? private let controller: GutenbergEditorController - private let timestampInit = CFAbsoluteTimeGetCurrent() private let bundleProvider = EditorAssetBundleProvider() - /// Displays a progress bar indicating loading status + // MARK: - Private Properties (UI) + + /// Progress bar shown during async dependency fetching ("No Dependencies" flow). private let progressView = UIEditorProgressView(loadingText: Strings.loadingEditor) - /// Displays an indeterminate indicator while WebKit loads the JS code + /// Spinning indicator shown while WebKit loads and parses the editor JavaScript. private let waitingView = UIActivityIndicatorView(style: .medium) + /// View controller that displays error information when loading fails. private let errorViewController = EditorErrorViewController() - public private(set) var state = EditorState() - - public weak var delegate: EditorViewControllerDelegate? - - /// Stores the contextId from the most recent openMediaLibrary call - /// to pass back to JavaScript when media is selected + /// Stores the contextId from the most recent `openMediaLibrary` JS call. + /// Passed back to JavaScript when media selection completes. private var currentMediaContextId: String? - /// Warmup mode preloads resources into memory to make the UI transition seamless when displaying the editor for the first time - private let isWarmupMode: Bool + // MARK: - Private Properties (Timing) - /// Overlay view shown over the navigation bar when modal dialogs are open + /// Timestamp captured at initialization for measuring first-render performance. + private let timestampInit = CFAbsoluteTimeGetCurrent() + + /// Semi-transparent overlay shown over the navigation bar when JS modal dialogs are open. + /// Prevents user interaction with navigation items while a modal is displayed. private lazy var navigationOverlayView: UIView = { let view = UIView() view.backgroundColor = UIColor.black.withAlphaComponent(0.3) view.isUserInteractionEnabled = true - view.isHidden = true + view.isHidden = true view.translatesAutoresizingMaskIntoConstraints = false return view }() - /// HTML Preview Manager instance for rendering pattern previews - /// - /// It is a fatal error to attempt to access this before the editor state is `ready`. - /// + /// Renders HTML previews for block patterns in the block inserter. private lazy var htmlPreviewManager: HTMLPreviewManager = { - guard case .ready(let dependencies) = viewState else { - preconditionFailure("Editor is not in a `.ready` state, cannot create HTMLPreviewManager") + guard let dependencies else { + preconditionFailure("Editor does not have dependencies, cannot create HTMLPreviewManager") } return HTMLPreviewManager(themeStyles: dependencies.editorSettings.themeStyles) }() - /// Initalizes the editor with the initial content (Gutenberg). public init( configuration: EditorConfiguration, dependencies: EditorDependencies? = nil, @@ -182,6 +156,7 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro isWarmupMode: Bool = false ) { self.configuration = configuration + self.dependencies = dependencies self.editorService = EditorService(configuration: configuration) self.mediaPicker = mediaPicker self.controller = GutenbergEditorController(configuration: configuration) @@ -206,16 +181,13 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro self.isWarmupMode = isWarmupMode super.init(nibName: nil, bundle: nil) - - if let dependencies { - self.viewState = .loaded(dependencies) - } } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + // MARK: - View Lifecycle (Loading Entry Point) public override func viewDidLoad() { super.viewDidLoad() @@ -234,25 +206,25 @@ 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.alpha = 0 + // Warmup mode - load HTML without dependencies for WebKit prewarming if isWarmupMode { self.loadEditorWithoutDependencies() } - // If we don't have dependencies yet, we need to load them - if case .start = viewState { - self.viewState = .loading(Task(priority: .userInitiated) { - await self.prepareEditor() - }) - } - - // If we already have the dependencies, we can just load the editor right away - if case .loaded(let editorDependencies) = viewState { + if let dependencies { + // FAST PATH: Dependencies were provided at init() - load immediately do { - try self.loadEditor(dependencies: editorDependencies) + try self.loadEditor(dependencies: dependencies) } catch { - self.viewState = .error(error) + self.error = error + } + } else { + // ASYNC FLOW: No dependencies - fetch them asynchronously + self.dependencyTaskHandle = Task(priority: .userInitiated) { + await self.prepareEditor() } } } @@ -267,32 +239,58 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro removeNavigationOverlay() } - /// Fetch (or read from cache) everything the editor needs to launch + public override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + self.dependencyTaskHandle?.cancel() + } + + /// Fetches all required dependencies and then loads the editor. /// + /// This method is the entry point for the **Async Flow** (when no dependencies were provided at init). + @MainActor private func prepareEditor() async { + self.displayProgressView() + defer { self.hideProgressView() } + do { + // EditorService.prepare() fetches dependencies concurrently with progress reporting let dependencies = try await self.editorService.prepare { @MainActor progress in self.progressView.setProgress(progress, animated: true) } - try self.loadEditor(dependencies: dependencies) - self.viewState = .loaded(dependencies) + // Store dependencies for later use (e.g., HTMLPreviewManager) + self.dependencies = dependencies + // Continue to the shared loading path + try self.loadEditor(dependencies: dependencies) } catch { - self.viewState = .error(error) + // Display error view - this sets self.error which triggers displayError() + self.error = error } } - /// Load the editor JS into the webview + // MARK: - Shared Loading Path: Load Editor into WebView + + /// Loads the editor HTML into the WebView with the given dependencies. + /// + /// This is the **shared loading path** used by both flows after dependencies are available. + /// + /// After this method completes, WebKit will parse the HTML and execute JavaScript. + /// The editor will eventually emit an `onEditorLoaded` message, triggering `didLoadEditor()`. /// @MainActor private func loadEditor(dependencies: EditorDependencies) throws { + self.displayActivityView() + defer { self.hideActivityView() } + + // Set asset bundle for the URL scheme handler to serve cached plugin/theme assets self.bundleProvider.set(bundle: dependencies.assetBundle) - // Register the handler that provides the editor configuration + // Build and inject editor configuration as window.GBKit let editorConfig = try buildEditorConfiguration(dependencies: dependencies) webView.configuration.userContentController.addUserScript(editorConfig) + // Load editor HTML - supports dev server via environment variable if let editorURL = ProcessInfo.processInfo.environment["GUTENBERG_EDITOR_URL"].flatMap(URL.init) { webView.load(URLRequest(url: editorURL)) } else { @@ -301,13 +299,24 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro } } - /// Load the editor without any external dependencies – this is useful for prewarming the JS + /// Loads the editor HTML without any dependencies (warmup mode only). + /// + /// This method is used exclusively by the warmup mechanism to preload editor resources + /// into WebKit's memory cache. The editor won't be functional without dependencies, + /// but subsequent loads will be faster because WebKit has already parsed the HTML/JS. /// + /// - Note: Only called when `isWarmupMode` is true. private func loadEditorWithoutDependencies() { let indexURL = Bundle.module.url(forResource: "index", withExtension: "html", subdirectory: "Gutenberg")! webView.loadFileURL(indexURL, allowingReadAccessTo: Bundle.module.resourceURL!) } + /// Builds a `WKUserScript` that injects the editor configuration into the page. + /// + /// The configuration is injected as `window.GBKit` at document start, before any other + /// scripts run. This ensures the editor JavaScript has access to all configuration data + /// when it initializes. + /// private func buildEditorConfiguration(dependencies: EditorDependencies) throws -> WKUserScript { let gbkitGlobal = try GBKitGlobal(configuration: self.configuration, dependencies: dependencies) let stringValue = try gbkitGlobal.toString() @@ -341,7 +350,7 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro } private func _setContent(_ content: String) { - guard case .ready = viewState else { + guard self.isReady else { return } @@ -649,22 +658,35 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro } } - // Only after this point it's safe to use JS `editor` API. - private func didLoadEditor() { + // MARK: - Loading Complete: Editor Ready - // If the editor uses `location.reload`, we'll end up here more than once - guard case .loaded(let editorDependencies) = viewState else { + /// Called when the editor JavaScript emits the `onEditorLoaded` message. + /// + /// This method marks the **final step of the loading process** for both flows. + /// At this point: + /// - All dependencies have been fetched (or were provided) + /// - The HTML has been loaded and parsed + /// - The JavaScript has executed and the editor has mounted + /// + /// **Important**: Only after this method completes is it safe to call JS `editor` APIs + /// + private func didLoadEditor() { + // Guard against multiple onEditorLoaded events (e.g., from location.reload()) + guard !self.isReady else { return } - self.viewState = .ready(editorDependencies) + self.isReady = true + // Fade in the WebView - it was hidden (alpha = 0) since viewDidLoad() UIView.animate(withDuration: 0.2, delay: 0.1, options: [.allowUserInteraction]) { self.webView.alpha = 1 } + // Log performance timing for monitoring let duration = CFAbsoluteTimeGetCurrent() - timestampInit print("gutenbergkit-measure_editor-first-render:", duration) + delegate?.editorDidLoad(self) if configuration.content.isEmpty { From 935fd8a3c19c39b764fd97905e1e871b7d6c206a Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Fri, 12 Dec 2025 16:36:15 -0700 Subject: [PATCH 18/25] Drop `EditorErrorViewController` --- .../Sources/EditorViewController.swift | 16 +++-- .../Sources/Views/EditorErrorView.swift | 61 ------------------- 2 files changed, 11 insertions(+), 66 deletions(-) delete mode 100644 ios/Sources/GutenbergKit/Sources/Views/EditorErrorView.swift diff --git a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift index 3d169f9c5..86d044822 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift @@ -118,7 +118,7 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro private let waitingView = UIActivityIndicatorView(style: .medium) /// View controller that displays error information when loading fails. - private let errorViewController = EditorErrorViewController() + private var errorViewController: UIHostingController? /// Stores the contextId from the most recent `openMediaLibrary` JS call. /// Passed back to JavaScript when media selection completes. @@ -771,14 +771,20 @@ extension EditorViewController { @MainActor func displayError(_ error: Error) { - self.displayAndCenterView(errorViewController.view!) - self.errorViewController.didMove(toParent: self) - errorViewController.error = error + let view = ContentUnavailableView( + "Editor Error", + systemImage: "exclamationmark.circle", + description: Text(error.localizedDescription) + ) + + self.errorViewController = UIHostingController(rootView: AnyView(view)) + self.displayAndCenterView(errorViewController!.view) + self.errorViewController?.didMove(toParent: self) } @MainActor func hideError() { - self.errorViewController.view.removeFromSuperview() + self.errorViewController?.view.removeFromSuperview() } @MainActor diff --git a/ios/Sources/GutenbergKit/Sources/Views/EditorErrorView.swift b/ios/Sources/GutenbergKit/Sources/Views/EditorErrorView.swift deleted file mode 100644 index 18bf3681b..000000000 --- a/ios/Sources/GutenbergKit/Sources/Views/EditorErrorView.swift +++ /dev/null @@ -1,61 +0,0 @@ -import SwiftUI - -struct EditorErrorView: View { - - class ErrorProvider: ObservableObject { - @Published var error: Error? - - func setError(_ error: Error?) { - self.objectWillChange.send() - self.error = error - } - } - - @State - var errorProvider: ErrorProvider = ErrorProvider() - - init(error: Error? = nil) { - self.errorProvider = ErrorProvider() - if let error { - self.errorProvider.setError(error) - } - } - - var body: some View { - if let error = errorProvider.error { - ContentUnavailableView( - "Editor Error", - systemImage: "exclamationmark.circle", - description: Text(error.localizedDescription) - ) - } - } -} - -#if canImport(UIKit) -import UIKit - -class EditorErrorViewController: UIHostingController { - - var error: Error? { - get { - self.rootView.errorProvider.error - } - set { - self.rootView.errorProvider.setError(newValue) - } - } - - init(error: Error? = nil) { - super.init(rootView: EditorErrorView(error: error)) - } - - @MainActor @preconcurrency required dynamic init?(coder aDecoder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } -} -#endif - -#Preview { - EditorErrorView(error: CocoaError(.fileNoSuchFile)) -} From 5682570653069b481aa8d7023238b41706ad614a Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Fri, 12 Dec 2025 16:41:55 -0700 Subject: [PATCH 19/25] Use EditorLocalization --- .../GutenbergKit/Sources/EditorLocalization.swift | 6 ++++++ .../GutenbergKit/Sources/EditorViewController.swift | 4 ++-- ios/Sources/GutenbergKit/Sources/Strings.swift | 9 --------- 3 files changed, 8 insertions(+), 11 deletions(-) delete mode 100644 ios/Sources/GutenbergKit/Sources/Strings.swift diff --git a/ios/Sources/GutenbergKit/Sources/EditorLocalization.swift b/ios/Sources/GutenbergKit/Sources/EditorLocalization.swift index 039f4f0b8..6f11ad9ae 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorLocalization.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorLocalization.swift @@ -17,6 +17,10 @@ public enum EditorLocalizableString { case insertPattern case patternsCategoryUncategorized case patternsCategoryAll + + // MARK: - Editor Loading + case loadingEditor + case editorError } /// Provides localized strings for the editor. @@ -40,6 +44,8 @@ public final class EditorLocalization { case .insertPattern: "Insert Pattern" case .patternsCategoryUncategorized: "Uncategorized" case .patternsCategoryAll: "All" + case .loadingEditor: "Loading Editor" + case .editorError: "Editor Error" } } diff --git a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift index 86d044822..f684546a2 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift @@ -112,7 +112,7 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro // MARK: - Private Properties (UI) /// Progress bar shown during async dependency fetching ("No Dependencies" flow). - private let progressView = UIEditorProgressView(loadingText: Strings.loadingEditor) + private let progressView = UIEditorProgressView(loadingText: EditorLocalization.localize(.loadingEditor)) /// Spinning indicator shown while WebKit loads and parses the editor JavaScript. private let waitingView = UIActivityIndicatorView(style: .medium) @@ -772,7 +772,7 @@ extension EditorViewController { @MainActor func displayError(_ error: Error) { let view = ContentUnavailableView( - "Editor Error", + EditorLocalization.localize(.editorError), systemImage: "exclamationmark.circle", description: Text(error.localizedDescription) ) diff --git a/ios/Sources/GutenbergKit/Sources/Strings.swift b/ios/Sources/GutenbergKit/Sources/Strings.swift deleted file mode 100644 index 1e0868739..000000000 --- a/ios/Sources/GutenbergKit/Sources/Strings.swift +++ /dev/null @@ -1,9 +0,0 @@ -import Foundation - -enum Strings { - static let loadingEditor = NSLocalizedString( - "org.wordpress.gutenbergkit.loading-editor", - value: "Loading Editor", - comment: "A message displayed to the user under a progress bar while the editor is loading" - ) -} From 39cff26ac87e8b8e72bf44dfb96b48f9278f8f28 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Fri, 12 Dec 2025 17:15:05 -0700 Subject: [PATCH 20/25] Fix a potential bug with editor loading --- ios/Demo-iOS/Sources/Views/SitePreparationView.swift | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/ios/Demo-iOS/Sources/Views/SitePreparationView.swift b/ios/Demo-iOS/Sources/Views/SitePreparationView.swift index 7c4fd590e..6b14c5261 100644 --- a/ios/Demo-iOS/Sources/Views/SitePreparationView.swift +++ b/ios/Demo-iOS/Sources/Views/SitePreparationView.swift @@ -24,9 +24,12 @@ struct SitePreparationView: View { } .toolbar { ToolbarItem(placement: .confirmationAction) { - Button("Start") { - self.viewModel.buildAndLoadConfiguration(navigation: navigation) - }.buttonStyle(.borderedProminent) + if self.viewModel.editorConfiguration != nil { + Button("Start") { + self.viewModel.buildAndLoadConfiguration(navigation: navigation) + } + .buttonStyle(.borderedProminent) + } } } .navigationTitle("Editor Configuration") From 900dce72c40ee94e4e948519f0e6f681fc92e73f Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Fri, 12 Dec 2025 17:34:05 -0700 Subject: [PATCH 21/25] Update ios/Demo-iOS/Sources/Views/SitePreparationView.swift Co-authored-by: David Calhoun --- ios/Demo-iOS/Sources/Views/SitePreparationView.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/ios/Demo-iOS/Sources/Views/SitePreparationView.swift b/ios/Demo-iOS/Sources/Views/SitePreparationView.swift index 6b14c5261..aeb6e0f69 100644 --- a/ios/Demo-iOS/Sources/Views/SitePreparationView.swift +++ b/ios/Demo-iOS/Sources/Views/SitePreparationView.swift @@ -264,7 +264,6 @@ class SitePreparationViewModel { .setShouldUsePlugins(canUsePlugins) .setAuthHeader(config.authHeader) .setLogLevel(.debug) - .setEnableNetworkLogging(true) .build() } From 304e4fbd74f4aac4f8a7124f16603cdd446b4cb1 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Fri, 12 Dec 2025 17:34:29 -0700 Subject: [PATCH 22/25] Update ios/Demo-iOS/Sources/Views/EditorView.swift Co-authored-by: David Calhoun --- ios/Demo-iOS/Sources/Views/EditorView.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/ios/Demo-iOS/Sources/Views/EditorView.swift b/ios/Demo-iOS/Sources/Views/EditorView.swift index 9a5d7a613..423b8f703 100644 --- a/ios/Demo-iOS/Sources/Views/EditorView.swift +++ b/ios/Demo-iOS/Sources/Views/EditorView.swift @@ -244,7 +244,6 @@ extension EditorConfiguration { ) .setShouldUsePlugins(false) .setAuthHeader("") - .setNativeInserterEnabled(true) .setIsOfflineModeEnabled(true) .build() } From e2a578a6feb2b60abb764ae201be666cbf0c5656 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Fri, 12 Dec 2025 17:34:47 -0700 Subject: [PATCH 23/25] Update ios/Demo-iOS/Sources/Views/EditorView.swift Co-authored-by: Alex Grebenyuk --- ios/Demo-iOS/Sources/Views/EditorView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ios/Demo-iOS/Sources/Views/EditorView.swift b/ios/Demo-iOS/Sources/Views/EditorView.swift index 423b8f703..b8a0361dc 100644 --- a/ios/Demo-iOS/Sources/Views/EditorView.swift +++ b/ios/Demo-iOS/Sources/Views/EditorView.swift @@ -5,7 +5,7 @@ struct EditorView: View { private let configuration: EditorConfiguration private let dependencies: EditorDependencies? - @ObservedObject private var viewModel = EditorViewModel() + @StateObject private var viewModel = EditorViewModel() @Environment(\.dismiss) var dismiss From 9fba044ca1ced2255397010e8156cf6c48f6521f Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Fri, 12 Dec 2025 18:00:55 -0700 Subject: [PATCH 24/25] Rename to Foundation+Extensions.swift --- .../Extensions/{Foundation.swift => Foundation+Extensions.swift} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename ios/Sources/GutenbergKit/Sources/Extensions/{Foundation.swift => Foundation+Extensions.swift} (100%) diff --git a/ios/Sources/GutenbergKit/Sources/Extensions/Foundation.swift b/ios/Sources/GutenbergKit/Sources/Extensions/Foundation+Extensions.swift similarity index 100% rename from ios/Sources/GutenbergKit/Sources/Extensions/Foundation.swift rename to ios/Sources/GutenbergKit/Sources/Extensions/Foundation+Extensions.swift From 7752bd57bf229f3b8b051f348a9b83faf0999374 Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Fri, 12 Dec 2025 19:46:29 -0700 Subject: [PATCH 25/25] Inline cachePolicy usage docs --- .../GutenbergKit/Sources/Services/EditorService.swift | 4 ++++ .../Sources/Stores/EditorAssetLibrary.swift | 3 +++ .../GutenbergKit/Sources/Stores/EditorURLCache.swift | 10 +++++++--- 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/ios/Sources/GutenbergKit/Sources/Services/EditorService.swift b/ios/Sources/GutenbergKit/Sources/Services/EditorService.swift index ce15862cd..9aab6168f 100644 --- a/ios/Sources/GutenbergKit/Sources/Services/EditorService.swift +++ b/ios/Sources/GutenbergKit/Sources/Services/EditorService.swift @@ -52,6 +52,10 @@ public actor EditorService { /// - configuration: The editor configuration specifying site credentials and settings. /// - httpClient: An optional HTTP client for making API requests. If `nil`, a default /// client is created using the configuration's auth header. + /// - cachePolicy: The policy that determines when cached responses are considered valid. + /// Use `.ignore` to always fetch fresh data, `.maxAge(_:)` to expire entries after + /// a time interval, or `.always` (the default) to use cached data regardless of age. + /// This policy applies to both API response caching and asset manifest caching. /// - storageRoot: The directory for storing downloaded asset bundles. If `nil`, uses /// a default location based on the site ID. /// - cacheRoot: The directory for caching API responses. If `nil`, uses a default diff --git a/ios/Sources/GutenbergKit/Sources/Stores/EditorAssetLibrary.swift b/ios/Sources/GutenbergKit/Sources/Stores/EditorAssetLibrary.swift index 1926612d1..5c598c8e9 100644 --- a/ios/Sources/GutenbergKit/Sources/Stores/EditorAssetLibrary.swift +++ b/ios/Sources/GutenbergKit/Sources/Stores/EditorAssetLibrary.swift @@ -14,6 +14,9 @@ public actor EditorAssetLibrary { /// - Parameters: /// - configuration: The editor configuration containing site-specific settings. /// - httpClient: The HTTP client used to fetch remote assets. + /// - cachePolicy: The policy that determines when cached asset manifests are considered valid. + /// Use `.ignore` to always fetch fresh manifests, `.maxAge(_:)` to expire entries after + /// a time interval, or `.always` (the default) to use cached manifests regardless of age. /// - storageRoot: The root directory where asset bundles will be stored on disk. public init( configuration: EditorConfiguration, diff --git a/ios/Sources/GutenbergKit/Sources/Stores/EditorURLCache.swift b/ios/Sources/GutenbergKit/Sources/Stores/EditorURLCache.swift index 286094f5d..2136d6366 100644 --- a/ios/Sources/GutenbergKit/Sources/Stores/EditorURLCache.swift +++ b/ios/Sources/GutenbergKit/Sources/Stores/EditorURLCache.swift @@ -13,9 +13,13 @@ public struct EditorURLCache: Sendable { /// Creates a new URL cache. /// - /// - Parameter cacheRoot: The directory where cached responses will be stored. - /// If `nil`, the system default cache directory is used. The cache has a - /// maximum disk capacity of 100 MB. + /// - Parameters: + /// - cacheRoot: The directory where cached responses will be stored. + /// If `nil`, the system default cache directory is used. The cache has a + /// maximum disk capacity of 100 MB. + /// - cachePolicy: The policy that determines when cached responses are considered valid. + /// Use `.ignore` to always fetch fresh data, `.maxAge(_:)` to expire entries after + /// a time interval, or `.always` (the default) to use cached data regardless of age. public init(cacheRoot: URL? = nil, cachePolicy: EditorCachePolicy = .always) { // About enough for 10 sites self.cache = URLCache(memoryCapacity: 0, diskCapacity: 100 * 1024 * 1024, directory: cacheRoot)