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
Original file line number Diff line number Diff line change
Expand Up @@ -281,7 +281,6 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro
@MainActor
private func loadEditor(dependencies: EditorDependencies) throws {
self.displayActivityView()
defer { self.hideActivityView() }

// Set asset bundle for the URL scheme handler to serve cached plugin/theme assets
self.bundleProvider.set(bundle: dependencies.assetBundle)
Expand Down Expand Up @@ -676,6 +675,7 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro
return
}

self.hideActivityView()
self.isReady = true

// Fade in the WebView - it was hidden (alpha = 0) since viewDidLoad()
Expand Down
18 changes: 15 additions & 3 deletions ios/Sources/GutenbergKit/Sources/Model/EditorAssetBundle.swift
Original file line number Diff line number Diff line change
Expand Up @@ -76,9 +76,17 @@ public struct EditorAssetBundle: Sendable, Equatable, Hashable {
/// Loads a bundle from a JSON file on disk.
///
/// - Parameter url: The file URL of the bundle's `manifest.json`.
/// - Throws: An error if the file cannot be read or decoded.
/// - Throws: An error if the file cannot be read or decoded, or if required files are missing.
init(url: URL) throws {
self = try EditorAssetBundle(data: Data(contentsOf: url), bundleRoot: url.deletingLastPathComponent())
let bundleRoot = url.deletingLastPathComponent()

// Validate that editor-representation.json exists (required for the bundle to be usable)
let editorRepPath = bundleRoot.appending(path: "editor-representation.json")
guard FileManager.default.fileExists(atPath: editorRepPath.path) else {
throw CocoaError(.fileNoSuchFile, userInfo: [NSFilePathErrorKey: editorRepPath.path])
}

self = try EditorAssetBundle(data: Data(contentsOf: url), bundleRoot: bundleRoot)
}

init(data: Data, bundleRoot: URL) throws {
Expand Down Expand Up @@ -163,10 +171,14 @@ public struct EditorAssetBundle: Sendable, Equatable, Hashable {
///
/// - Parameter path: The file URL where the bundle should be saved.
/// - Throws: An error if encoding fails or the file cannot be written.
func writeManifest(to path: URL? = nil) throws {
func writeManifest(to path: URL? = nil, editorRepresentation: EditorRepresentation? = nil) throws {
try FileManager.default.createDirectory(at: self.bundleRoot, withIntermediateDirectories: true)
let destination = path ?? self.bundleRoot.appendingPathComponent("manifest.json")
try self.dataRepresentation().write(to: destination, options: .atomic)

if let editorRepresentation {
try setEditorRepresentation(editorRepresentation)
}
}

/// Copies the bundle to the given directoy.
Expand Down
85 changes: 70 additions & 15 deletions ios/Sources/GutenbergKit/Sources/RESTAPIRepository.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,62 @@ public struct RESTAPIRepository: Sendable {

let apiRoot = configuration.siteApiRoot

self.editorSettingsUrl = apiRoot.appending(rawPath: Constants.API.editorSettingsPath)
self.activeThemeUrl = apiRoot.appending(rawPath: Constants.API.activeThemePath)
self.siteSettingsUrl = apiRoot.appending(rawPath: Constants.API.siteSettingsPath)
self.postTypesUrl = apiRoot.appending(rawPath: Constants.API.postTypesPath)
// Use custom endpoint if provided, otherwise build from apiRoot with namespace
if let customEndpoint = configuration.editorSettingsEndpoint {
self.editorSettingsUrl = customEndpoint
} else {
self.editorSettingsUrl = Self.buildNamespacedURL(
apiRoot: apiRoot,
path: Constants.API.editorSettingsPath,
namespace: configuration.siteApiNamespace.first
)
}

self.activeThemeUrl = Self.buildNamespacedURL(
apiRoot: apiRoot,
path: Constants.API.activeThemePath,
namespace: configuration.siteApiNamespace.first
)
self.siteSettingsUrl = Self.buildNamespacedURL(
apiRoot: apiRoot,
path: Constants.API.siteSettingsPath,
namespace: configuration.siteApiNamespace.first
)
self.postTypesUrl = Self.buildNamespacedURL(
apiRoot: apiRoot,
path: Constants.API.postTypesPath,
namespace: configuration.siteApiNamespace.first
)
}

/// Builds a URL by inserting the namespace after the version segment of the path.
/// For example: `/wp/v2/posts` with namespace `sites/123/` becomes `/wp/v2/sites/123/posts`
private static func buildNamespacedURL(apiRoot: URL, path: String, namespace: String?) -> URL {
guard let namespace = namespace else {
return apiRoot.appending(rawPath: path)
}

// Parse the path to find where to insert the namespace
// Path format is typically: /prefix/version/endpoint (e.g., /wp/v2/posts or /wp-block-editor/v1/settings)
let components = path.split(separator: "/", omittingEmptySubsequences: true)
guard components.count >= 2 else {
return apiRoot.appending(rawPath: path)
}

// Insert namespace after the version segment (second component)
// e.g., /wp-block-editor/v1/settings -> /wp-block-editor/v1/sites/123/settings
let prefix = components[0]
let version = components[1]
let remainder = components.dropFirst(2).joined(separator: "/")

let namespacedPath: String
if remainder.isEmpty {
namespacedPath = "/\(prefix)/\(version)/\(namespace)"
} else {
namespacedPath = "/\(prefix)/\(version)/\(namespace)\(remainder)"
}

return apiRoot.appending(rawPath: namespacedPath)
}

/// Clears all cached API responses.
Expand All @@ -54,12 +106,13 @@ public struct RESTAPIRepository: Sendable {
}

private func buildPostUrl(id: Int) -> URL {
configuration.siteApiRoot
.appending(path: "wp/v2/posts")
.appendingPathComponent(String(id))
.appending(queryItems: [
URLQueryItem(name: "context", value: "edit")
])
Self.buildNamespacedURL(
apiRoot: configuration.siteApiRoot,
path: "/wp/v2/posts/\(id)",
namespace: configuration.siteApiNamespace.first
).appending(queryItems: [
URLQueryItem(name: "context", value: "edit")
])
}

// MARK: Editor Settings
Expand Down Expand Up @@ -98,11 +151,13 @@ public struct RESTAPIRepository: Sendable {
}

private func buildPostTypeUrl(type: String) -> URL {
configuration.siteApiRoot.appending(path: "wp/v2/types/")
.appending(path: type)
.appending(queryItems: [
URLQueryItem(name: "context", value: "edit")
])
Self.buildNamespacedURL(
apiRoot: configuration.siteApiRoot,
path: "/wp/v2/types/\(type)",
namespace: configuration.siteApiNamespace.first
).appending(queryItems: [
URLQueryItem(name: "context", value: "edit")
])
}

// MARK: GET Active Theme
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ public actor EditorService {
/// - Returns: The complete set of dependencies needed to initialize the editor.
/// - Throws: An error if any required resource fails to download.
@discardableResult
public func prepare(progress: @escaping EditorProgressCallback) async throws -> EditorDependencies {
public func prepare(progress: EditorProgressCallback? = nil) async throws -> EditorDependencies {

if self.configuration.isOfflineModeEnabled {
return EditorDependencies(
Expand Down
39 changes: 25 additions & 14 deletions ios/Sources/GutenbergKit/Sources/Stores/EditorAssetLibrary.swift
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ public actor EditorAssetLibrary {
let remoteManifest = try RemoteEditorAssetManifest(data: data)

guard
let existingManifest = try self.existingBundle(forManifestChecksum: remoteManifest.checksum),
let existingManifest = self.existingBundle(forManifestChecksum: remoteManifest.checksum),
self.cachePolicy.allowsResponseWith(date: existingManifest.downloadDate)
else {
return try LocalEditorAssetManifest(remoteManifest: remoteManifest)
Expand All @@ -64,7 +64,7 @@ public actor EditorAssetLibrary {
.filter { $0.hasDirectoryPath } // Only include directories
.filter { $0.pathExtension != "download" } // Don't include bundles that are being downloaded
.map { $0.appending(path: "manifest.json") }
.map { try EditorAssetBundle(url: $0) }
.compactMap { try? EditorAssetBundle(url: $0) } // Skip invalid/incomplete bundles
.sorted { $0.downloadDate > $1.downloadDate }
}

Expand All @@ -81,20 +81,24 @@ public actor EditorAssetLibrary {
return try await self.buildBundle(for: manifest, progress: progress)
}

/// Checks whether a bundle with the given manifest checksum exists on disk.
/// Checks whether a complete bundle with the given manifest checksum exists on disk.
///
/// A bundle is considered complete only if both `manifest.json` and `editor-representation.json` exist.
package func hasBundle(forManifestChecksum checksum: String) -> Bool {
FileManager.default.directoryExists(at: self.bundleRoot(for: checksum))
let bundleRoot = self.bundleRoot(for: checksum)
let manifestExists = FileManager.default.fileExists(atPath: bundleRoot.appending(path: "manifest.json").path)
let editorRepExists = FileManager.default.fileExists(atPath: bundleRoot.appending(path: "editor-representation.json").path)
return manifestExists && editorRepExists
}

/// Retrieves an existing bundle from disk if one exists for the given manifest checksum.
///
package func existingBundle(forManifestChecksum checksum: String) throws -> EditorAssetBundle? {
package func existingBundle(forManifestChecksum checksum: String) -> EditorAssetBundle? {
guard self.hasBundle(forManifestChecksum: checksum) else {
return nil
}
return try EditorAssetBundle(url: self.bundleManifestPath(for: checksum))

return try? EditorAssetBundle(url: self.bundleManifestPath(for: checksum))
}

// MARK: - Individual Asset Handling
Expand Down Expand Up @@ -123,10 +127,8 @@ public actor EditorAssetLibrary {
bundleRoot: tempDirectory
)

try bundle.writeManifest()

let editorRepresentation = try manifest.buildEditorRepresentation(for: self.configuration)
try bundle.setEditorRepresentation(editorRepresentation)
try bundle.writeManifest(editorRepresentation: editorRepresentation)

try await withThrowingTaskGroup { group in
let links = (manifest.scripts + manifest.styles).filter { self.isSupportedAsset($0) }
Expand Down Expand Up @@ -184,9 +186,18 @@ public actor EditorAssetLibrary {

// MARK: - Helpers
private func editorAssetsUrl(for configuration: EditorConfiguration) -> URL {
configuration.siteApiRoot
.appending(path: "/wpcom/v2/editor-assets")
.appending(queryItems: [URLQueryItem(name: "exclude", value: "core,gutenberg")])
let baseUrl: URL
if let customEndpoint = configuration.editorAssetsEndpoint {
baseUrl = customEndpoint
} else if let namespace = configuration.siteApiNamespace.first {
// Insert namespace: /wpcom/v2/editor-assets -> /wpcom/v2/sites/123/editor-assets
baseUrl = configuration.siteApiRoot
.appending(path: "/wpcom/v2/\(namespace)editor-assets")
} else {
baseUrl = configuration.siteApiRoot
.appending(path: "/wpcom/v2/editor-assets")
}
return baseUrl.appending(queryItems: [URLQueryItem(name: "exclude", value: "core,gutenberg")])
}

/// Cleans up outdated library entries for this site.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -191,8 +191,8 @@ struct EditorAssetBundleTests {
manifest: originalBundle.manifest,
downloadDate: originalBundle.downloadDate
)
let encoded = try JSONEncoder().encode(rawBundle)
try encoded.write(to: manifestURL)
let bundleToWrite = EditorAssetBundle(raw: rawBundle, bundleRoot: tempDir)
try bundleToWrite.writeManifest(editorRepresentation: .empty)

// Initialize from URL
let loadedBundle = try EditorAssetBundle(url: manifestURL)
Expand Down Expand Up @@ -358,8 +358,8 @@ struct EditorAssetBundleTests {
manifest: originalBundle.manifest,
downloadDate: originalBundle.downloadDate
)
let encoded = try JSONEncoder().encode(rawBundle)
try encoded.write(to: tempURL)
let bundleToWrite = EditorAssetBundle(raw: rawBundle, bundleRoot: tempDir)
try bundleToWrite.writeManifest(to: tempURL, editorRepresentation: .empty)

// Read back
let loadedBundle = try EditorAssetBundle(url: tempURL)
Expand Down