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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion ios/Demo-iOS/Sources/Views/AppRootView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
print("Failed to setup editor environment, confinuing with the default or cached configuration:", error)
print("Failed to set up editor environment, continuing with the default or cached configuration:", error)

}
}

self.activeEditorConfiguration = updatedConfiguration
} catch {
self.hasError = true
Expand Down
Original file line number Diff line number Diff line change
@@ -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 {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could just replace it with sha256 as the folder name is only needed for technical purposes. wdyt?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, hashing is likely sufficient for this purpose.

A readable name might make debugging easier, but that'd likely be infrequent and manageable.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was also leaning towards hashing. It's simpler and more robust.

// 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: "-")
}
}
114 changes: 114 additions & 0 deletions ios/Sources/GutenbergKit/Sources/Service/EditorService.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import Foundation

/// Service for fetching the editor settings and other parts of the environment
/// 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<Void, Error>?

/// 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
Copy link
Contributor Author

@kean kean Nov 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should go into "Application" not "Documents".

.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
}
}