diff --git a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift index f684546a2..ec08a6ab4 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift @@ -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) @@ -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() diff --git a/ios/Sources/GutenbergKit/Sources/Model/EditorAssetBundle.swift b/ios/Sources/GutenbergKit/Sources/Model/EditorAssetBundle.swift index b4d36d843..6ea00084b 100644 --- a/ios/Sources/GutenbergKit/Sources/Model/EditorAssetBundle.swift +++ b/ios/Sources/GutenbergKit/Sources/Model/EditorAssetBundle.swift @@ -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 { @@ -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. diff --git a/ios/Sources/GutenbergKit/Sources/RESTAPIRepository.swift b/ios/Sources/GutenbergKit/Sources/RESTAPIRepository.swift index a65df4d10..b3fe76acf 100644 --- a/ios/Sources/GutenbergKit/Sources/RESTAPIRepository.swift +++ b/ios/Sources/GutenbergKit/Sources/RESTAPIRepository.swift @@ -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. @@ -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 @@ -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 diff --git a/ios/Sources/GutenbergKit/Sources/Services/EditorService.swift b/ios/Sources/GutenbergKit/Sources/Services/EditorService.swift index a0ed11db7..8fd3138a6 100644 --- a/ios/Sources/GutenbergKit/Sources/Services/EditorService.swift +++ b/ios/Sources/GutenbergKit/Sources/Services/EditorService.swift @@ -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( diff --git a/ios/Sources/GutenbergKit/Sources/Stores/EditorAssetLibrary.swift b/ios/Sources/GutenbergKit/Sources/Stores/EditorAssetLibrary.swift index 5c598c8e9..b084eda59 100644 --- a/ios/Sources/GutenbergKit/Sources/Stores/EditorAssetLibrary.swift +++ b/ios/Sources/GutenbergKit/Sources/Stores/EditorAssetLibrary.swift @@ -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) @@ -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 } } @@ -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 @@ -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) } @@ -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. diff --git a/ios/Tests/GutenbergKitTests/Model/EditorAssetBundleTests.swift b/ios/Tests/GutenbergKitTests/Model/EditorAssetBundleTests.swift index c383bafff..8e553ff96 100644 --- a/ios/Tests/GutenbergKitTests/Model/EditorAssetBundleTests.swift +++ b/ios/Tests/GutenbergKitTests/Model/EditorAssetBundleTests.swift @@ -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) @@ -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)