diff --git a/docs/preloading.md b/docs/preloading.md new file mode 100644 index 00000000..a8734046 --- /dev/null +++ b/docs/preloading.md @@ -0,0 +1,328 @@ +# Preloading System + +## Overview + +The preloading system in GutenbergKit pre-fetches WordPress REST API responses before the editor loads, eliminating network latency during editor initialization. By injecting cached API responses directly into the JavaScript runtime, the Gutenberg editor can almost always initialize instantly without waiting for network requests. + +## Architecture + +The preloading system consists of several interconnected components: + +``` ++---------------------------------------------------------------------+ +| EditorService | +| (Orchestrates dependency fetching and caching) | ++----------------------------------+----------------------------------+ + | + +------------------+------------------+ + | | | + v v v + +--------------------+ +--------------+ +--------------------+ + | RESTAPIRepository | |EditorPreload | |EditorAssetLibrary | + | (API caching) | | List | | (JS/CSS bundles) | + +---------+----------+ +------+-------+ +--------------------+ + | | + v v + +--------------------+ +-------------------------------------+ + | EditorURLCache | | GBKitGlobal | + | (Disk caching) | | (Serialized to window.GBKit) | + +---------+----------+ +------------------+------------------+ + | | + v v + +--------------------+ +-----------------------------+ + |EditorCachePolicy | | JavaScript Preloading | + | (TTL management) | | Middleware | + +--------------------+ +-----------------------------+ +``` + +## Key Components + +### EditorService + +The `EditorService` actor coordinates fetching all editor dependencies concurrently: + +**Swift** +```swift +let service = EditorService(configuration: config) +let dependencies = try await service.prepare { progress in + print("Loading: \(progress.fractionCompleted * 100)%") +} +``` + +**Kotlin** +```kotlin +// TBD +``` + +The `prepare` method fetches these resources in parallel: +- Editor settings (theme styles, block settings) +- Asset bundles (JavaScript and CSS files) +- Preload list (API responses for editor initialization) + +### EditorPreloadList + +The `EditorPreloadList` struct contains pre-fetched API responses that are serialized to JSON and injected into the editor's JavaScript runtime: + +| Property | API Endpoint | Description | +|----------|--------------|-------------| +| `postData` | `/wp/v2/posts/{id}?context=edit` | The post being edited (existing posts only) | +| `postTypeData` | `/wp/v2/types/{type}?context=edit` | Schema for the current post type | +| `postTypesData` | `/wp/v2/types?context=view` | All available post types | +| `activeThemeData` | `/wp/v2/themes?context=edit&status=active` | Active theme information | +| `settingsOptionsData` | `OPTIONS /wp/v2/settings` | Site settings schema | + +### EditorURLCache + +The `EditorURLCache` provides disk-based caching for API responses, keyed by URL and HTTP method. It supports three cache policies via `EditorCachePolicy`: + +| Policy | Behavior | +|--------|----------| +| `.ignore` | Never use cached responses (force fresh data) | +| `.maxAge(TimeInterval)` | Use cached responses younger than the specified age | +| `.always` | Always use cached responses regardless of age | + +Example: + +**Swift** +```swift +// Cache responses for up to 1 hour +let service = EditorService( + configuration: config, + cachePolicy: .maxAge(3600) +) +``` + +**Kotlin** +```kotlin +// TBD +``` + +### RESTAPIRepository + +The `RESTAPIRepository` handles fetching and caching individual API responses. It follows a read-through caching pattern: + +1. Check cache for existing response +2. If cache hit and valid per policy, return cached data +3. If cache miss or expired, fetch from network +4. Store response in cache +5. Return response + +## Data Flow + +### 1. Preparation Phase (Native) + +When `EditorService.prepare()` is called: + +``` +EditorService.prepare() + |-- prepareEditorSettings() -> EditorSettings + |-- prepareAssetBundle() -> EditorAssetBundle + +-- preparePreloadList() + |-- prepareActiveTheme() -> EditorURLResponse + |-- prepareSettingsOptions() -> EditorURLResponse + |-- preparePost(type:) -> EditorURLResponse + |-- preparePostTypes() -> EditorURLResponse + +-- preparePost(id:) -> EditorURLResponse (if editing existing post) +``` + +### 2. Serialization Phase (Native) + +The `EditorPreloadList` is converted to JSON via `build()`: + +```json +{ + "/wp/v2/types/post?context=edit": { + "body": { "slug": "post", "supports": { ... } }, + "headers": { "Link": "<...>; rel=\"https://api.w.org/\"" } + }, + "/wp/v2/types?context=view": { + "body": { "post": { ... }, "page": { ... } }, + "headers": {} + }, + "/wp/v2/themes?context=edit&status=active": { + "body": [ ... ], + "headers": {} + }, + "OPTIONS": { + "/wp/v2/settings": { + "body": { ... }, + "headers": {} + } + } +} +``` + +### 3. Injection Phase (Native to Web) + +The `GBKitGlobal` struct packages all configuration and preload data, then injects it into the WebView as `window.GBKit`: + +```javascript +window.GBKit = { + siteURL: "https://example.com", + siteApiRoot: "https://example.com/wp-json", + authHeader: "Bearer ...", + preloadData: { /* serialized EditorPreloadList */ }, + editorSettings: { /* theme styles, colors, etc. */ }, + // ... other configuration +}; +``` + +### 4. Consumption Phase (JavaScript) + +The `@wordpress/api-fetch` package includes a preloading middleware that intercepts API requests: + +```javascript +// In src/utils/api-fetch.js +export function configureApiFetch() { + const { preloadData } = getGBKit(); + + apiFetch.use( + apiFetch.createPreloadingMiddleware(preloadData ?? defaultPreloadData) + ); +} +``` + +When Gutenberg makes an API request: + +1. The preloading middleware checks if the request path exists in `preloadData` +2. If found, the cached response is returned immediately (no network request) +3. If not found, the request proceeds to the network +4. The preload entry is consumed (one-time use) to ensure fresh data on subsequent requests + +## Header Filtering + +Only certain headers are preserved in preload responses to match WordPress core's behavior: + +- `Accept` - Content type negotiation +- `Link` - REST API discovery and pagination + +This filtering is performed by `EditorURLResponse.asPreloadResponse()`. + +## Cache Management + +### Automatic Cleanup + +`EditorService` automatically cleans up old asset bundles once per day: + +**Swift** +```swift +try await onceEvery(.seconds(86_400)) { + try await self.cleanup() +} +``` + +**Kotlin** +```kotlin +//tbd +``` + +### Manual Cache Control + +**Swift** +```swift +// Clear unused resources (keeps most recent) +try await service.cleanup() + +// Clear all resources (requires re-download) +try await service.purge() +``` + +**Kotlin** +```kotlin +//tbd +``` + +## Offline Mode + +When `EditorConfiguration.isOfflineModeEnabled` is `true`, the preloading system returns empty dependencies: + +```swift +if self.configuration.isOfflineModeEnabled { + return EditorDependencies( + editorSettings: .undefined, + assetBundle: .empty, + preloadList: nil + ) +} +``` + +Offline mode doesn't refer to reguar site that are offline – it's for when you're using GutenbergKit separately from a WordPress +site (for instance, the bundled editor in the demo app, or you just want an editor without the WP integration). + +The JavaScript side falls back to `defaultPreloadData` which contains minimal type definitions to allow basic editor functionality. + +## Progress Reporting + +The preloading system reports its progress to give the user high-quality feedback about the loading process - if the user loads the +editor without `EditorDependencies` present, the editor will display a loading screen with a progress bar. If the user provides `EditorDependencies` +that contain everything the editor needs, the progress bar will never be displayed. + +## EditorDependencies + +`EditorDependencies` contains all pre-fetched resources needed to initialize the editor instantly. + +| Property | Type | Description | +|----------|------|-------------| +| `editorSettings` | `EditorSettings` | Theme styles, colors, typography, block settings | +| `assetBundle` | `EditorAssetBundle` | Cached JavaScript/CSS for plugins/themes | +| `preloadList` | `EditorPreloadList?` | Pre-fetched API responses | + +### Obtaining Dependencies + +```swift +let service = EditorService(configuration: configuration) +let dependencies = try await service.prepare { progress in + loadingView.progress = progress.fractionCompleted +} +``` + +### EditorViewController Loading Flows + +`EditorViewController` supports two loading flows based on whether dependencies are provided: + +#### Flow 1: Dependencies Provided (Recommended) + +```swift +let editor = EditorViewController( + configuration: configuration, + dependencies: dependencies // Loads immediately +) +``` + +The editor skips the progress UI and loads the WebView immediately. + +#### Flow 2: No Dependencies (Fallback) + +```swift +let editor = EditorViewController( + configuration: configuration + // No dependencies - fetches automatically +) +``` + +The editor displays a progress bar while fetching, then loads once complete. + +### Best Practice: Prepare Early + +Fetch dependencies before the user needs the editor: + +```swift +class PostListViewController: UIViewController { + private var editorDependencies: EditorDependencies? + private let editorService: EditorService + + override func viewDidLoad() { + super.viewDidLoad() + Task { + self.editorDependencies = try? await editorService.prepare { _ in } + } + } + + func editPost(_ post: Post) { + let editor = EditorViewController( + configuration: EditorConfiguration(post: post), + dependencies: editorDependencies + ) + navigationController?.pushViewController(editor, animated: true) + } +} diff --git a/ios/Demo-iOS/Gutenberg.xcodeproj/project.pbxproj b/ios/Demo-iOS/Gutenberg.xcodeproj/project.pbxproj index c9268e1d..283bfb48 100644 --- a/ios/Demo-iOS/Gutenberg.xcodeproj/project.pbxproj +++ b/ios/Demo-iOS/Gutenberg.xcodeproj/project.pbxproj @@ -122,6 +122,7 @@ buildRules = ( ); dependencies = ( + 245D6BE42EDFCD640076D741 /* PBXTargetDependency */, ); fileSystemSynchronizedGroups = ( 2468525B2EAAC62B00ED1F09 /* Views */, @@ -197,6 +198,13 @@ }; /* End PBXSourcesBuildPhase section */ +/* Begin PBXTargetDependency section */ + 245D6BE42EDFCD640076D741 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + productRef = 245D6BE32EDFCD640076D741 /* GutenbergKit */; + }; +/* End PBXTargetDependency section */ + /* Begin XCBuildConfiguration section */ 0C4F59972BEFF4980028BD96 /* Debug */ = { isa = XCBuildConfiguration; @@ -431,6 +439,10 @@ isa = XCSwiftPackageProductDependency; productName = GutenbergKit; }; + 245D6BE32EDFCD640076D741 /* GutenbergKit */ = { + isa = XCSwiftPackageProductDependency; + productName = GutenbergKit; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = 0C4F59832BEFF4970028BD96 /* Project object */; 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 0ea20e7d..909f39e3 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/Demo-iOS/Sources/ConfigurationItem.swift b/ios/Demo-iOS/Sources/ConfigurationItem.swift index a29c4157..bfe6d87b 100644 --- a/ios/Demo-iOS/Sources/ConfigurationItem.swift +++ b/ios/Demo-iOS/Sources/ConfigurationItem.swift @@ -1,7 +1,8 @@ import Foundation +import GutenbergKit /// Represents a configuration item for the editor -enum ConfigurationItem: Codable, Identifiable, Equatable { +enum ConfigurationItem: Codable, Identifiable, Equatable, Hashable { case bundledEditor case editorConfiguration(ConfiguredEditor) @@ -24,8 +25,13 @@ enum ConfigurationItem: Codable, Identifiable, Equatable { } } +struct RunnableEditor: Equatable, Hashable { + let configuration: EditorConfiguration + let dependencies: EditorDependencies? +} + /// Configuration for an editor with site integration -struct ConfiguredEditor: Codable, Identifiable, Equatable { +struct ConfiguredEditor: Codable, Identifiable, Equatable, Hashable { let id: String let name: String let siteUrl: String diff --git a/ios/Demo-iOS/Sources/GutenbergApp.swift b/ios/Demo-iOS/Sources/GutenbergApp.swift index 8b4f9ecf..82f5e02e 100644 --- a/ios/Demo-iOS/Sources/GutenbergApp.swift +++ b/ios/Demo-iOS/Sources/GutenbergApp.swift @@ -2,8 +2,42 @@ import SwiftUI import OSLog 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 { + private struct NavigationKey: EnvironmentKey { + static let defaultValue = Navigation() + } + + var navigation: Navigation { + get { self[NavigationKey.self] } + set { self[NavigationKey.self] = newValue } + } +} + @main struct GutenbergApp: App { + @StateObject + private var navigation = Navigation() + + private let configurationStorage = ConfigurationStorage() + private let authenticationManager = AuthenticationManager() + init() { // Configure logger for GutenbergKit EditorLogger.shared = OSLogEditorLogger() @@ -12,12 +46,24 @@ struct GutenbergApp: App { var body: some Scene { WindowGroup { - NavigationStack { + NavigationStack(path: $navigation.path) { AppRootView() + .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) + } + } + } } - .environmentObject(ConfigurationStorage()) - .environmentObject(AuthenticationManager()) + .environment(\.navigation, navigation) + .environmentObject(configurationStorage) + .environmentObject(authenticationManager) } } diff --git a/ios/Demo-iOS/Sources/Views/AppRootView.swift b/ios/Demo-iOS/Sources/Views/AppRootView.swift index 49c9e6a6..f42e38c7 100644 --- a/ios/Demo-iOS/Sources/Views/AppRootView.swift +++ b/ios/Demo-iOS/Sources/Views/AppRootView.swift @@ -11,7 +11,6 @@ struct AppRootView: View { @EnvironmentObject private var authenticationManager: AuthenticationManager - @State private var selectedConfiguration: ConfigurationItem? @State private var configurations: [ConfigurationItem] = [.bundledEditor] @State private var siteUrlInput = "" @@ -23,7 +22,7 @@ struct AppRootView: View { @AppStorage("isNativeInserterEnabled") private var isNativeInserterEnabled = false var body: some View { - EditorList(isNativeInserterEnabled: $isNativeInserterEnabled, selectedConfiguration: $selectedConfiguration) + EditorList() .alert(isPresented: $hasError, error: error, actions: { Button { self.hasError = false @@ -35,79 +34,12 @@ struct AppRootView: View { } }.buttonStyle(.borderedProminent) }) - .fullScreenCover(item: $selectedConfiguration) { config in - editor - } - .onChange(of: self.selectedConfiguration) { oldValue, newValue in - switch newValue { - case .bundledEditor: - let config = createBundledConfiguration() - activeEditorConfiguration = config - case .editorConfiguration(let config): - self.loadEditorConfiguration(for: config) - case .none: - self.activeEditorConfiguration = nil - } - } - } - - @ViewBuilder - var editor: some View { - NavigationView { - if let activeEditorConfiguration { - EditorView(configuration: activeEditorConfiguration) - } else { - ProgressView("Preparing Editor") - } - } - } - - private func loadEditorConfiguration(for config: ConfiguredEditor) { - Task { - do { - let client = WordPressAPI( - urlSession: .shared, - apiRootUrl: try ParsedUrl.parse(input: config.siteApiRoot), - authentication: .authorizationHeader(token: config.authHeader) - ) - - let apiRoot = try await client.apiRoot.get().data - - let canUsePlugins = apiRoot.hasRoute(route: "/wpcom/v2/editor-assets") - let canUseEditorStyles = apiRoot.hasRoute(route: "/wp-block-editor/v1/settings") - - self.activeEditorConfiguration = EditorConfigurationBuilder() - .setShouldUseThemeStyles(canUseEditorStyles) - .setShouldUsePlugins(canUsePlugins) - .setSiteUrl(config.siteUrl) - .setSiteApiRoot(config.siteApiRoot) - .setAuthHeader(config.authHeader) - .setNativeInserterEnabled(isNativeInserterEnabled) - .setLogLevel(.debug) - .setEnableNetworkLogging(true) - .build() - } catch { - self.hasError = true - self.error = AppError(errorDescription: error.localizedDescription) - } - } } private func deleteConfiguration(_ config: ConfigurationItem) { configurations.removeAll { $0.id == config.id } configurationStorage.saveConfigurations(configurations) } - - private func createBundledConfiguration() -> EditorConfiguration { - EditorConfigurationBuilder() - .setShouldUsePlugins(false) - .setSiteUrl("") - .setSiteApiRoot("") - .setAuthHeader("") - .setNativeInserterEnabled(isNativeInserterEnabled) - .setEnableNetworkLogging(true) - .build() - } } struct AppError: LocalizedError { diff --git a/ios/Demo-iOS/Sources/Views/EditorList.swift b/ios/Demo-iOS/Sources/Views/EditorList.swift index 4f616121..d747335c 100644 --- a/ios/Demo-iOS/Sources/Views/EditorList.swift +++ b/ios/Demo-iOS/Sources/Views/EditorList.swift @@ -7,17 +7,15 @@ struct EditorList: View { @State private var showAddDialog = false @State private var showDebugSettings = false - @Binding var isNativeInserterEnabled: Bool - - @Binding var selectedConfiguration: ConfigurationItem? @State var configurationToDelete: ConfigurationItem? var body: some View { List { Section { - Button("Default Editor") { - selectedConfiguration = .bundledEditor + NavigationLink("Default Editor") { + SitePreparationView(site: .bundledEditor) + } } header: { if ProcessInfo.processInfo.environment["GUTENBERG_EDITOR_URL"] != nil @@ -47,10 +45,6 @@ struct EditorList: View { } footer: { Text("Editors with site configuration; enabling media uploads, plugin support, etc.") } - - Section("Feature Configuration") { - Toggle("Native Inserter", isOn: $isNativeInserterEnabled) - } } .alert( "Delete Editor Configuration?", @@ -105,9 +99,7 @@ struct EditorList: View { if case .editorConfiguration = $0 { return true } return false }) { config in - Button(config.displayName) { - self.selectedConfiguration = config - } + NavigationLink(config.displayName, value: config) .swipeActions(edge: .trailing) { Button(role: .destructive) { configurationToDelete = config diff --git a/ios/Demo-iOS/Sources/Views/EditorView.swift b/ios/Demo-iOS/Sources/Views/EditorView.swift index 07701aeb..b8a0361d 100644 --- a/ios/Demo-iOS/Sources/Views/EditorView.swift +++ b/ios/Demo-iOS/Sources/Views/EditorView.swift @@ -3,17 +3,23 @@ import GutenbergKit struct EditorView: View { private let configuration: EditorConfiguration + private let dependencies: EditorDependencies? - @State private var viewModel = EditorViewModel() + @StateObject private var viewModel = EditorViewModel() - @Environment(\.dismiss) private var dismiss + @Environment(\.dismiss) var dismiss - init(configuration: EditorConfiguration) { + init(configuration: EditorConfiguration, dependencies: EditorDependencies? = nil) { self.configuration = configuration + self.dependencies = dependencies } var body: some View { - _EditorView(configuration: configuration, viewModel: viewModel) + _EditorView( + configuration: configuration, + dependencies: dependencies, + viewModel: viewModel + ) .toolbar { toolbar } } @@ -21,13 +27,11 @@ struct EditorView: View { private var toolbar: some ToolbarContent { ToolbarItem(placement: .topBarLeading) { Button { - dismiss() + self.dismiss() } label: { Image(systemName: "xmark") } - .disabled(viewModel.isModalDialogOpen) } - ToolbarItemGroup(placement: .topBarTrailing) { Group { Button { @@ -90,13 +94,16 @@ struct EditorView: View { private struct _EditorView: UIViewControllerRepresentable { private let configuration: EditorConfiguration + private let dependencies: EditorDependencies? private let viewModel: EditorViewModel init( configuration: EditorConfiguration, + dependencies: EditorDependencies? = nil, viewModel: EditorViewModel ) { self.configuration = configuration + self.dependencies = dependencies self.viewModel = viewModel } @@ -105,10 +112,9 @@ private struct _EditorView: UIViewControllerRepresentable { } func makeUIViewController(context: Context) -> EditorViewController { - let viewController = EditorViewController(configuration: configuration) + let viewController = EditorViewController(configuration: configuration, dependencies: dependencies) viewController.delegate = context.coordinator viewController.webView.isInspectable = true - viewController.startEditorSetup() viewModel.perform = { [weak viewController] in switch $0 { @@ -210,8 +216,7 @@ private struct _EditorView: UIViewControllerRepresentable { } } -@Observable -private final class EditorViewModel { +private final class EditorViewModel: ObservableObject { var isModalDialogOpen = false var hasUndo = false var hasRedo = false @@ -227,6 +232,18 @@ private final class EditorViewModel { #Preview { NavigationStack { - EditorView(configuration: .default) + EditorView(configuration: .bundled) } } + +extension EditorConfiguration { + static let bundled = EditorConfigurationBuilder( + postType: "post", + siteURL: URL(string: "https://example.com")!, + siteApiRoot: URL(string: "https://example.com/wp-json")! + ) + .setShouldUsePlugins(false) + .setAuthHeader("") + .setIsOfflineModeEnabled(true) + .build() +} diff --git a/ios/Demo-iOS/Sources/Views/SitePreparationView.swift b/ios/Demo-iOS/Sources/Views/SitePreparationView.swift new file mode 100644 index 00000000..aeb6e0f6 --- /dev/null +++ b/ios/Demo-iOS/Sources/Views/SitePreparationView.swift @@ -0,0 +1,334 @@ +import SwiftUI +import WordPressAPI +import GutenbergKit + +struct SitePreparationView: View { + + @Environment(\.navigation) + private var navigation + + @State + private var viewModel: SitePreparationViewModel + + init(site: ConfigurationItem) { + self.viewModel = SitePreparationViewModel(configurationItem: site) + } + + var body: some View { + Group { + if let configuration = self.viewModel.editorConfiguration { + loadedView(configuration: configuration) + } else { + ProgressView("Loading Site Configuration") + } + } + .toolbar { + ToolbarItem(placement: .confirmationAction) { + if self.viewModel.editorConfiguration != nil { + Button("Start") { + self.viewModel.buildAndLoadConfiguration(navigation: navigation) + } + .buttonStyle(.borderedProminent) + } + } + } + .navigationTitle("Editor Configuration") + .onAppear { + self.viewModel.startLoading() + } + } + + func loadedView(configuration: EditorConfiguration) -> some View { + Form { + + if viewModel.editorDependencies != nil { + Text("Editor Dependencies Loaded – the editor should load instantly") + } else { + Text("Editor Dependencies Missing – the editor will need to load them when it starts") + } + + Section("Feature Configuration") { + Toggle("Enable Native Inserter", isOn: $viewModel.enableNativeInserter) + Toggle("Enable Network Logging", isOn: $viewModel.enableNetworkLogging) + + // TODO: Loading this from the server would allow us to validate Custom Post Type support + Picker("Post Type", selection: $viewModel.postType) { + Text("Post").tag("post") + Text("Page").tag("page") + } + } + + if let editorConfiguration = viewModel.editorConfiguration { + Section("Editor Configuration Details") { + KeyValueRow(key: "Site URL", value: editorConfiguration.siteURL.absoluteString) + KeyValueRow(key: "API Root", value: editorConfiguration.siteApiRoot.absoluteString) + KeyValueRow(key: "Supports Block Assets", value: editorConfiguration.shouldUsePlugins) + KeyValueRow(key: "Supports Theme Styles", value: editorConfiguration.shouldUseThemeStyles) + } + } + + self.preloadSection + + if let error = viewModel.error { + Section("Error") { + Text(error.localizedDescription) + } + } + } + } + + var preloadSection: some View { + Section { + Button("Prepare Editor") { + withAnimation { + self.viewModel.prepareEditor() + } + }.disabled(self.viewModel.disableButtons) + + Button("Prepare Editor Ignoring Cache") { + withAnimation { + self.viewModel.prepareEditorFromScratch() + } + }.disabled(self.viewModel.disableButtons) + + Button("Clear Preload Cache") { + withAnimation { + self.viewModel.resetEditorCaches() + } + }.disabled(self.viewModel.disableButtons) + } header: { + Text("Local Caches") + } footer: { + if let progress = self.viewModel.loadingProgress { + ProgressView(value: progress.fractionCompleted) + } + }.task { + await self.viewModel.countAssetBundles() + } + } +} + +@Observable +class SitePreparationViewModel { + + var enableNativeInserter: Bool = true + + var enableNetworkLogging: Bool = false + + var postType: String = "post" + + var cacheBundleCount: Int? + + var isPreparing: Bool = false + + var isPrepared: Bool = false + + var error: Error? + + var configurationItem: ConfigurationItem + + var editorConfiguration: EditorConfiguration? + + var disableButtons: Bool = false + + var loadingProgress: EditorProgress? + + var editorDependencies: EditorDependencies? + + private var taskHandle: Task? + + init(configurationItem: ConfigurationItem) { + self.configurationItem = configurationItem + } + + @MainActor + func startLoading() { + self.taskHandle = Task { + do { + switch configurationItem { + case .bundledEditor: + self.editorConfiguration = .bundled + case .editorConfiguration(let siteDetails): + let newConfiguration = try await self.loadConfiguration(for: siteDetails) + self.editorConfiguration = newConfiguration + } + } catch { + self.error = error + } + } + } + + /// Prepares the editor by caching all resources and preparing an `EditorDependencies` object to inject into the editor. + /// Once this method is run, the editor should load instantly. + @MainActor + func prepareEditor() { + guard let configuration = self.editorConfiguration else { + preconditionFailure("Unable to prepare editor without editor configuration – the UI should prevent this") + } + + let cacheInterval: TimeInterval = 86_400 // Cache for one day + self.prepareEditor(with: EditorService(configuration: configuration, cachePolicy: .maxAge(cacheInterval))) + } + + /// Prepares the editor by caching all resources and preparing an `EditorDependencies` object to inject into the editor. + /// Once this method is run, the editor should load instantly. + @MainActor + func prepareEditorFromScratch() { + guard let configuration = self.editorConfiguration else { + preconditionFailure("Unable to prepare editor without editor configuration – the UI should prevent this") + } + + self.prepareEditor(with: EditorService(configuration: configuration, cachePolicy: .ignore)) + } + + private func prepareEditor(with editorService: EditorService) { + self.taskHandle = Task { + self.disableButtons = true + defer { + self.loadingProgress = nil + self.disableButtons = false + } + + do { + self.editorDependencies = try await editorService.prepare { @MainActor progress in + self.loadingProgress = progress + } + + await self.countAssetBundles() + + } catch { + self.error = error + } + } + + } + + /// Clears all local editor data, forcing the loading functions to run the next time the editor starts up. This is useful for testing the built-in + /// editor loading code. + func resetEditorCaches() { + guard let configuration = self.editorConfiguration else { + preconditionFailure("Unable to prepare editor without editor configuration – the UI should prevent this") + } + + self.taskHandle = Task { + do { + self.disableButtons = true + defer { self.disableButtons = false } + + self.editorDependencies = nil + + let editorService = EditorService(configuration: configuration) + try await editorService.purge() + + await self.countAssetBundles() + } catch { + self.error = error + } + } + } + + func countAssetBundles() async { + do { + guard let editorConfiguration else { + self.cacheBundleCount = 0 + return + } + + let editorService = EditorService(configuration: editorConfiguration) + self.cacheBundleCount = try await editorService.fetchAssetBundleCount() + } catch { + self.error = error + } + } + + @MainActor + private func loadConfiguration(for config: ConfiguredEditor) async throws -> EditorConfiguration { + let parsedApiRoot = try ParsedUrl.parse(input: config.siteApiRoot) + let client = WordPressAPI( + urlSession: .shared, + apiRootUrl: parsedApiRoot, + authentication: .authorizationHeader(token: config.authHeader) + ) + + let apiRoot = try await client.apiRoot.get().data + + let canUsePlugins = apiRoot.hasRoute(route: "/wpcom/v2/editor-assets") + let canUseEditorStyles = apiRoot.hasRoute(route: "/wp-block-editor/v1/settings") + + return EditorConfigurationBuilder( + postType: "post", + siteURL: URL(string: apiRoot.siteUrlString())!, + siteApiRoot: parsedApiRoot.asURL() + ) + .setShouldUseThemeStyles(canUseEditorStyles) + .setShouldUsePlugins(canUsePlugins) + .setAuthHeader(config.authHeader) + .setLogLevel(.debug) + .build() + } + + private func buildConfiguration() -> EditorConfiguration { + guard let editorConfiguration = self.editorConfiguration else { + preconditionFailure("Cannot build configuration as it is not loaded yet") + } + + return editorConfiguration.toBuilder() + .setEnableNetworkLogging(self.enableNetworkLogging) + .setNativeInserterEnabled(self.enableNativeInserter) + .setPostType(self.postType) + .build() + } + + func buildAndLoadConfiguration(navigation: Navigation) { + let editor = RunnableEditor( + configuration: buildConfiguration(), + dependencies: self.editorDependencies + ) + + navigation.present(editor) + } +} + +struct KeyValueRow: View { + + enum Value { + case string(String) + case bool(Bool) + } + + let key: String + let value: Value + + init(key: String, value: String) { + self.key = key + self.value = .string(value) + } + + init(key: String, value: Bool) { + self.key = key + self.value = .bool(value) + } + + var body: some View { + switch self.value { + case .string(let string): + VStack(alignment: .leading) { + Text(key).font(.caption2).foregroundStyle(Color.secondary) + Text(string) + } + case .bool(let bool): + HStack { + Text(key) + Spacer() + Image(systemName: bool ? "checkmark.circle" : "xmark.circle") + .foregroundStyle(bool ? Color.green : Color.red) + } + } + } +} + +#Preview("Bundled Editor") { + NavigationStack { + SitePreparationView(site: .bundledEditor) + } +} diff --git a/ios/Sources/GutenbergKit/Sources/Constants.swift b/ios/Sources/GutenbergKit/Sources/Constants.swift new file mode 100644 index 00000000..aabd0c32 --- /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 93694e46..00000000 --- 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 80f66e3b..00000000 --- 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 00000000..6973288d --- /dev/null +++ b/ios/Sources/GutenbergKit/Sources/EditorHTTPClient.swift @@ -0,0 +1,96 @@ +import Foundation +import OSLog + +/// A protocol for making authenticated HTTP requests to the WordPress REST API. +public protocol EditorHTTPClientProtocol: Sendable { + 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. +/// +/// 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? + private let requestTimeout: TimeInterval + + public init( + urlSession: URLSession, + authHeader: String, + delegate: EditorHTTPClientDelegate? = nil, + requestTimeout: TimeInterval = 60 // `URLRequest` default + ) { + self.urlSession = urlSession + self.authHeader = authHeader + self.delegate = delegate + self.requestTimeout = requestTimeout + } + + 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 \(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/EditorLocalization.swift b/ios/Sources/GutenbergKit/Sources/EditorLocalization.swift index 039f4f0b..6f11ad9a 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/EditorLogging.swift b/ios/Sources/GutenbergKit/Sources/EditorLogging.swift index 77ff2e46..bf090330 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: "performance") + + /// 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,14 @@ 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) { 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 b2f5e069..f684546a 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift @@ -1,61 +1,164 @@ @preconcurrency import WebKit import SwiftUI -import Combine -import CryptoKit +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 { - public let webView: WKWebView - let service: EditorService - let assetsLibrary: EditorAssetsLibrary + public let webView: WKWebView public var configuration: EditorConfiguration + + /// 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 _isEditorRendered = false - private var _isEditorSetup = false + private var dependencyTaskHandle: Task? + + /// Error encountered while loading dependencies. + private var error: Error? { + didSet { + if let error { + self.displayError(error) + } + } + } + + /// 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 + + /// 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() - public private(set) var state = EditorState() + // MARK: - Private Properties (UI) - public weak var delegate: EditorViewControllerDelegate? + /// Progress bar shown during async dependency fetching ("No Dependencies" flow). + private let progressView = UIEditorProgressView(loadingText: EditorLocalization.localize(.loadingEditor)) - private var cancellables: [AnyCancellable] = [] + /// Spinning indicator shown while WebKit loads and parses the editor JavaScript. + private let waitingView = UIActivityIndicatorView(style: .medium) - /// Stores the contextId from the most recent openMediaLibrary call - /// to pass back to JavaScript when media is selected + /// View controller that displays error information when loading fails. + private var errorViewController: UIHostingController? + + /// 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) + + /// Timestamp captured at initialization for measuring first-render performance. + private let timestampInit = CFAbsoluteTimeGetCurrent() - /// Overlay view shown over the navigation bar when modal dialogs are open + /// 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 - private(set) lazy var htmlPreviewManager = HTMLPreviewManager(themeStyles: dependencies?.extractThemeStyles()) + /// Renders HTML previews for block patterns in the block inserter. + private lazy var htmlPreviewManager: 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 = .default, + configuration: EditorConfiguration, + dependencies: EditorDependencies? = nil, mediaPicker: MediaPickerController? = nil, isWarmupMode: Bool = false ) { - self.service = EditorService.shared(for: configuration.siteURL) self.configuration = configuration + self.dependencies = dependencies + 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 +173,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 @@ -88,6 +187,7 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro fatalError("init(coder:) has not been implemented") } + // MARK: - View Lifecycle (Loading Entry Point) public override func viewDidLoad() { super.viewDidLoad() @@ -106,10 +206,26 @@ 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 { - startEditorSetup() + self.loadEditorWithoutDependencies() + } + + if let dependencies { + // FAST PATH: Dependencies were provided at init() - load immediately + do { + try self.loadEditor(dependencies: dependencies) + } catch { + self.error = error + } + } else { + // ASYNC FLOW: No dependencies - fetch them asynchronously + self.dependencyTaskHandle = Task(priority: .userInitiated) { + await self.prepareEditor() + } } } @@ -123,20 +239,58 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro removeNavigationOverlay() } - private func setUpEditor() { - let webViewConfiguration = webView.configuration - let userContentController = webViewConfiguration.userContentController - let editorInitialConfig = getEditorConfiguration() - userContentController.addUserScript(editorInitialConfig) + public override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + self.dependencyTaskHandle?.cancel() } - private func loadEditor() { - webView.configuration.userContentController.addScriptMessageHandler( - EditorAssetsProvider(library: assetsLibrary), - contentWorld: .page, - name: "loadFetchedEditorAssets" - ) + /// 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) + } + + // Store dependencies for later use (e.g., HTMLPreviewManager) + self.dependencies = dependencies + // Continue to the shared loading path + try self.loadEditor(dependencies: dependencies) + } catch { + // Display error view - this sets self.error which triggers displayError() + self.error = error + } + } + + // 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) + + // 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 { @@ -145,41 +299,46 @@ 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) - }; + /// 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!) + } - localStorage.setItem('GBKit', JSON.stringify(window.GBKit)); + /// 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() + 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 +350,9 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro } private func _setContent(_ content: String) { - guard _isEditorRendered else { return } + guard self.isReady else { + return + } let escapedString = content.addingPercentEncoding(withAllowedCharacters: .alphanumerics)! evaluate("editor.setContent('\(escapedString)');", isCritical: true) @@ -248,18 +409,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) { @@ -509,17 +658,35 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro } } - // Only after this point it's safe to use JS `editor` API. + // MARK: - Loading Complete: Editor Ready + + /// 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 !_isEditorRendered else { return } - _isEditorRendered = true + // Guard against multiple onEditorLoaded events (e.g., from location.reload()) + guard !self.isReady else { + return + } + + 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 { @@ -599,5 +766,78 @@ private final class GutenbergEditorController: NSObject, WKNavigationDelegate, W } } +//MARK: - View Transformation +extension EditorViewController { + + @MainActor + func displayError(_ error: Error) { + let view = ContentUnavailableView( + EditorLocalization.localize(.editorError), + 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() + } + + @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+Extensions.swift b/ios/Sources/GutenbergKit/Sources/Extensions/Foundation+Extensions.swift new file mode 100644 index 00000000..aa76bb7c --- /dev/null +++ b/ios/Sources/GutenbergKit/Sources/Extensions/Foundation+Extensions.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(method: EditorHttpMethod, url: URL) { + 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 d579be30..00000000 --- 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 00000000..b4d36d84 --- /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 00000000..869cf2b3 --- /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 3c432379..00000000 --- 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) - } - } -} diff --git a/src/utils/api-fetch.js b/src/utils/api-fetch.js index 3ce2f551..1e6e6c81 100644 --- a/src/utils/api-fetch.js +++ b/src/utils/api-fetch.js @@ -19,7 +19,7 @@ import { getGBKit } from './bridge'; * @return {void} */ export function configureApiFetch() { - const { siteApiRoot = '' } = getGBKit(); + const { siteApiRoot = '', preloadData = null } = getGBKit(); apiFetch.use( apiFetch.createRootURLMiddleware( siteApiRoot ) ); apiFetch.use( corsMiddleware ); @@ -28,7 +28,9 @@ export function configureApiFetch() { apiFetch.use( filterEndpointsMiddleware ); apiFetch.use( mediaUploadMiddleware ); apiFetch.use( transformOEmbedApiResponse ); - apiFetch.use( apiFetch.createPreloadingMiddleware( preloadData ) ); + apiFetch.use( + apiFetch.createPreloadingMiddleware( preloadData ?? defaultPreloadData ) + ); } /** @@ -201,13 +203,7 @@ function transformOEmbedApiResponse( options, next ) { return next( options, next ); } -/** - * Initial data to reduce initial render time. E.g., the PostTitle component - * requires the post type data to render the title placeholder. - * - * @todo Provide this data from the host app - */ -const preloadData = { +const defaultPreloadData = { '/wp/v2/types?context=view': { body: { post: {