diff --git a/Package.swift b/Package.swift index 39214b9a..a3353d9e 100644 --- a/Package.swift +++ b/Package.swift @@ -27,7 +27,7 @@ let package = Package( path: "ios/Tests", exclude: [], resources: [ - .copy("GutenbergKitTests/Resources/manifest-test-case-1.json") + .process("GutenbergKitTests/Resources/") ] ), ] diff --git a/ios/Demo-iOS/Sources/GutenbergApp.swift b/ios/Demo-iOS/Sources/GutenbergApp.swift index c8973ecd..8b4f9ecf 100644 --- a/ios/Demo-iOS/Sources/GutenbergApp.swift +++ b/ios/Demo-iOS/Sources/GutenbergApp.swift @@ -1,7 +1,15 @@ import SwiftUI +import OSLog +import GutenbergKit @main struct GutenbergApp: App { + init() { + // Configure logger for GutenbergKit + EditorLogger.shared = OSLogEditorLogger() + EditorLogger.logLevel = .debug + } + var body: some Scene { WindowGroup { NavigationStack { @@ -12,3 +20,20 @@ struct GutenbergApp: App { .environmentObject(AuthenticationManager()) } } + +struct OSLogEditorLogger: GutenbergKit.EditorLogging { + private let logger: Logger + + init(subsystem: String = "com.gutenbergkit.demo", category: String = "GutenbergKit") { + self.logger = Logger(subsystem: subsystem, category: category) + } + + func log(_ level: GutenbergKit.EditorLogLevel, _ message: String) { + switch level { + case .debug: logger.debug("\(message)") + case .info: logger.info("\(message)") + case .warn: logger.warning("\(message)") + case .error: logger.error("\(message)") + } + } +} diff --git a/ios/Demo-iOS/Sources/Views/AppRootView.swift b/ios/Demo-iOS/Sources/Views/AppRootView.swift index fe0f5c82..abd105bf 100644 --- a/ios/Demo-iOS/Sources/Views/AppRootView.swift +++ b/ios/Demo-iOS/Sources/Views/AppRootView.swift @@ -40,9 +40,13 @@ struct AppRootView: View { } .onChange(of: self.selectedConfiguration) { oldValue, newValue in switch newValue { - case .bundledEditor: activeEditorConfiguration = createBundledConfiguration() - case .editorConfiguration(let config): self.loadEditorConfiguration(for: config) - case .none: self.activeEditorConfiguration = nil + case .bundledEditor: + let config = createBundledConfiguration() + activeEditorConfiguration = config + case .editorConfiguration(let config): + self.loadEditorConfiguration(for: config) + case .none: + self.activeEditorConfiguration = nil } } } @@ -85,10 +89,10 @@ struct AppRootView: View { if let baseURL = URL(string: config.siteApiRoot) { let service = EditorService( - siteID: config.siteUrl, - baseURL: baseURL, - authHeader: config.authHeader + siteURL: config.siteUrl, + networkSession: URLSession.shared ) + do { try await service.setup(&updatedConfiguration) } catch { diff --git a/ios/Demo-iOS/Sources/Views/DebugSettingsView.swift b/ios/Demo-iOS/Sources/Views/DebugSettingsView.swift index f25f2d41..1a528639 100644 --- a/ios/Demo-iOS/Sources/Views/DebugSettingsView.swift +++ b/ios/Demo-iOS/Sources/Views/DebugSettingsView.swift @@ -5,6 +5,8 @@ struct DebugSettingsView: View { @Environment(\.dismiss) private var dismiss @State private var showingClearCacheAlert = false @State private var cacheCleared = false + @State private var showingClearEditorDataAlert = false + @State private var editorDataCleared = false var body: some View { List { @@ -26,6 +28,25 @@ struct DebugSettingsView: View { } footer: { Text("Clears all cached preview images. Previews will be re-rendered on next view.") } + + Section { + Button(role: .destructive) { + showingClearEditorDataAlert = true + } label: { + HStack { + Text("Clear Editor Data") + Spacer() + if editorDataCleared { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.green) + } + } + } + } header: { + Text("Editor Service") + } footer: { + Text("Deletes all cached editor settings and assets. The editor will re-download everything on next launch.") + } } .navigationTitle("Debug Settings") .navigationBarTitleDisplayMode(.inline) @@ -44,6 +65,14 @@ struct DebugSettingsView: View { } message: { Text("This will delete all cached preview images. They will be regenerated when needed.") } + .alert("Clear Editor Data?", isPresented: $showingClearEditorDataAlert) { + Button("Clear", role: .destructive) { + clearEditorData() + } + Button("Cancel", role: .cancel) {} + } message: { + Text("This will delete all cached editor settings and assets. The editor will re-download everything on next launch.") + } } private func clearCache() { @@ -56,6 +85,17 @@ struct DebugSettingsView: View { cacheCleared = false } } + + private func clearEditorData() { + Task { + try? EditorViewController.deleteAllData() + editorDataCleared = true + + // Reset the checkmark after 2 seconds + try? await Task.sleep(nanoseconds: 2_000_000_000) + editorDataCleared = false + } + } } #Preview { diff --git a/ios/Demo-iOS/Sources/Views/EditorView.swift b/ios/Demo-iOS/Sources/Views/EditorView.swift index f97e8962..07701aeb 100644 --- a/ios/Demo-iOS/Sources/Views/EditorView.swift +++ b/ios/Demo-iOS/Sources/Views/EditorView.swift @@ -94,7 +94,7 @@ private struct _EditorView: UIViewControllerRepresentable { init( configuration: EditorConfiguration, - viewModel: EditorViewModel, + viewModel: EditorViewModel ) { self.configuration = configuration self.viewModel = viewModel @@ -159,10 +159,6 @@ private struct _EditorView: UIViewControllerRepresentable { // No-op for demo } - func editor(_ viewController: EditorViewController, didLogMessage message: String, level: LogLevel) { - print("[\(level)]: \(message)") - } - func editor(_ viewController: EditorViewController, didLogException error: GutenbergJSException) { // No-op for demo } diff --git a/ios/Sources/GutenbergKit/Sources/Cache/CachedAssetSchemeHandler.swift b/ios/Sources/GutenbergKit/Sources/Cache/CachedAssetSchemeHandler.swift deleted file mode 100644 index 6b57f9ca..00000000 --- a/ios/Sources/GutenbergKit/Sources/Cache/CachedAssetSchemeHandler.swift +++ /dev/null @@ -1,108 +0,0 @@ -import Foundation -import WebKit - -class CachedAssetSchemeHandler: NSObject, WKURLSchemeHandler { - nonisolated static let cachedURLSchemePrefix = "gbk-cache-" - nonisolated static let supportedURLSchemes = ["gbk-cache-http", "gbk-cache-https"] - - nonisolated static func originalHTTPURL(from url: URL) -> URL? { - guard let scheme = url.scheme, supportedURLSchemes.contains(scheme) else { return nil } - - guard var components = URLComponents(url: url, resolvingAgainstBaseURL: true) else { - return nil - } - - components.scheme = String(scheme.suffix(from: scheme.index(scheme.startIndex, offsetBy: cachedURLSchemePrefix.count))) - return components.url - } - - nonisolated static func cachedURL(forWebLink link: String) -> String? { - if link.starts(with: "http://") || link.starts(with: "https://") { - return cachedURLSchemePrefix + link - } - return nil - } - - let worker: Worker - - init(library: EditorAssetsLibrary) { - self.worker = .init(library: library) - } - - func webView(_ webView: WKWebView, start urlSchemeTask: any WKURLSchemeTask) { - Task { - await worker.start(urlSchemeTask) - } - } - - func webView(_ webView: WKWebView, stop urlSchemeTask: any WKURLSchemeTask) { - Task { - await worker.stop(urlSchemeTask) - } - } - - actor Worker { - struct TaskInfo { - var webViewTask: WKURLSchemeTask - var fetchAssetTask: Task - - func cancel() { - fetchAssetTask.cancel() - } - } - - let library: EditorAssetsLibrary - var tasks: [ObjectIdentifier: TaskInfo] = [:] - - init(library: EditorAssetsLibrary) { - self.library = library - } - - deinit { - for (_, task) in tasks { - task.cancel() - } - } - - func start(_ task: WKURLSchemeTask) { - guard let url = task.request.url, let httpURL = CachedAssetSchemeHandler.originalHTTPURL(from: url) else { - task.didFailWithError(URLError(.badURL)) - return - } - - let taskKey = ObjectIdentifier(task) - - let fetchAssetTask = Task { [library, weak self] in - do { - let (response, content) = try await library.cacheAsset(from: httpURL, webViewURL: url) - - await self?.tasks[taskKey]?.webViewTask.didReceive(response) - await self?.tasks[taskKey]?.webViewTask.didReceive(content) - - await self?.finish(with: nil, taskKey: taskKey) - } catch { - await self?.finish(with: error, taskKey: taskKey) - } - } - tasks[taskKey] = .init(webViewTask: task, fetchAssetTask: fetchAssetTask) - } - - func stop(_ task: WKURLSchemeTask) { - let taskKey = ObjectIdentifier(task) - tasks[taskKey]?.cancel() - tasks[taskKey] = nil - } - - private func finish(with error: Error?, taskKey: ObjectIdentifier) { - guard let task = tasks[taskKey] else { return } - - if let error { - task.webViewTask.didFailWithError(error) - } else { - task.webViewTask.didFinish() - } - tasks[taskKey] = nil - } - } -} - diff --git a/ios/Sources/GutenbergKit/Sources/Cache/EditorAssetsLibrary.swift b/ios/Sources/GutenbergKit/Sources/Cache/EditorAssetsLibrary.swift deleted file mode 100644 index e01b8880..00000000 --- a/ios/Sources/GutenbergKit/Sources/Cache/EditorAssetsLibrary.swift +++ /dev/null @@ -1,299 +0,0 @@ -import Foundation -import CryptoKit -import SwiftSoup - -public actor EditorAssetsLibrary { - enum ManifestError: Error { - case unavailable - case invalidServerResponse - } - - let urlSession: URLSession - let configuration: EditorConfiguration - let assetsDirectory: URL - - public init(configuration: EditorConfiguration) { - self.configuration = configuration - self.assetsDirectory = configuration.cachedEditorAssetsDirectory - self.urlSession = URLSession(configuration: .default) - } - - /// Returns the `EditorConfiguration.editorAssetsEndpoint` manifest content. The manifest content is cached and - /// reused on future calls. - func loadManifestContent() async throws -> Data { - let endpoint: URL - // The GutenbergKit bundle includes the required `@wordpress` modules - let excludeParam = URLQueryItem(name: "exclude", value: "core,gutenberg") - if let url = configuration.editorAssetsEndpoint { - endpoint = url.appending(queryItems: [excludeParam]) - } else if !configuration.siteApiRoot.isEmpty, let apiRoot = URL(string: configuration.siteApiRoot) { - endpoint = apiRoot - .appendingPathComponent("wpcom/v2/editor-assets") - .appending(queryItems: [excludeParam]) - } else { - throw ManifestError.unavailable - } - - var request = URLRequest(url: endpoint) - request.setValue(configuration.authHeader, forHTTPHeaderField: "Authorization") - let (data, response) = try await URLSession.shared.data(for: request) - if let status = (response as? HTTPURLResponse)?.statusCode, (200..<300).contains(status) { - return data - } else { - throw ManifestError.invalidServerResponse - } - } - - /// Returns the `EditorConfiguration.editorAssetsEndpoint` manifest content, with JavaScript and stylesheet links - /// modified so that their content can be cached and reused by the editor. - /// - /// - SeeAlso: `CachedAssetSchemeHandler` - /// - SeeAlso: `EditorAssetsLibrary.addAsset` - func manifestContentForEditor() async throws -> Data { - // For scheme-less links (i.e. '//stats.wp.com/w.js'), use the scheme in `siteURL`. - let siteURLScheme = URL(string: configuration.siteURL)?.scheme - let data = try await loadManifestContent() - let manifest = try JSONDecoder().decode(EditorAssetsMainifest.self, from: data) - return try manifest.renderForEditor(defaultScheme: siteURLScheme) - } - - /// Fetches all assets in the `EditorConfiguration.editorAssetsEndpoint` manifest and stores them on the device. - /// - /// - SeeAlso: CachedAssetSchemeHandler - public func fetchAssets() async throws { - // For scheme-less links (i.e. '//stats.wp.com/w.js'), use the scheme in `siteURL`. - let siteURLScheme = URL(string: configuration.siteURL)?.scheme - - let data = try await loadManifestContent() - let manifest = try JSONDecoder().decode(EditorAssetsMainifest.self, from: data) - let assetLinks = try manifest.parseAssetLinks(defaultScheme: siteURLScheme) - - for link in assetLinks { - guard let url = URL(string: link) else { - NSLog("Malformed asset link: \(link)") - continue - } - - guard url.scheme == "http" || url.scheme == "https" else { - NSLog("Unexpected asset link: \(link)") - continue - } - - _ = try await cacheAsset(from: url) - } - NSLog("\(assetLinks.count) resources processed.") - } - - /// Fetches one asset (JavaScript or stylesheet) and caches its content on the device. - /// - /// - Parameters: - /// - httpURL: The javascript or css URL. - /// - webViewURL: The corresponding URL requested by web view, which should the "GBK cache prefix" (`gbk-cache-https://`) - func cacheAsset(from httpURL: URL, webViewURL: URL? = nil) async throws -> (URLResponse, Data) { - // The Web Inspector automatically requests ".js.map" files, we'll support it here for debugging purpose. - let supportedResourceSuffixes = [".js", ".css", ".js.map"] - guard httpURL.scheme?.starts(with: "http") == true, - supportedResourceSuffixes.contains(where: { httpURL.lastPathComponent.hasSuffix($0) }) else { - NSLog("Attemps to cache an unsupported URL: \(httpURL)") - throw URLError(.unsupportedURL) - } - - let fileManager = FileManager.default - - let localURL = assetsDirectory.appendingPathComponent(httpURL.uniqueFilename) - - if !fileManager.fileExists(atPath: localURL.path) { - if !fileManager.fileExists(atPath: localURL.deletingLastPathComponent().path) { - try fileManager.createDirectory(at: localURL.deletingLastPathComponent(), withIntermediateDirectories: true) - } - - let (downloaded, response) = try await urlSession.download(from: httpURL) - if let status = (response as? HTTPURLResponse)?.statusCode, (200..<300).contains(status) { - try fileManager.moveItem(at: downloaded, to: localURL) - } else { - NSLog("Received an unexpected HTTP response for URL: \(httpURL)") - var cacheResponse = response - // When loading the asset for web view, we need to make sure the return URLResponse.url matches the - // asset url in the web view. - if let webViewURL { - cacheResponse = URLResponse( - url: webViewURL, - mimeType: response.mimeType, - expectedContentLength: Int(response.expectedContentLength), - textEncodingName: response.textEncodingName - ) - } - return try (cacheResponse, Data(contentsOf: downloaded)) - } - } - - let content = try Data(contentsOf: localURL) - let mimeType: String = switch httpURL.pathExtension { - case "js": "application/javascript" - case "css": "text/css" - default: "application/octet-stream" - } - let response = URLResponse(url: webViewURL ?? httpURL, mimeType: mimeType, expectedContentLength: content.count, textEncodingName: nil) - return (response, content) - } -} - -private extension EditorConfiguration { - var cachedEditorAssetsDirectory: URL { - var siteName = "shared" - - if !siteURL.isEmpty, var url = URLComponents(string: siteURL) { - url.scheme = nil - url.query = nil - url.fragment = nil - - if let dirname = url.url?.absoluteString { - let illegalChars = CharacterSet(charactersIn: "/:\\?%*|\"<>").union(.newlines).union(.controlCharacters) - siteName = dirname.trimmingCharacters(in: illegalChars).components(separatedBy: illegalChars).joined(separator: "-") - } - } - - return FileManager.default - // Use the app cache directory to prevent editor assets from being backed up to iCloud. - // Set `isExcludedFromBackup = true` if this directory is changed in the future. - .urls(for: .cachesDirectory, in:.userDomainMask) - .last! - .appendingPathComponent("editor-caches") - .appendingPathComponent(siteName) - } -} - -private extension URL { - var uniqueFilename: String { - var filename = path - - if filename.hasPrefix("/") { - filename.removeFirst() - } - - filename.removeLast(pathExtension.count) - - let hash = SHA256.hash(data: Data(absoluteString.utf8)) - .compactMap { String(format: "%02x", $0) } - .joined() - - filename += hash - - if pathExtension.isEmpty { - return filename - } else { - return filename + "." + pathExtension - } - } -} - -private extension String { - var sha256: String { - SHA256.hash(data: Data(utf8)) - .compactMap { String(format: "%02x", $0) } - .joined() - } -} - -struct EditorAssetsMainifest: Codable { - var scripts: String - var styles: String - var allowedBlockTypes: [String] - - enum CodingKeys: String, CodingKey { - case scripts - case styles - case allowedBlockTypes = "allowed_block_types" - } - - func parseAssetLinks(defaultScheme: String?) throws -> [String] { - let html = """ - - - \(scripts) - \(styles) - - - - """ - let document = try SwiftSoup.parse(html) - - var assetLinks: [String] = [] - assetLinks += try document.select("script[src]").map { - Self.resolveAssetLink(try $0.attr("src"), defaultScheme: defaultScheme) - } - assetLinks += try document.select(#"link[rel="stylesheet"][href]"#).map { - Self.resolveAssetLink(try $0.attr("href"), defaultScheme: defaultScheme) - } - return assetLinks - } - - func renderForEditor(defaultScheme: String?) throws -> Data { - var rendered = self - rendered.scripts = try Self.renderForEditor(scripts: self.scripts, defaultScheme: defaultScheme) - rendered.styles = try Self.renderForEditor(styles: self.styles, defaultScheme: defaultScheme) - return try JSONEncoder().encode(rendered) - } - - private static func renderForEditor(scripts: String, defaultScheme: String?) throws -> String { - let html = """ - - - \(scripts) - - - - """ - let document = try SwiftSoup.parse(html) - - for script in try document.select("script[src]") { - if let src = try? script.attr("src") { - let link = Self.resolveAssetLink(src, defaultScheme: defaultScheme) - #if canImport(UIKit) - let newLink = CachedAssetSchemeHandler.cachedURL(forWebLink: link) ?? link - #else - let newLink = link - #endif - try script.attr("src", newLink) - } - } - - let head = document.head()! - return try head.html() - } - - private static func renderForEditor(styles: String, defaultScheme: String?) throws -> String { - let html = """ - - - \(styles) - - - - """ - let document = try SwiftSoup.parse(html) - - for stylesheet in try document.select(#"link[rel="stylesheet"][href]"#) { - if let href = try? stylesheet.attr("href") { - let link = Self.resolveAssetLink(href, defaultScheme: defaultScheme) - #if canImport(UIKit) - let newLink = CachedAssetSchemeHandler.cachedURL(forWebLink: link) ?? link - #else - let newLink = link - #endif - try stylesheet.attr("href", newLink) - } - } - - let head = document.head()! - return try head.html() - } - - private static func resolveAssetLink(_ link: String, defaultScheme: String?) -> String { - if link.starts(with: "//") { - return "\(defaultScheme ?? "https"):\(link)" - } - - return link - } -} diff --git a/ios/Sources/GutenbergKit/Sources/EditorConfiguration.swift b/ios/Sources/GutenbergKit/Sources/EditorConfiguration.swift index 6313b31b..33b6f57a 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorConfiguration.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorConfiguration.swift @@ -33,9 +33,9 @@ public struct EditorConfiguration: Sendable { /// Enables the native inserter UI in the editor public let isNativeInserterEnabled: Bool /// Endpoint for loading editor assets, used when enabling `shouldUsePlugins` - public var editorAssetsEndpoint: URL? + public let editorAssetsEndpoint: URL? /// Logs emitted at or above this level will be printed to the debug console - public let logLevel: LogLevel + public let logLevel: EditorLogLevel /// Enables logging of all network requests/responses to the native host public let enableNetworkLogging: Bool @@ -56,8 +56,8 @@ public struct EditorConfiguration: Sendable { editorSettings: String, locale: String, isNativeInserterEnabled: Bool, - editorAssetsEndpoint: URL? = nil, - logLevel: LogLevel, + editorAssetsEndpoint: URL?, + logLevel: EditorLogLevel, enableNetworkLogging: Bool = false ) { self.title = title @@ -131,7 +131,7 @@ public struct EditorConfigurationBuilder { private var locale: String private var isNativeInserterEnabled: Bool private var editorAssetsEndpoint: URL? - private var logLevel: LogLevel + private var logLevel: EditorLogLevel private var enableNetworkLogging: Bool public init( @@ -151,7 +151,7 @@ public struct EditorConfigurationBuilder { locale: String = "en", isNativeInserterEnabled: Bool = false, editorAssetsEndpoint: URL? = nil, - logLevel: LogLevel = .error, + logLevel: EditorLogLevel = .error, enableNetworkLogging: Bool = false ){ self.title = title @@ -270,7 +270,7 @@ public struct EditorConfigurationBuilder { return copy } - public func setLogLevel(_ logLevel: LogLevel) -> EditorConfigurationBuilder { + public func setLogLevel(_ logLevel: EditorLogLevel) -> EditorConfigurationBuilder { var copy = self copy.logLevel = logLevel return copy @@ -359,4 +359,3 @@ private extension String { .replacingOccurrences(of: "\u{12}", with: "\\f") } } - diff --git a/ios/Sources/GutenbergKit/Sources/EditorDependencies.swift b/ios/Sources/GutenbergKit/Sources/EditorDependencies.swift new file mode 100644 index 00000000..80f66e3b --- /dev/null +++ b/ios/Sources/GutenbergKit/Sources/EditorDependencies.swift @@ -0,0 +1,22 @@ +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/EditorJSMessage.swift b/ios/Sources/GutenbergKit/Sources/EditorJSMessage.swift index 4d99706f..8a43f158 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorJSMessage.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorJSMessage.swift @@ -102,6 +102,6 @@ struct EditorJSMessage { struct LogMessage: Decodable { let message: String - let level: LogLevel + let level: EditorLogLevel } } diff --git a/ios/Sources/GutenbergKit/Sources/EditorLogging.swift b/ios/Sources/GutenbergKit/Sources/EditorLogging.swift new file mode 100644 index 00000000..77ff2e46 --- /dev/null +++ b/ios/Sources/GutenbergKit/Sources/EditorLogging.swift @@ -0,0 +1,45 @@ +import Foundation + +/// Protocol for logging editor-related messages. +public protocol EditorLogging: Sendable { + /// Logs a message at the specified level. + func log(_ level: EditorLogLevel, _ message: String) +} + +/// Global logger for GutenbergKit. +/// +/// - warning: The shared properties are nonisolated and should be set once +/// during the program lifetime before other editor APIs are used. +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 { + return + } + logger.log(level, message()) +} + +public enum EditorLogLevel: String, Decodable, Sendable { + case error + case warn + case info + case debug + + public static let all: EditorLogLevel = .debug + + public var priority: Int { + switch self { + case .error: return 3 + case .warn: return 2 + case .info: return 1 + case .debug: return 0 + } + } +} diff --git a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift index 99ac71b6..e5757b1e 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift @@ -9,9 +9,11 @@ import UIKit @MainActor public final class EditorViewController: UIViewController, GutenbergEditorControllerDelegate, UIAdaptivePresentationControllerDelegate, UIPopoverPresentationControllerDelegate, UISheetPresentationControllerDelegate { public let webView: WKWebView + let service: EditorService let assetsLibrary: EditorAssetsLibrary public var configuration: EditorConfiguration + private var dependencies: EditorDependencies? private var _isEditorRendered = false private var _isEditorSetup = false private let mediaPicker: MediaPickerController? @@ -42,7 +44,7 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro }() /// HTML Preview Manager instance for rendering pattern previews - private(set) lazy var htmlPreviewManager = HTMLPreviewManager(themeStyles: configuration.extractThemeStyles()) + private(set) lazy var htmlPreviewManager = HTMLPreviewManager(themeStyles: dependencies?.extractThemeStyles()) /// Initalizes the editor with the initial content (Gutenberg). public init( @@ -50,9 +52,10 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro mediaPicker: MediaPickerController? = nil, isWarmupMode: Bool = false ) { + self.service = EditorService.shared(for: configuration.siteURL) self.configuration = configuration self.mediaPicker = mediaPicker - self.assetsLibrary = EditorAssetsLibrary(configuration: configuration) + self.assetsLibrary = EditorAssetsLibrary(service: service, configuration: configuration) self.controller = GutenbergEditorController(configuration: configuration) // The `allowFileAccessFromFileURLs` allows the web view to access the @@ -67,7 +70,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(library: assetsLibrary) + let schemeHandler = CachedAssetSchemeHandler(service: service) for scheme in CachedAssetSchemeHandler.supportedURLSchemes { config.setURLSchemeHandler(schemeHandler, forURLScheme: scheme) } @@ -106,8 +109,7 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro webView.alpha = 0 if isWarmupMode { - setUpEditor() - loadEditor() + startEditorSetup() } } @@ -144,9 +146,7 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro } private func getEditorConfiguration() -> WKUserScript { - let jsCode = """ - window.GBKit = { siteURL: '\(configuration.siteURL)', siteApiRoot: '\(configuration.siteApiRoot)', @@ -157,7 +157,7 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro plugins: \(configuration.shouldUsePlugins), enableNativeBlockInserter: \(configuration.isNativeInserterEnabled), hideTitle: \(configuration.shouldHideTitle), - editorSettings: \(configuration.editorSettings), + editorSettings: \(dependencies?.editorSettings ?? "undefined"), locale: '\(configuration.locale)', post: { id: \(configuration.postID ?? -1), @@ -177,6 +177,11 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro return editorScript } + /// Deletes all cached editor data for all sites + public static func deleteAllData() throws { + try EditorService.deleteAllData() + } + // MARK: - Public API // TODO: synchronize with the editor user-generated updates @@ -248,8 +253,11 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro guard !_isEditorSetup else { return } _isEditorSetup = true - setUpEditor() - loadEditor() + Task { @MainActor in + dependencies = await service.dependencies(for: configuration, isWarmup: isWarmupMode) + setUpEditor() + loadEditor() + } } // MARK: - Internal (JavaScript) @@ -472,8 +480,8 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro hideNavigationOverlay() delegate?.editor(self, didCloseModalDialog: body.dialogType) case .log: - let log = try message.decode(EditorJSMessage.LogMessage.self) - delegate?.editor(self, didLogMessage: log.message, level: log.level) + let logMessage = try message.decode(EditorJSMessage.LogMessage.self) + log(logMessage.level, logMessage.message) case .onNetworkRequest: guard let requestDict = message.body as? [String: Any], let networkRequest = RecordedNetworkRequest(from: requestDict) else { @@ -523,7 +531,7 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro /// Calls this at any moment before showing the actual editor. The warmup /// shaves a couple of hundred milliseconds off the first load. - public static func warmup(configuration: EditorConfiguration = .default) { + public static func warmup(configuration: EditorConfiguration) { let editorViewController = EditorViewController(configuration: configuration, isWarmupMode: true) _ = editorViewController.view // Trigger viewDidLoad diff --git a/ios/Sources/GutenbergKit/Sources/EditorViewControllerDelegate.swift b/ios/Sources/GutenbergKit/Sources/EditorViewControllerDelegate.swift index 3fdd5d16..4f01be0a 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorViewControllerDelegate.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorViewControllerDelegate.swift @@ -35,9 +35,6 @@ public protocol EditorViewControllerDelegate: AnyObject { /// Notifies the client about an exception that occurred during the editor func editor(_ viewController: EditorViewController, didLogException error: GutenbergJSException) - /// Notifies the client about a log message emitted by the editor - func editor(_ viewController: EditorViewController, didLogMessage message: String, level: LogLevel) - func editor(_ viewController: EditorViewController, didRequestMediaFromSiteMediaLibrary config: OpenMediaLibraryAction) /// Notifies the client that an autocompleter was triggered. diff --git a/ios/Sources/GutenbergKit/Sources/Extensions/String+Extensions.swift b/ios/Sources/GutenbergKit/Sources/Extensions/String+Extensions.swift new file mode 100644 index 00000000..d579be30 --- /dev/null +++ b/ios/Sources/GutenbergKit/Sources/Extensions/String+Extensions.swift @@ -0,0 +1,23 @@ +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/Helpers/URLSessionProtocol.swift b/ios/Sources/GutenbergKit/Sources/Helpers/URLSessionProtocol.swift new file mode 100644 index 00000000..b934afd1 --- /dev/null +++ b/ios/Sources/GutenbergKit/Sources/Helpers/URLSessionProtocol.swift @@ -0,0 +1,9 @@ +import Foundation + +/// Protocol for network operations, allowing dependency injection for testing +public protocol URLSessionProtocol: Sendable { + func data(for request: URLRequest) async throws -> (Data, URLResponse) + func download(for request: URLRequest, delegate: (any URLSessionTaskDelegate)?) async throws -> (URL, URLResponse) +} + +extension URLSession: URLSessionProtocol {} diff --git a/ios/Sources/GutenbergKit/Sources/LogLevel.swift b/ios/Sources/GutenbergKit/Sources/LogLevel.swift deleted file mode 100644 index 123f8423..00000000 --- a/ios/Sources/GutenbergKit/Sources/LogLevel.swift +++ /dev/null @@ -1,8 +0,0 @@ -import Foundation - -public enum LogLevel: String, Decodable, Sendable { - case error - case warn - case info - case debug -} diff --git a/ios/Sources/GutenbergKit/Sources/Service/CachedAssetSchemeHandler.swift b/ios/Sources/GutenbergKit/Sources/Service/CachedAssetSchemeHandler.swift new file mode 100644 index 00000000..115a3043 --- /dev/null +++ b/ios/Sources/GutenbergKit/Sources/Service/CachedAssetSchemeHandler.swift @@ -0,0 +1,54 @@ +import Foundation +import WebKit + +class CachedAssetSchemeHandler: NSObject, WKURLSchemeHandler { + nonisolated static let cachedURLSchemePrefix = "gbk-cache-" + nonisolated static let supportedURLSchemes = ["gbk-cache-http", "gbk-cache-https"] + + nonisolated static func originalHTTPURL(from url: URL) -> URL? { + guard let scheme = url.scheme, supportedURLSchemes.contains(scheme) else { return nil } + + guard var components = URLComponents(url: url, resolvingAgainstBaseURL: true) else { + return nil + } + + components.scheme = String(scheme.suffix(from: scheme.index(scheme.startIndex, offsetBy: cachedURLSchemePrefix.count))) + return components.url + } + + nonisolated static func cachedURL(forWebLink link: String) -> String? { + if link.starts(with: "http://") || link.starts(with: "https://") { + return cachedURLSchemePrefix + link + } + return nil + } + + let service: EditorService + + init(service: EditorService) { + self.service = service + } + + func webView(_ webView: WKWebView, start urlSchemeTask: any WKURLSchemeTask) { + guard let url = urlSchemeTask.request.url else { + urlSchemeTask.didFailWithError(URLError(.badURL)) + return + } + + Task { + do { + let (response, content) = try await service.getCachedAsset(from: url) + urlSchemeTask.didReceive(response) + urlSchemeTask.didReceive(content) + urlSchemeTask.didFinish() + } catch { + urlSchemeTask.didFailWithError(error) + } + } + } + + func webView(_ webView: WKWebView, stop urlSchemeTask: any WKURLSchemeTask) { + // No-op: since we're reading from disk synchronously, there's nothing to cancel + } +} + diff --git a/ios/Sources/GutenbergKit/Sources/Service/EditorAssetsLibrary.swift b/ios/Sources/GutenbergKit/Sources/Service/EditorAssetsLibrary.swift new file mode 100644 index 00000000..29543b40 --- /dev/null +++ b/ios/Sources/GutenbergKit/Sources/Service/EditorAssetsLibrary.swift @@ -0,0 +1,21 @@ +import Foundation + +/// Wrapper around EditorService to provide manifest content for the editor +actor EditorAssetsLibrary { + let service: EditorService + let configuration: EditorConfiguration + + init(service: EditorService, configuration: EditorConfiguration) { + self.service = service + self.configuration = configuration + } + + /// Returns the editor assets manifest content as Data, ready to be sent to the web view + func manifestContentForEditor() async throws -> Data { + let jsonString = try await service.getProcessedManifest() + guard let data = jsonString.data(using: .utf8) else { + throw URLError(.cannotDecodeContentData) + } + return data + } +} diff --git a/ios/Sources/GutenbergKit/Sources/Service/EditorAssetsManifest.swift b/ios/Sources/GutenbergKit/Sources/Service/EditorAssetsManifest.swift new file mode 100644 index 00000000..2aaed48b --- /dev/null +++ b/ios/Sources/GutenbergKit/Sources/Service/EditorAssetsManifest.swift @@ -0,0 +1,106 @@ +import Foundation +import CryptoKit +import SwiftSoup + +struct EditorAssetsManifest: Codable { + var scripts: String + var styles: String + var allowedBlockTypes: [String] + + enum CodingKeys: String, CodingKey { + case scripts + case styles + case allowedBlockTypes = "allowed_block_types" + } + + func parseAssetLinks(defaultScheme: String? = nil) throws -> [String] { + let html = """ + + + \(scripts) + \(styles) + + + + """ + let document = try SwiftSoup.parse(html) + + var assetLinks: [String] = [] + assetLinks += try document.select("script[src]").map { + Self.resolveAssetLink(try $0.attr("src"), defaultScheme: defaultScheme) + } + assetLinks += try document.select(#"link[rel="stylesheet"][href]"#).map { + Self.resolveAssetLink(try $0.attr("href"), defaultScheme: defaultScheme) + } + return assetLinks + } + + func renderForEditor(defaultScheme: String?) throws -> Data { + var rendered = self + rendered.scripts = try Self.renderForEditor(scripts: self.scripts, defaultScheme: defaultScheme) + rendered.styles = try Self.renderForEditor(styles: self.styles, defaultScheme: defaultScheme) + return try JSONEncoder().encode(rendered) + } + + private static func renderForEditor(scripts: String, defaultScheme: String?) throws -> String { + let html = """ + + + \(scripts) + + + + """ + let document = try SwiftSoup.parse(html) + + for script in try document.select("script[src]") { + if let src = try? script.attr("src") { + let link = Self.resolveAssetLink(src, defaultScheme: defaultScheme) + #if canImport(UIKit) + let newLink = CachedAssetSchemeHandler.cachedURL(forWebLink: link) ?? link + #else + let newLink = link + #endif + try script.attr("src", newLink) + } + } + + let head = document.head()! + return try head.html() + } + + private static func renderForEditor(styles: String, defaultScheme: String?) throws -> String { + let html = """ + + + \(styles) + + + + """ + let document = try SwiftSoup.parse(html) + + for stylesheet in try document.select(#"link[rel="stylesheet"][href]"#) { + if let href = try? stylesheet.attr("href") { + let link = Self.resolveAssetLink(href, defaultScheme: defaultScheme) + #if canImport(UIKit) + let newLink = CachedAssetSchemeHandler.cachedURL(forWebLink: link) ?? link + #else + let newLink = link + #endif + try stylesheet.attr("href", newLink) + } + } + + let head = document.head()! + return try head.html() + } + + private static func resolveAssetLink(_ link: String, defaultScheme: String?) -> String { + if link.starts(with: "//") { + return "\(defaultScheme ?? "https"):\(link)" + } + + return link + } +} diff --git a/ios/Sources/GutenbergKit/Sources/Cache/EditorAssetsProvider.swift b/ios/Sources/GutenbergKit/Sources/Service/EditorAssetsProvider.swift similarity index 100% rename from ios/Sources/GutenbergKit/Sources/Cache/EditorAssetsProvider.swift rename to ios/Sources/GutenbergKit/Sources/Service/EditorAssetsProvider.swift diff --git a/ios/Sources/GutenbergKit/Sources/Service/EditorService.swift b/ios/Sources/GutenbergKit/Sources/Service/EditorService.swift index 49e7327b..7974f9d0 100644 --- a/ios/Sources/GutenbergKit/Sources/Service/EditorService.swift +++ b/ios/Sources/GutenbergKit/Sources/Service/EditorService.swift @@ -1,78 +1,219 @@ import Foundation +import CryptoKit +import SwiftSoup +import OSLog /// Service for fetching the editor settings and other parts of the environment /// required to launch the editor. public actor EditorService { + internal struct State: Codable { + let refreshDate: Date + } + enum EditorServiceError: Error { case invalidResponseData } - private let siteID: String - private let baseURL: URL - private let authHeader: String - private let urlSession: URLSession + @MainActor private static var instances: [String: EditorService] = [:] + + private let siteURL: String + private let networkSession: URLSessionProtocol private let storeURL: URL private var editorSettingsFileURL: URL { storeURL.appendingPathComponent("settings.json") } + private var manifestOriginalFileURL: URL { storeURL.appendingPathComponent("manifest-original.json") } + private var manifestProcessedFileURL: URL { storeURL.appendingPathComponent("manifest-processed.json") } + private var stateFileURL: URL { storeURL.appendingPathComponent("state.json") } + private var assetsDirectoryURL: URL { storeURL.appendingPathComponent("assets", isDirectory: true) } - private var refreshTask: Task? + private var refreshTask: Task? + + /// Returns the shared EditorService instance for the given siteURL + @MainActor + public static func shared(for siteURL: String) -> EditorService { + if let existing = instances[siteURL] { + return existing + } + let service = EditorService(siteURL: siteURL, networkSession: URLSession.shared) + instances[siteURL] = service + return service + } - /// Creates a new EditorService instance + /// Creates a new EditorService instance for testing /// - Parameters: - /// - siteID: Unique identifier for the site (used for caching) - /// - baseURL: Root URL for the site API - /// - authHeader: Authorization header value - /// - urlSession: URLSession to use for network requests (defaults to .shared) - public init(siteID: String, baseURL: URL, authHeader: String, urlSession: URLSession = .shared) { - self.siteID = siteID - self.baseURL = baseURL - self.authHeader = authHeader - self.urlSession = urlSession + /// - siteURL: Unique identifier for the site (used for caching) + /// - storeURL: Custom store URL for testing + /// - networkSession: Network session to use for network requests + public init(siteURL: String, storeURL: URL? = nil, networkSession: URLSessionProtocol) { + self.siteURL = siteURL + self.networkSession = networkSession + self.storeURL = storeURL ?? EditorService.rootURL + .appendingPathComponent(siteURL.sha1, isDirectory: true) - self.storeURL = URL.documentsDirectory - .appendingPathComponent("GutenbergKit", isDirectory: true) - .appendingPathComponent(siteID.safeFilename, isDirectory: true) + Task { + await scheduleAutomaticCleanup() + } } - /// Set up the editor for the given site. + + /// Schedules automatic cleanup of orphaned assets after a brief delay. /// - /// - warning: The request make take a significant amount of time the first + /// This is safe to call during initialization because: + /// - No previous editor instance can be accessing orphaned files at this point + /// - The delay ensures initialization completes before cleanup starts + /// - Any errors are caught and logged, preventing initialization failures + private func scheduleAutomaticCleanup() { + Task { + // Brief delay to allow service initialization to complete + try? await Task.sleep(for: .seconds(5)) + + do { + try await cleanupOrphanedAssets() + } catch { + log(.error, "Automatic cleanup failed: \(error)") + } + } + } + + private static var rootURL: URL { + URL.applicationSupportDirectory.appendingPathComponent("GutenbergKit", isDirectory: true) + } + + /// Returns the editor dependencies for the given configuration. + /// + /// - warning: The request may take a significant amount of time the first /// time you open the editor. - public func setup(_ configuration: inout EditorConfiguration) async throws { - var builder = configuration.toBuilder() + func dependencies(for configuration: EditorConfiguration, isWarmup: Bool = false) async -> EditorDependencies { + let startTime = CFAbsoluteTimeGetCurrent() if !isEditorLoaded { - try await refresh() + await refresh(configuration: configuration) + } else { + // Trigger a background refresh after a delay to avoid interfering with editor loading + Task { + if !isWarmup { + try? await Task.sleep(for: .seconds(7)) + } + if isRefreshNeeded() { + log(.info, "Refresh scheduled for later") + await refresh(configuration: configuration) + } else { + log(.info, "Skipping refresh – data is fresh") + } + } } + log(.info, "Prepping local dependencies") + var dependencies = EditorDependencies() if let data = try? Data(contentsOf: editorSettingsFileURL), let settings = String(data: data, encoding: .utf8) { - builder = builder.setEditorSettings(settings) + dependencies.editorSettings = settings } + let loadTime = CFAbsoluteTimeGetCurrent() - startTime + log(.info, "Loaded dependencies in \(String(format: "%.3f", loadTime))s") - return configuration = builder.build() + return dependencies } - /// Returns `true` is the resources requied for the editor already exist. + /// Returns `true` if the resources required for the editor already exist. private var isEditorLoaded: Bool { - FileManager.default.fileExists(atPath: editorSettingsFileURL.path()) + FileManager.default.fileExists(atPath: stateFileURL.path) } /// Refresh the editor resources. - public func refresh() async throws { + func refresh(configuration: EditorConfiguration) async { if let task = refreshTask { - return try await task.value + return await task.value } let task = Task { defer { refreshTask = nil } - try await actuallyRefresh() + await actuallyRefresh(configuration: configuration) } refreshTask = task - return try await task.value + return await task.value } - private func actuallyRefresh() async throws { - try await fetchEditorSettings() + private func isRefreshNeeded() -> Bool { + guard let data = try? Data(contentsOf: stateFileURL), + let state = try? JSONDecoder().decode(State.self, from: data) else { + return true + } + let timeSinceLastRefresh = Date().timeIntervalSince(state.refreshDate) + return timeSinceLastRefresh > 30 + } + + private func actuallyRefresh(configuration: EditorConfiguration) async { + let startTime = CFAbsoluteTimeGetCurrent() + log(.info, "Starting editor resources refresh") + + guard let baseURL = URL(string: configuration.siteApiRoot) else { + log(.error, "Invalid siteApiRoot URL: \(configuration.siteApiRoot)") + return + } + + // Fetch settings and manifest in parallel + async let settingsFuture = Result { + try await fetchEditorSettings(baseURL: baseURL, authHeader: configuration.authHeader) + } + async let manifestFuture = Result { + try await fetchManifestData(configuration: configuration) + } + + let (settingsResult, manifestResult) = await (settingsFuture, manifestFuture) + + let fetchTime = CFAbsoluteTimeGetCurrent() - startTime + log(.info, "Fetched settings and manifest in \(String(format: "%.2f", fetchTime))s") + + if case .failure(let error) = settingsResult { + log(.error, "Failed to fetch editor settings: \(error)") + } + + switch manifestResult { + case .success(let manifest): + // Fetch all assets for the new manifest + do { + try await fetchAssets(manifestData: manifest) + + // Only write both manifest versions to disk after all assets are successfully fetched + FileManager.default.createDirectoryIfNeeded(at: storeURL) + try saveManifest(originalData: manifest) + log(.info, "Saved manifest") + } catch { + log(.error, "Failed to fetch assets: \(error) – skipping the manifest") + } + case .failure(let error): + log(.error, "Failed to fetch manifest: \(error)") + } + + // Save state to indicate completed refresh (even if it fails) + do { + let state = State(refreshDate: Date()) + try JSONEncoder().encode(state).write(to: stateFileURL) + } catch { + log(.error, "Failed to save state: \(error)") + } + + let totalTime = CFAbsoluteTimeGetCurrent() - startTime + log(.info, "Editor refresh completed in \(String(format: "%.2f", totalTime))s") + } + + /// Set up the editor for the given site. + /// + /// - warning: The request make take a significant amount of time the first + /// time you open the editor. + public func setup(_ configuration: inout EditorConfiguration) async throws { + var builder = configuration.toBuilder() + + if !isEditorLoaded { + try await refresh(configuration: configuration) + } + + if let data = try? Data(contentsOf: editorSettingsFileURL), + let settings = String(data: data, encoding: .utf8) { + builder = builder.setEditorSettings(settings) + } + + return configuration = builder.build() } // MARK: – Editor Settings @@ -81,10 +222,10 @@ public actor EditorService { /// /// - Returns: Raw settings data from the API @discardableResult - private func fetchEditorSettings() async throws -> Data { - let data = try await getData(for: baseURL.appendingPathComponent("/wp-block-editor/v1/settings")) + private func fetchEditorSettings(baseURL: URL, authHeader: String) async throws -> Data { + let data = try await fetchData(for: baseURL.appendingPathComponent("/wp-block-editor/v1/settings"), authHeader: authHeader) do { - createStoreDirectoryIfNeeded() + FileManager.default.createDirectoryIfNeeded(at: storeURL) try data.write(to: editorSettingsFileURL) } catch { assertionFailure("Failed to save settings: \(error)") @@ -92,7 +233,233 @@ public actor EditorService { return data } - // MARK: - Private Helpers + // MARK: - Assets Manifest + + /// Fetches the editor assets manifest from the WordPress REST API + /// Does not write to disk - use this to get manifest data without persisting it + private func fetchManifestData(configuration: EditorConfiguration) async throws -> Data { + let endpoint = try configuration.editorAssetsManifestEndpoint() + let data = try await fetchData(for: endpoint, authHeader: configuration.authHeader) + return data + } + + /// Saves both original and processed manifest to disk + private func saveManifest(originalData: Data) throws { + // Save original manifest + try originalData.write(to: manifestOriginalFileURL) + + // Process and save processed manifest + let manifest = try JSONDecoder().decode(EditorAssetsManifest.self, from: originalData) + let siteURLScheme = URL(string: siteURL)?.scheme + let processedData = try manifest.renderForEditor(defaultScheme: siteURLScheme) + try processedData.write(to: manifestProcessedFileURL) + } + + /// Loads the processed manifest from disk + private func loadProcessedManifest() throws -> String { + let data = try Data(contentsOf: manifestProcessedFileURL) + guard let jsonString = String(data: data, encoding: .utf8) else { + throw URLError(.cannotDecodeContentData) + } + return jsonString + } + + /// Returns the processed manifest for use by the editor + func getProcessedManifest() throws -> String { + try loadProcessedManifest() + } + + // MARK: - Assets + + /// Removes assets that are no longer referenced in the current manifest. + /// + /// This method is safe to call at any time, but is typically called automatically + /// shortly after service initialization. At that point, no previous editor instance + /// can be referencing orphaned files, making it safe to delete them immediately. + func cleanupOrphanedAssets() async throws { + // Load current manifest to determine which assets should be retained + guard FileManager.default.fileExists(atPath: manifestOriginalFileURL.path) else { + log(.warn, "No manifest found, skipping cleanup") + return + } + + let manifestData = try Data(contentsOf: manifestOriginalFileURL) + let manifest = try JSONDecoder().decode(EditorAssetsManifest.self, from: manifestData) + let currentAssetLinks = try manifest.parseAssetLinks() + .filter { isSupportedAsset($0) } + + // Build set of expected filenames + let expectedFilenames = Set(currentAssetLinks.map { cachedFilename(for: $0) }) + + // Get all files in assets directory + guard FileManager.default.fileExists(atPath: assetsDirectoryURL.path) else { + log(.debug, "Assets directory doesn't exist, nothing to clean up") + return + } + + let filesOnDisk = try FileManager.default.contentsOfDirectory( + at: assetsDirectoryURL, + includingPropertiesForKeys: [.fileSizeKey], + options: [.skipsHiddenFiles] + ) + + // Identify and delete orphaned files + var deletedCount = 0 + var deletedSize: Int64 = 0 + + for fileURL in filesOnDisk { + let filename = fileURL.lastPathComponent + + // Skip if file is referenced in current manifest + if expectedFilenames.contains(filename) { + continue + } + + // Delete orphaned asset + try? FileManager.default.removeItem(at: fileURL) + deletedCount += 1 + deletedSize += fileURL.fileSize + } + + if deletedCount > 0 { + log(.info, "Cleaned up \(deletedCount) orphaned assets (\(deletedSize.formatted))") + } else { + log(.debug, "No orphaned assets to clean up") + } + } + + /// Fetches all assets from the manifest and stores them on the device + private func fetchAssets(manifestData: Data) async throws { + let startTime = CFAbsoluteTimeGetCurrent() + let manifest = try JSONDecoder().decode(EditorAssetsManifest.self, from: manifestData) + let assetLinks = try manifest.parseAssetLinks() + .filter { isSupportedAsset($0) } + + log(.info, "Found \(assetLinks.count) assets to fetch") + + FileManager.default.createDirectoryIfNeeded(at: assetsDirectoryURL) + + var assetURLs: [URL] = [] + var lastError: Error? + + // Fetch all assets in parallel + await withTaskGroup(of: Result.self) { group in + for link in assetLinks { + group.addTask { + await Result { try await self.fetchAsset(from: link) } + } + } + + for await result in group { + switch result { + case .success(let url): + assetURLs.append(url) + case .failure(let error): + log(.error, "Failed to fetch asset: \(error)") + lastError = error + } + } + } + + let totalTime = CFAbsoluteTimeGetCurrent() - startTime + let totalSize = assetURLs.reduce(0) { $0 + $1.fileSize } + log(.info, "Assets loaded: \(assetURLs.count) assets, \(totalSize.formatted) in \(String(format: "%.2f", totalTime))s") + + if let lastError { + throw lastError + } + } + + /// Checks if an asset URL is supported + private func isSupportedAsset(_ urlString: String) -> Bool { + guard let url = URL(string: urlString) else { + log(.warn, "Malformed asset link: \(urlString)") + return false + } + + guard url.scheme == "http" || url.scheme == "https" else { + log(.warn, "Unexpected asset link: \(urlString)") + return false + } + + let supportedResourceSuffixes = [".js", ".css", ".js.map"] + guard supportedResourceSuffixes.contains(where: { url.lastPathComponent.hasSuffix($0) }) else { + log(.warn, "Unsupported asset URL: \(url)") + return false + } + + return true + } + + /// Fetches a single asset and stores it on disk + /// - Returns: The local file URL where the asset is stored + private func fetchAsset(from urlString: String) async throws -> URL { + guard let url = URL(string: urlString) else { + throw URLError(.badURL) + } + + let localURL = assetsDirectoryURL.appendingPathComponent(cachedFilename(for: urlString)) + + if FileManager.default.fileExists(atPath: localURL.path) { + return localURL + } + + let startTime = CFAbsoluteTimeGetCurrent() + let request = URLRequest(url: url) + let (downloadedURL, response) = try await networkSession.download(for: request, delegate: nil) + let downloadTime = CFAbsoluteTimeGetCurrent() - startTime + + guard let status = (response as? HTTPURLResponse)?.statusCode, (200..<300).contains(status) else { + throw URLError(.badServerResponse) + } + + try FileManager.default.moveItem(at: downloadedURL, to: localURL) + + log(.debug, "Downloaded asset: \(url.lastPathComponent) (\(localURL.fileSize.formatted)) in \(String(format: "%.2f", downloadTime))s") + return localURL + } + + /// Loads a cached asset from disk + func getCachedAsset(from assetsURL: URL) throws -> (URLResponse, Data) { + guard let httpURL = CachedAssetSchemeHandler.originalHTTPURL(from: assetsURL) else { + throw URLError(.badURL) + } + + let localURL = assetsDirectoryURL.appendingPathComponent(cachedFilename(for: httpURL.absoluteString)) + + guard FileManager.default.fileExists(atPath: localURL.path) else { + throw URLError(.fileDoesNotExist) + } + + let content = try Data(contentsOf: localURL) + let mimeType: String = switch httpURL.pathExtension { + case "js": "application/javascript" + case "css": "text/css" + default: "application/octet-stream" + } + let response = URLResponse(url: assetsURL, mimeType: mimeType, expectedContentLength: content.count, textEncodingName: nil) + return (response, content) + } + + // MARK: - Helpers + + /// Deletes all cached editor data for all sites + static func deleteAllData() throws { + if FileManager.default.fileExists(atPath: EditorService.rootURL.path()) { + try FileManager.default.removeItem(at: EditorService.rootURL) + } + } + + /// Generates a cached filename from an asset URL using SHA256 hash + nonisolated func cachedFilename(for urlString: String) -> String { + let hash = urlString.sha1 + // Preserve file extension if present + if let url = URL(string: urlString) { + let ext = url.pathExtension + return ext.isEmpty ? hash : "\(hash).\(ext)" + } + return hash + } private func createStoreDirectoryIfNeeded() { if !FileManager.default.fileExists(atPath: storeURL.path) { @@ -100,11 +467,11 @@ public actor EditorService { } } - private func getData(for requestURL: URL) async throws -> Data { + private func fetchData(for requestURL: URL, authHeader: String) async throws -> Data { var request = URLRequest(url: requestURL) request.setValue(authHeader, forHTTPHeaderField: "Authorization") - let (data, response) = try await urlSession.data(for: request) + let (data, response) = try await networkSession.data(for: request) guard let status = (response as? HTTPURLResponse)?.statusCode, (200..<300).contains(status) else { throw URLError(.badServerResponse) @@ -112,3 +479,50 @@ public actor EditorService { return data } } + +private extension Result { + init(catching body: () async throws -> Success) async where Failure == Error { + do { + self = .success(try await body()) + } catch { + self = .failure(error) + } + } +} + +private extension URL { + var fileSize: Int64 { + (try? FileManager.default.attributesOfItem(atPath: path())[.size] as? Int64) ?? 0 + } +} + +private extension Int64 { + var formatted: String { + ByteCountFormatter.string(fromByteCount: self, countStyle: .file) + } +} + +private extension FileManager { + func createDirectoryIfNeeded(at url: URL) { + if !fileExists(atPath: url.path) { + try? createDirectory(at: url, withIntermediateDirectories: true) + } + } +} + +private extension EditorConfiguration { + /// Returns the endpoint URL for fetching the editor assets manifest + func editorAssetsManifestEndpoint() throws -> URL { + if let customEndpoint = editorAssetsEndpoint { + return customEndpoint + } + // Fall back to constructing endpoint from siteApiRoot + guard let baseURL = URL(string: siteApiRoot) else { + throw URLError(.badURL) + } + let excludeParam = URLQueryItem(name: "exclude", value: "core,gutenberg") + return baseURL + .appendingPathComponent("/wpcom/v2/editor-assets") + .appending(queryItems: [excludeParam]) + } +} diff --git a/ios/Sources/GutenbergKit/Sources/Views/HTMLPreview/HTMLPreviewManager.swift b/ios/Sources/GutenbergKit/Sources/Views/HTMLPreview/HTMLPreviewManager.swift index 1dcd0d88..efa577d6 100644 --- a/ios/Sources/GutenbergKit/Sources/Views/HTMLPreview/HTMLPreviewManager.swift +++ b/ios/Sources/GutenbergKit/Sources/Views/HTMLPreview/HTMLPreviewManager.swift @@ -61,7 +61,7 @@ public final class HTMLPreviewManager: ObservableObject { // for caching purposes. This way, if you make any changes to the template, // it will automatically invaliate the previous caches. let template = makePatternHTML(content: "", viewportWidth: 0, editorStyles: gutenbergCSS, themeStyles: themeStyles) - self.templateHash = template.sha256 + self.templateHash = template.sha1 self.urlCache = HTMLPreviewManager.makeCache() } @@ -247,7 +247,7 @@ public final class HTMLPreviewManager: ObservableObject { // MARK: - Private Helper Functions private func makeDiskCacheKey(content: String, viewportWidth: Int, templateHash: String) -> String { - "\(content)-\(viewportWidth)-\(templateHash)".sha256 + "\(content)-\(viewportWidth)-\(templateHash)".sha1 } /// Creates the HTML for rendering the pattern preview. @@ -324,11 +324,3 @@ private func encode(_ image: UIImage) -> Data? { return data as Data } -private extension String { - /// Creates a SHA256 hash of the string - var sha256: String { - let data = Data(self.utf8) - let hash = SHA256.hash(data: data) - return hash.compactMap { String(format: "%02x", $0) }.joined() - } -} diff --git a/ios/Tests/GutenbergKitTests/EditorConfigurationBuilderTests.swift b/ios/Tests/GutenbergKitTests/EditorConfigurationBuilderTests.swift index 3f12240b..43a8c8f9 100644 --- a/ios/Tests/GutenbergKitTests/EditorConfigurationBuilderTests.swift +++ b/ios/Tests/GutenbergKitTests/EditorConfigurationBuilderTests.swift @@ -20,7 +20,6 @@ struct EditorConfigurationBuilderTests { #expect(builder.siteApiNamespace == []) #expect(builder.namespaceExcludedPaths == []) #expect(builder.authHeader == "") - #expect(builder.editorSettings == "undefined") #expect(builder.locale == "en") #expect(builder.editorAssetsEndpoint == nil) #expect(builder.isNativeInserterEnabled == false) @@ -43,7 +42,6 @@ struct EditorConfigurationBuilderTests { .setSiteApiNamespace(["wp", "v2"]) .setNamespaceExcludedPaths(["jetpack"]) .setAuthHeader("Bearer Token") - .setEditorSettings(#"{"foo":"bar"}"#) .setLocale("fr") .setEditorAssetsEndpoint(URL(string: "https://example.com/wp-content/plugins/gutenberg/build/")) .setNativeInserterEnabled(true) @@ -65,7 +63,6 @@ struct EditorConfigurationBuilderTests { #expect(configuration.siteApiNamespace == ["wp", "v2"]) #expect(configuration.namespaceExcludedPaths == ["jetpack"]) #expect(configuration.authHeader == "Bearer Token") - #expect(configuration.editorSettings == #"{"foo":"bar"}"#) #expect(configuration.locale == "fr") #expect(configuration.editorAssetsEndpoint == URL(string: "https://example.com/wp-content/plugins/gutenberg/build/")) #expect(configuration.isNativeInserterEnabled == true) @@ -144,18 +141,6 @@ struct EditorConfigurationBuilderTests { #expect(EditorConfigurationBuilder().setAuthHeader("Bearer token").build().authHeader == "Bearer token") } - @Test("Sets editorSettings Correctly") - func editorConfigurationBuilderSetsEditorSettingsCorrectly() throws { - let json = #"{"foo":"bar"}"# - #expect( - EditorConfigurationBuilder() - .setEditorSettings(json) - .build() - .editorSettings - == json - ) - } - @Test("Sets locale Correctly") func editorConfigurationBuilderSetsLocaleCorrectly() throws { #expect(EditorConfigurationBuilder().setLocale("en").build().locale == "en") diff --git a/ios/Tests/GutenbergKitTests/EditorManifestTests.swift b/ios/Tests/GutenbergKitTests/EditorManifestTests.swift index 5a74014d..e127c265 100644 --- a/ios/Tests/GutenbergKitTests/EditorManifestTests.swift +++ b/ios/Tests/GutenbergKitTests/EditorManifestTests.swift @@ -8,7 +8,7 @@ struct EditorManifestTests { @Test func parseAssetLinks() throws { let json = try json(named: "manifest-test-case-1") - let manifest = try JSONDecoder().decode(EditorAssetsMainifest.self, from: json) + let manifest = try JSONDecoder().decode(EditorAssetsManifest.self, from: json) let links = try manifest.parseAssetLinks(defaultScheme: nil) let scripts = links.filter { $0.contains(".js") } @@ -21,8 +21,8 @@ struct EditorManifestTests { @Test func editorWebViewGetsCachedLinks() throws { let json = try json(named: "manifest-test-case-1") - let original = try JSONDecoder().decode(EditorAssetsMainifest.self, from: json) - let forEditor = try JSONDecoder().decode(EditorAssetsMainifest.self, from: original.renderForEditor(defaultScheme: nil)) + let original = try JSONDecoder().decode(EditorAssetsManifest.self, from: json) + let forEditor = try JSONDecoder().decode(EditorAssetsManifest.self, from: original.renderForEditor(defaultScheme: nil)) #expect(try original.parseAssetLinks(defaultScheme: nil).count == forEditor.parseAssetLinks(defaultScheme: nil).count) @@ -44,7 +44,7 @@ struct EditorManifestTests { @Test func useDefaultScheme() throws { let scriptHTML = #""# - let manifest = EditorAssetsMainifest(scripts: scriptHTML, styles: "", allowedBlockTypes: []) + 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"]) } diff --git a/ios/Tests/GutenbergKitTests/EditorServiceTests.swift b/ios/Tests/GutenbergKitTests/EditorServiceTests.swift new file mode 100644 index 00000000..9fd8e109 --- /dev/null +++ b/ios/Tests/GutenbergKitTests/EditorServiceTests.swift @@ -0,0 +1,350 @@ +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) + #expect(manifest.contains("gbk-cache-https:")) + + // 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/ios/Tests/GutenbergKitTests/Helpers/MockURLSession.swift b/ios/Tests/GutenbergKitTests/Helpers/MockURLSession.swift new file mode 100644 index 00000000..e421163e --- /dev/null +++ b/ios/Tests/GutenbergKitTests/Helpers/MockURLSession.swift @@ -0,0 +1,85 @@ +import Foundation +@testable import GutenbergKit + +/// Mock implementation of URLSessionProtocol for testing +final class MockURLSession: URLSessionProtocol, @unchecked Sendable { + private var mockedResponses: [String: (Data, HTTPURLResponse)] = [:] + private var requestCounts: [String: Int] = [:] + private let lock = NSLock() + + func mockResponse(for urlString: String, data: Data, statusCode: Int) { + let url = URL(string: urlString)! + let response = HTTPURLResponse( + url: url, + statusCode: statusCode, + httpVersion: nil, + headerFields: nil + )! + lock.withLock { + mockedResponses[urlString] = (data, response) + } + } + + func data(for request: URLRequest) async throws -> (Data, URLResponse) { + guard let url = request.url, + let urlString = getCanonicalURLString(for: url) else { + throw URLError(.badURL) + } + + lock.withLock { + requestCounts[urlString, default: 0] += 1 + } + + let result = lock.withLock { mockedResponses[urlString] } + guard let (data, response) = result else { + throw URLError(.badURL) + } + return (data, response) + } + + func download(for request: URLRequest, delegate: (any URLSessionTaskDelegate)?) async throws -> (URL, URLResponse) { + guard let url = request.url, + let urlString = getCanonicalURLString(for: url) else { + throw URLError(.badURL) + } + + lock.withLock { + requestCounts[urlString, default: 0] += 1 + } + + let result = lock.withLock { mockedResponses[urlString] } + guard let (data, response) = result else { + throw URLError(.badURL) + } + + // Write to temp file + let tempURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + try data.write(to: tempURL) + + return (tempURL, response) + } + + func requestCount(for urlSubstring: String) -> Int { + lock.withLock { + requestCounts + .filter { $0.key.contains(urlSubstring) } + .values + .reduce(0, +) + } + } + + private func getCanonicalURLString(for url: URL) -> String? { + // For URLs with query parameters, construct the canonical form + if let components = URLComponents(url: url, resolvingAgainstBaseURL: false) { + // Sort query items for consistent lookup + if var queryItems = components.queryItems { + queryItems.sort { $0.name < $1.name } + var canonicalComponents = components + canonicalComponents.queryItems = queryItems + return canonicalComponents.url?.absoluteString + } + } + return url.absoluteString + } +} diff --git a/ios/Tests/GutenbergKitTests/Resources/manifest-test-case-2.json b/ios/Tests/GutenbergKitTests/Resources/manifest-test-case-2.json new file mode 100644 index 00000000..2f65596f --- /dev/null +++ b/ios/Tests/GutenbergKitTests/Resources/manifest-test-case-2.json @@ -0,0 +1,5 @@ +{ + "styles": "\n", + "scripts": "\n\n", + "allowed_block_types": ["jetpack/contact-form", "jetpack/slideshow", "jetpack/videopress", "jetpack/markdown"] +} diff --git a/ios/Tests/GutenbergKitTests/Resources/manifest-test-case-3.json b/ios/Tests/GutenbergKitTests/Resources/manifest-test-case-3.json new file mode 100644 index 00000000..9804c929 --- /dev/null +++ b/ios/Tests/GutenbergKitTests/Resources/manifest-test-case-3.json @@ -0,0 +1,5 @@ +{ + "styles": "\n", + "scripts": "\n", + "allowed_block_types": ["jetpack/contact-form", "jetpack/videopress", "jetpack/ai-assistant"] +}