From 5ef6674c71d6bc49aac60a9fcd08bc34e7282257 Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Thu, 20 Nov 2025 12:09:02 -0500 Subject: [PATCH 1/2] Add EditorService --- ios/Demo-iOS/Sources/Views/AppRootView.swift | 15 ++- .../Helpers/String+SafeDirectoryName.swift | 38 ++++++ .../Sources/Service/EditorService.swift | 114 ++++++++++++++++++ 3 files changed, 166 insertions(+), 1 deletion(-) create mode 100644 ios/Sources/GutenbergKit/Sources/Helpers/String+SafeDirectoryName.swift create mode 100644 ios/Sources/GutenbergKit/Sources/Service/EditorService.swift diff --git a/ios/Demo-iOS/Sources/Views/AppRootView.swift b/ios/Demo-iOS/Sources/Views/AppRootView.swift index 81b5eb1c..d80707b9 100644 --- a/ios/Demo-iOS/Sources/Views/AppRootView.swift +++ b/ios/Demo-iOS/Sources/Views/AppRootView.swift @@ -72,7 +72,7 @@ struct AppRootView: View { let canUsePlugins = apiRoot.hasRoute(route: "/wpcom/v2/editor-assets") let canUseEditorStyles = apiRoot.hasRoute(route: "/wp-block-editor/v1/settings") - let updatedConfiguration = EditorConfigurationBuilder() + var updatedConfiguration = EditorConfigurationBuilder() .setShouldUseThemeStyles(canUseEditorStyles) .setShouldUsePlugins(canUsePlugins) .setSiteUrl(config.siteUrl) @@ -82,6 +82,19 @@ struct AppRootView: View { .setLogLevel(.debug) .build() + if let baseURL = URL(string: config.siteApiRoot) { + let service = EditorService( + siteID: config.siteUrl, + baseURL: baseURL, + authHeader: config.authHeader + ) + do { + try await service.setup(&updatedConfiguration) + } catch { + print("Failed to setup editor environment, confinuing with the default or cached configuration:", error) + } + } + self.activeEditorConfiguration = updatedConfiguration } catch { self.hasError = true diff --git a/ios/Sources/GutenbergKit/Sources/Helpers/String+SafeDirectoryName.swift b/ios/Sources/GutenbergKit/Sources/Helpers/String+SafeDirectoryName.swift new file mode 100644 index 00000000..1faa3afb --- /dev/null +++ b/ios/Sources/GutenbergKit/Sources/Helpers/String+SafeDirectoryName.swift @@ -0,0 +1,38 @@ +import Foundation + +extension String { + /// Converts a string (such as a URL) into a safe directory name by removing illegal filesystem characters + /// + /// This method filters out characters that are not allowed in directory names across different filesystems, + /// including: `/`, `:`, `\`, `?`, `%`, `*`, `|`, `"`, `<`, `>`, newlines, and control characters. + /// + /// Example: + /// ```swift + /// let url = "https://example.com/path?query=1" + /// let safeName = url.safeFilename + /// // Result: "https---example.com-path-query-1" + /// ``` + var safeFilename: String { + // Define illegal characters for directory names + let illegalChars = CharacterSet(charactersIn: "/:\\?%*|\"<>") + .union(.newlines) + .union(.controlCharacters) + + // Remove scheme and other URL components we don't want + var cleaned = self + if var urlComponents = URLComponents(string: self) { + urlComponents.scheme = nil + urlComponents.query = nil + urlComponents.fragment = nil + if let url = urlComponents.url?.absoluteString { + cleaned = url + } + } + + // Trim and replace illegal characters with dashes + return cleaned + .trimmingCharacters(in: illegalChars) + .components(separatedBy: illegalChars) + .joined(separator: "-") + } +} diff --git a/ios/Sources/GutenbergKit/Sources/Service/EditorService.swift b/ios/Sources/GutenbergKit/Sources/Service/EditorService.swift new file mode 100644 index 00000000..bcd9dd41 --- /dev/null +++ b/ios/Sources/GutenbergKit/Sources/Service/EditorService.swift @@ -0,0 +1,114 @@ +import Foundation + +/// Service for fetching the editor settings and other parts of the enrvironment +/// required to launch the editor. +public actor EditorService { + enum EditorServiceError: Error { + case invalidResponseData + } + + private let siteID: String + private let baseURL: URL + private let authHeader: String + private let urlSession: URLSession + + private let storeURL: URL + private var editorSettingsFileURL: URL { storeURL.appendingPathComponent("settings.json") } + + private var refreshTask: Task? + + /// Creates a new EditorService instance + /// - 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 + + self.storeURL = URL.documentsDirectory + .appendingPathComponent("GutenbergKit", isDirectory: true) + .appendingPathComponent(siteID.safeFilename, isDirectory: true) + } + + /// 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() + } + + if let data = try? Data(contentsOf: editorSettingsFileURL), + let settings = String(data: data, encoding: .utf8) { + builder = builder.setEditorSettings(settings) + } + + return configuration = builder.build() + } + + /// Returns `true` is the resources requied for the editor already exist. + private var isEditorLoaded: Bool { + FileManager.default.fileExists(atPath: editorSettingsFileURL.path()) + } + + /// Refresh the editor resources. + public func refresh() async throws { + if let task = refreshTask { + return try await task.value + } + let task = Task { + defer { refreshTask = nil } + try await actuallyRefresh() + } + refreshTask = task + return try await task.value + } + + private func actuallyRefresh() async throws { + try await fetchEditorSettings() + } + + // MARK: – Editor Settings + + /// Fetches block editor settings from the WordPress REST API + /// + /// - 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")) + do { + createStoreDirectoryIfNeeded() + try data.write(to: editorSettingsFileURL) + } catch { + assertionFailure("Failed to save settings: \(error)") + } + return data + } + + // MARK: - Private Helpers + + private func createStoreDirectoryIfNeeded() { + if !FileManager.default.fileExists(atPath: storeURL.path) { + try? FileManager.default.createDirectory(at: storeURL, withIntermediateDirectories: true) + } + } + + private func getData(for requestURL: URL) async throws -> Data { + var request = URLRequest(url: requestURL) + request.setValue(authHeader, forHTTPHeaderField: "Authorization") + + let (data, response) = try await urlSession.data(for: request) + guard let status = (response as? HTTPURLResponse)?.statusCode, + (200..<300).contains(status) else { + throw URLError(.badServerResponse) + } + return data + } +} From 36a05490debd04bea6963eebb98d7915dbc5df34 Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Fri, 21 Nov 2025 06:49:31 -0500 Subject: [PATCH 2/2] Update ios/Sources/GutenbergKit/Sources/Service/EditorService.swift Co-authored-by: David Calhoun --- ios/Sources/GutenbergKit/Sources/Service/EditorService.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ios/Sources/GutenbergKit/Sources/Service/EditorService.swift b/ios/Sources/GutenbergKit/Sources/Service/EditorService.swift index bcd9dd41..49e7327b 100644 --- a/ios/Sources/GutenbergKit/Sources/Service/EditorService.swift +++ b/ios/Sources/GutenbergKit/Sources/Service/EditorService.swift @@ -1,6 +1,6 @@ import Foundation -/// Service for fetching the editor settings and other parts of the enrvironment +/// Service for fetching the editor settings and other parts of the environment /// required to launch the editor. public actor EditorService { enum EditorServiceError: Error {