From 1ad7e00fa437805aef5036ea303067e19aba8e3d Mon Sep 17 00:00:00 2001 From: Jeremy Massel <1123407+jkmassel@users.noreply.github.com> Date: Tue, 4 Mar 2025 19:15:58 -0700 Subject: [PATCH 1/2] Add iOS support for custom bundles --- Package.swift | 7 +- .../Gutenberg.xcodeproj/project.pbxproj | 4 + ios/Demo-iOS/Sources/ContentView.swift | 80 ++++++- ios/Demo-iOS/Sources/EditorDownloadView.swift | 65 +++++ ios/Demo-iOS/Sources/EditorView.swift | 22 +- ios/Demo-iOS/Sources/GutenbergApp.swift | 4 +- .../Sources/EditorConfiguration.swift | 8 + .../GutenbergKit/Sources/EditorLibrary.swift | 223 ++++++++++++++++++ .../Sources/EditorViewController.swift | 28 ++- .../EditorManifestTests.swift | 32 +++ .../Resources/manifest-test-case-1.json | 110 +++++++++ 11 files changed, 563 insertions(+), 20 deletions(-) create mode 100644 ios/Demo-iOS/Sources/EditorDownloadView.swift create mode 100644 ios/Sources/GutenbergKit/Sources/EditorLibrary.swift create mode 100644 ios/Tests/GutenbergKitTests/EditorManifestTests.swift create mode 100644 ios/Tests/GutenbergKitTests/Resources/manifest-test-case-1.json diff --git a/Package.swift b/Package.swift index 4a20c5d6..755800c0 100644 --- a/Package.swift +++ b/Package.swift @@ -21,7 +21,10 @@ let package = Package( name: "GutenbergKitTests", dependencies: ["GutenbergKit"], path: "ios/Tests", - exclude: [] - ) + exclude: [], + resources: [ + .copy("GutenbergKitTests/Resources/manifest-test-case-1.json") + ] + ), ] ) diff --git a/ios/Demo-iOS/Gutenberg.xcodeproj/project.pbxproj b/ios/Demo-iOS/Gutenberg.xcodeproj/project.pbxproj index 945cbdf1..afd7b3cb 100644 --- a/ios/Demo-iOS/Gutenberg.xcodeproj/project.pbxproj +++ b/ios/Demo-iOS/Gutenberg.xcodeproj/project.pbxproj @@ -13,6 +13,7 @@ 0CE8E78E2C339B0600B9DC67 /* GutenbergApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CE8E7872C339B0600B9DC67 /* GutenbergApp.swift */; }; 0CE8E78F2C339B0600B9DC67 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 0CE8E7892C339B0600B9DC67 /* Preview Assets.xcassets */; }; 0CF6E04C2BEFF60E00EDEE8A /* GutenbergKit in Frameworks */ = {isa = PBXBuildFile; productRef = 0CF6E04B2BEFF60E00EDEE8A /* GutenbergKit */; }; + 2441AFD92D77E5DA00563F80 /* EditorDownloadView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2441AFD82D77E5DA00563F80 /* EditorDownloadView.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -23,6 +24,7 @@ 0CE8E7872C339B0600B9DC67 /* GutenbergApp.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GutenbergApp.swift; sourceTree = ""; }; 0CE8E7892C339B0600B9DC67 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; 0CE8E7922C339B1B00B9DC67 /* GutenbergKit */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = GutenbergKit; path = ../..; sourceTree = ""; }; + 2441AFD82D77E5DA00563F80 /* EditorDownloadView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditorDownloadView.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -69,6 +71,7 @@ isa = PBXGroup; children = ( 0CE8E7852C339B0600B9DC67 /* ContentView.swift */, + 2441AFD82D77E5DA00563F80 /* EditorDownloadView.swift */, 0CE8E7862C339B0600B9DC67 /* EditorView.swift */, 0CE8E7872C339B0600B9DC67 /* GutenbergApp.swift */, ); @@ -164,6 +167,7 @@ buildActionMask = 2147483647; files = ( 0CE8E78E2C339B0600B9DC67 /* GutenbergApp.swift in Sources */, + 2441AFD92D77E5DA00563F80 /* EditorDownloadView.swift in Sources */, 0CE8E78C2C339B0600B9DC67 /* ContentView.swift in Sources */, 0CE8E78D2C339B0600B9DC67 /* EditorView.swift in Sources */, ); diff --git a/ios/Demo-iOS/Sources/ContentView.swift b/ios/Demo-iOS/Sources/ContentView.swift index 2f628d4c..6c1c6764 100644 --- a/ios/Demo-iOS/Sources/ContentView.swift +++ b/ios/Demo-iOS/Sources/ContentView.swift @@ -3,11 +3,89 @@ import GutenbergKit let editorURL: URL? = ProcessInfo.processInfo.environment["GUTENBERG_EDITOR_URL"].flatMap(URL.init) +@Observable +class EditorListViewModel { + + var manifests: [LocalEditorManifest] = [] + + var hasError: Bool = false + + var error: Error? = nil + + let library: EditorLibrary = EditorLibrary() + + func load() async { + do { + self.manifests = try await library.listManifests() + } catch { + self.error = error + } + } + + func remove(atOffsets offsets: IndexSet) { + Task { + do { + for offset in offsets { + let manifestToRemove = manifests[offset] + try await library.remove(manifest: manifestToRemove) + } + } catch { + self.error = error + } + + await self.load() + } + } +} + struct ContentView: View { + @State + private var viewModel = EditorListViewModel() + var body: some View { NavigationView { - EditorView(editorURL: editorURL) + List { + if let error = viewModel.error { + Text("Error fetching manifests: \(error.localizedDescription)") + } + + ForEach(viewModel.manifests) { manifest in + NavigationLink(value: manifest) { + Text(manifest.name) + } + }.onDelete(perform: deleteManifests) + } + } + .task { + await self.viewModel.load() } + .navigationDestination(for: LocalEditorManifest.self) { manifest in + EditorView( + editorManifest: manifest, + editorLibrary: viewModel.library + ) + } + .toolbar { + ToolbarItem(placement: .primaryAction) { + NavigationLink("Add Editor") { + EditorDownloadView() + } + } + } + } + + func deleteManifests(_ offsets: IndexSet) { + viewModel.remove(atOffsets: offsets) + } +} + +extension LocalEditorManifest: @retroactive Identifiable { + public var id: String { + self.rootDirectory.path + } + + var name: String { + self.rootDirectory.deletingPathExtension().lastPathComponent } } diff --git a/ios/Demo-iOS/Sources/EditorDownloadView.swift b/ios/Demo-iOS/Sources/EditorDownloadView.swift new file mode 100644 index 00000000..dab48e26 --- /dev/null +++ b/ios/Demo-iOS/Sources/EditorDownloadView.swift @@ -0,0 +1,65 @@ +import SwiftUI +import GutenbergKit + +struct EditorDownloadView: View { + + @Observable + class ViewModel { + var downloadProgress: Double = 0 + + var siteUrl: String = "http://localhost" + + var error: Error? = nil + + func download() { + Task { + + let url = URL(string: siteUrl)! + .appendingPathComponent("wp-json") + .appendingPathComponent("__experimental") + .appendingPathComponent("wp-block-editor") + .appendingPathComponent("v1") + .appendingPathComponent( "editor-assets" ) + + print("Downloading from \(url)") + + do { + self.error = nil + + try await EditorLibrary().downloadManifest(from: url) { progress in + self.downloadProgress = Double(progress.fractionCompleted) + } + } catch { + self.error = error + } + } + } + } + + @State + var viewModel = ViewModel() + + var body: some View { + Form { + if let error = viewModel.error { + Text("Error: \(error.localizedDescription)") + } + + TextField(text: $viewModel.siteUrl, prompt: Text("Site URL")) { + Text("Site URL") + } + .keyboardType(.URL) + .autocapitalization(.none) + + Button("Download") { + self.viewModel.download() + } + + ProgressView(value: viewModel.downloadProgress).progressViewStyle(.linear) + } + } +} + +#Preview { + EditorDownloadView() +} diff --git a/ios/Demo-iOS/Sources/EditorView.swift b/ios/Demo-iOS/Sources/EditorView.swift index 3fc9a8c9..5bf505a2 100644 --- a/ios/Demo-iOS/Sources/EditorView.swift +++ b/ios/Demo-iOS/Sources/EditorView.swift @@ -2,10 +2,19 @@ import SwiftUI import GutenbergKit struct EditorView: View { - var editorURL: URL? + private let editorManifest: LocalEditorManifest + private let editorLibrary: EditorLibrary + + init( + editorManifest: LocalEditorManifest? = nil, + editorLibrary: EditorLibrary = EditorLibrary() + ) { + self.editorManifest = editorManifest ?? editorLibrary.bundledManifest + self.editorLibrary = editorLibrary + } var body: some View { - _EditorView(editorURL: editorURL) + _EditorView(editorManifest: editorManifest, editorLibrary: editorLibrary) .toolbar { ToolbarItemGroup(placement: .topBarLeading) { Button(action: {}, label: { @@ -69,11 +78,12 @@ struct EditorView: View { } private struct _EditorView: UIViewControllerRepresentable { - var editorURL: URL? + let editorManifest: LocalEditorManifest + let editorLibrary: EditorLibrary func makeUIViewController(context: Context) -> EditorViewController { - let viewController = EditorViewController() - viewController.editorURL = editorURL + let viewController = EditorViewController(editorLibrary: editorLibrary) + if #available(iOS 16.4, *) { viewController.webView.isInspectable = true } @@ -87,6 +97,6 @@ private struct _EditorView: UIViewControllerRepresentable { #Preview { NavigationStack { - EditorView() + EditorView(editorLibrary: EditorLibrary()) } } diff --git a/ios/Demo-iOS/Sources/GutenbergApp.swift b/ios/Demo-iOS/Sources/GutenbergApp.swift index 9622b807..e9d2cb93 100644 --- a/ios/Demo-iOS/Sources/GutenbergApp.swift +++ b/ios/Demo-iOS/Sources/GutenbergApp.swift @@ -4,7 +4,9 @@ import SwiftUI struct GutenbergApp: App { var body: some Scene { WindowGroup { - ContentView() + NavigationStack { + ContentView() + } } } } diff --git a/ios/Sources/GutenbergKit/Sources/EditorConfiguration.swift b/ios/Sources/GutenbergKit/Sources/EditorConfiguration.swift index d42c1a8b..5fe05740 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorConfiguration.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorConfiguration.swift @@ -20,4 +20,12 @@ public struct EditorConfiguration { self.title = title self.content = content } + + var manifestURL: URL { + URL(string: siteApiRoot)! + .appendingPathComponent("__experimental") + .appendingPathComponent("wp-block-editor") + .appendingPathComponent("v1") + .appendingPathComponent( "editor-assets" ) + } } diff --git a/ios/Sources/GutenbergKit/Sources/EditorLibrary.swift b/ios/Sources/GutenbergKit/Sources/EditorLibrary.swift new file mode 100644 index 00000000..2b1df799 --- /dev/null +++ b/ios/Sources/GutenbergKit/Sources/EditorLibrary.swift @@ -0,0 +1,223 @@ +import Foundation +import RegexBuilder + +let editorCacheRoot: URL = FileManager.default + .urls(for: .applicationSupportDirectory, in:.userDomainMask) + .last! + .appendingPathComponent("editor-caches") + + +struct EditorManifest: Codable { + let styles_html: String + let scripts_html: String + + let styles: [URL] + let scripts: [URL] + let hash: String + + init(data: Data) throws { + self = try JSONDecoder().decode(EditorManifest.self, from: data) + } + + var rootDirectory: URL { + editorCacheRoot.appendingPathComponent("\(hash).editorbundle") + } + + var editorURL: URL { + rootDirectory.appendingPathComponent("index.html") + } +} + +public struct LocalEditorManifest: Hashable { + public let rootDirectory: URL + + init(_ url: URL) { + let hash = url.deletingPathExtension().lastPathComponent + self.init(hash: hash) + } + + init(rootDirectory: URL) { + self.rootDirectory = rootDirectory + } + + init(hash: String) { + self.rootDirectory = editorCacheRoot.appendingPathComponent("\(hash).editorbundle") + } + + public var editorURL: URL { + rootDirectory.appendingPathComponent("index.html") + } +} + +/// A mechanism to store editor assets on-disk to avoid downloading them every time +/// +public actor EditorLibrary { + + enum CachedAssetType { + case script + case style + } + + struct CachedAsset { + let type: CachedAssetType + let source: String + let destination: String + } + + public nonisolated var rootDirectory: URL { + editorCacheRoot + } + + public nonisolated var bundledManifest: LocalEditorManifest { + LocalEditorManifest(rootDirectory: Bundle.module.url(forResource: "Gutenberg", withExtension: nil)!) + } + + public typealias ProgressCallback = (Progress) -> Void + + private let urlsession: URLSession + + public init(urlSession: URLSession = .shared) { + self.urlsession = urlSession + } + + /// Downloads the editor asset manifest from the given URL + /// + /// Optionally takes a progress callback for updating UI + /// ```swift + /// // While the user is waiting: + /// try await EditorAssetCache().buildManifest(for: manifest) { progress in + /// print(progress.fractionCompleted) + /// } + /// + /// // In the background, prewarming the cache: + /// try await EditorAssetCache().buildManifest(for: manifest) + /// ``` + /// + /// + @discardableResult + public func downloadManifest(from url: URL, progress callback: ProgressCallback?) async throws -> LocalEditorManifest { + let (data, _) = try await urlsession.data(from: url) + let manifest = try EditorManifest(data: data) + + try await buildManifest(for: manifest, progress: callback) + + return LocalEditorManifest(hash: manifest.hash) + } + + func buildManifest(for manifest: EditorManifest, progress callback: ProgressCallback? = nil) async throws { + try FileManager.default.createDirectory(at: manifest.rootDirectory, withIntermediateDirectories: true) + + let assets = try await withThrowingTaskGroup(of: CachedAsset.self) { group in + let progress = Progress(totalUnitCount: Int64(manifest.scripts.count + manifest.styles.count)) + callback?(progress) + + var assets: [CachedAsset] = [] + + for script in manifest.scripts { + group.addTask { + let destination = manifest.rootDirectory.appendingPathComponent(script.path) + try await self.download(source: script, to: destination) + return CachedAsset(type: .script, source: script.absoluteString, destination: script.path) + } + } + + for style in manifest.styles { + group.addTask { + let destination = manifest.rootDirectory.appendingPathComponent(style.path) + try await self.download(source: style, to: destination) + return CachedAsset(type: .style, source: style.absoluteString, destination: style.path) + } + } + + for try await asset in group { + progress.completedUnitCount += 1 + callback?(progress) + assets.append(asset) + } + + return assets + } + + try buildEditorPage(assets: assets, manifest: manifest) + } + + func buildEditorPage(assets: [CachedAsset], manifest: EditorManifest) throws { + var html = +""" + + + + + + Gutenberg + [!--- Scripts ---] + [!--- Styles ---] + + +
+ + +""" + + var mutableScriptHTML = manifest.scripts_html + + for asset in assets where asset.type == .script { + mutableScriptHTML = mutableScriptHTML.replacingOccurrences( + of: asset.source, + with: "." + asset.destination + ) + } + + var mutableStyleHtml = manifest.styles_html + + for asset in assets where asset.type == .style { + mutableStyleHtml = mutableStyleHtml.replacingOccurrences( + of: asset.source, + with: "." + asset.destination + ) + } + + html = html + .replacingOccurrences(of: "[!--- Scripts ---]", with: mutableScriptHTML) + .replacingOccurrences(of: "[!--- Styles ---]", with: mutableStyleHtml) + + let indexPath = manifest.rootDirectory.appendingPathComponent("index.html").path + FileManager.default.createFile(atPath: indexPath, contents: html.data(using: .utf8)) + } + + public func listManifests() throws -> [LocalEditorManifest] { + try createRootIfNotExists() + + return try FileManager.default + .contentsOfDirectory(atPath: rootDirectory.path) + .compactMap { URL(string: $0) } + .filter { $0.pathExtension == "editorbundle" } + .map { LocalEditorManifest($0) } + [bundledManifest] + } + + public nonisolated func urlIsInsideEditorLibrary(url: URL) -> Bool { + url.pathComponents.starts(with: editorCacheRoot.pathComponents) + } + + public func remove(manifest: LocalEditorManifest) async throws { + try FileManager.default.removeItem(at: manifest.rootDirectory) + } + + private func createRootIfNotExists() throws { + try FileManager.default.createDirectory(at: editorCacheRoot, withIntermediateDirectories: true) + } + + private func download(source url: URL, to destination: URL) async throws { + let (tempUrl, _) = try await urlsession.download(from: url) + + let parentDirectory = destination.deletingLastPathComponent() + + try FileManager.default.createDirectory(at: parentDirectory, withIntermediateDirectories: true) + + guard !FileManager.default.fileExists(atPath: destination.path) else { + return + } + + try FileManager.default.moveItem(at: tempUrl, to: destination) + } +} diff --git a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift index c38908f8..5b990817 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift @@ -1,11 +1,12 @@ import UIKit -import WebKit +@preconcurrency import WebKit import SwiftUI import Combine @MainActor public final class EditorViewController: UIViewController, GutenbergEditorControllerDelegate { public let webView: WKWebView + public let editorLibrary: EditorLibrary private let configuration: EditorConfiguration private var _isEditorRendered = false @@ -17,13 +18,15 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro public weak var delegate: EditorViewControllerDelegate? /// A custom URL for the editor. - public var editorURL: URL? + public var editorManifest: LocalEditorManifest private var cancellables: [AnyCancellable] = [] /// Initalizes the editor with the initial content (Gutenberg). - public init(configuration: EditorConfiguration = .init()) { + public init(configuration: EditorConfiguration = .init(), editorLibrary: EditorLibrary = EditorLibrary()) { self.configuration = configuration + self.editorLibrary = editorLibrary + self.editorManifest = editorLibrary.bundledManifest // The `allowFileAccessFromFileURLs` allows the web view to access the // files from the local filesystem. @@ -106,15 +109,13 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro } private func loadEditor() { - if let editorURL = editorURL ?? ProcessInfo.processInfo.environment["GUTENBERG_EDITOR_URL"].flatMap(URL.init) { + // If you want to load something that's not a manifest, specify the environment variable + if let editorURL = ProcessInfo.processInfo.environment["GUTENBERG_EDITOR_URL"].flatMap(URL.init) { webView.load(URLRequest(url: editorURL)) - } else if configuration.plugins, - let editorURL = Bundle.module.url(forResource: "remote", withExtension: "html", subdirectory: "Gutenberg") { - webView.load(URLRequest(url: editorURL)) - } else { - let reactAppURL = Bundle.module.url(forResource: "index", withExtension: "html", subdirectory: "Gutenberg")! - webView.loadFileURL(reactAppURL, allowingReadAccessTo: Bundle.module.resourceURL!) + return } + + webView.loadFileURL(self.editorManifest.editorURL, allowingReadAccessTo: self.editorManifest.rootDirectory) } private func getEditorConfiguration() -> WKUserScript { @@ -323,10 +324,17 @@ private final class GutenbergEditorController: NSObject, WKNavigationDelegate, W return } + debugPrint(url) + let gutenbergEditorURL = URL(string: ProcessInfo.processInfo.environment["GUTENBERG_EDITOR_URL"] ?? "") let localIndexURL = Bundle.module.url(forResource: "index", withExtension: "html", subdirectory: "Gutenberg") let localRemoteURL = Bundle.module.url(forResource: "remote", withExtension: "html", subdirectory: "Gutenberg") + if EditorLibrary().urlIsInsideEditorLibrary(url: url) { + decisionHandler(.allow) + return + } + if url == localIndexURL || url == localRemoteURL || url.host == gutenbergEditorURL?.host { decisionHandler(.allow) } else { diff --git a/ios/Tests/GutenbergKitTests/EditorManifestTests.swift b/ios/Tests/GutenbergKitTests/EditorManifestTests.swift new file mode 100644 index 00000000..78f34177 --- /dev/null +++ b/ios/Tests/GutenbergKitTests/EditorManifestTests.swift @@ -0,0 +1,32 @@ +import Foundation +import Testing +@testable import GutenbergKit + +@Suite("Manifest Tests") +struct EditorManifestTests { + + @Test("Default Manifest Test Case") + func runTestCase1() async throws { + let json = try json(named: "manifest-test-case-1") + let manifest = try EditorManifest(data: json) + + #expect(manifest.scripts.count == 79) + #expect(manifest.styles.count == 22) + #expect(manifest.hash == "4a041c7d3cdcb9457bdf7d63c0f4d289fe087701ec2f91908c8c8fb92002fb2c") + } + + @Test("Try downloading manifest") + func runTestCase2() async throws { + let json = try json(named: "manifest-test-case-1") + let manifest = try EditorManifest(data: json) + + try await EditorLibrary().buildManifest(for: manifest) + + try await #expect(EditorLibrary().listManifests().count == 1) + } + + private func json(named name: String) throws -> Data { + let json = Bundle.module.url(forResource: name, withExtension: "json")! + return try Data(contentsOf: json) + } +} diff --git a/ios/Tests/GutenbergKitTests/Resources/manifest-test-case-1.json b/ios/Tests/GutenbergKitTests/Resources/manifest-test-case-1.json new file mode 100644 index 00000000..12f0f8be --- /dev/null +++ b/ios/Tests/GutenbergKitTests/Resources/manifest-test-case-1.json @@ -0,0 +1,110 @@ +{ + "styles_html": "\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n