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..91534b1c 100644 --- a/ios/Demo-iOS/Sources/ContentView.swift +++ b/ios/Demo-iOS/Sources/ContentView.swift @@ -3,11 +3,96 @@ 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 + + func load() async { + do { + self.manifests = try await EditorLibrary.shared.listManifests() + } catch { + self.error = error + } + } + + func remove(atOffsets offsets: IndexSet) { + Task { + do { + for offset in offsets { + let manifestToRemove = manifests[offset] + try await EditorLibrary.shared.remove(manifest: manifestToRemove) + } + } catch { + self.error = error + } + + await self.load() + } + } +} + struct ContentView: View { + @State + private var viewModel = EditorListViewModel() + + let bundledManifest = EditorLibrary.shared.bundledManifest + var body: some View { NavigationView { - EditorView(editorURL: editorURL) + List { + if let error = viewModel.error { + Text("Error fetching manifests: \(error.localizedDescription)") + } + + Section { + NavigationLink(value: bundledManifest) { + Text("Bundled Gutenberg") + } + } + + if !viewModel.manifests.isEmpty { + Section("Downloaded Bundles") { + 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) + } + .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..dae60aa4 --- /dev/null +++ b/ios/Demo-iOS/Sources/EditorDownloadView.swift @@ -0,0 +1,67 @@ +import SwiftUI +import GutenbergKit + +struct EditorDownloadView: View { + + class ViewModel: ObservableObject { + @Published + var downloadProgress: Double = 0 + + @Published + var siteUrl: String = "http://localhost" + + @Published + 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.shared.downloadManifest(from: url) { progress in + self.downloadProgress = Double(progress.fractionCompleted) + } + } catch { + self.error = error + } + } + } + } + + @StateObject + 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..7606364b 100644 --- a/ios/Demo-iOS/Sources/EditorView.swift +++ b/ios/Demo-iOS/Sources/EditorView.swift @@ -2,10 +2,14 @@ import SwiftUI import GutenbergKit struct EditorView: View { - var editorURL: URL? + private let editorManifest: LocalEditorManifest + + init(editorManifest: LocalEditorManifest = EditorLibrary.shared.bundledManifest) { + self.editorManifest = editorManifest + } var body: some View { - _EditorView(editorURL: editorURL) + _EditorView(editorManifest: editorManifest) .toolbar { ToolbarItemGroup(placement: .topBarLeading) { Button(action: {}, label: { @@ -69,11 +73,14 @@ struct EditorView: View { } private struct _EditorView: UIViewControllerRepresentable { - var editorURL: URL? + let editorManifest: LocalEditorManifest func makeUIViewController(context: Context) -> EditorViewController { - let viewController = EditorViewController() - viewController.editorURL = editorURL + let viewController = EditorViewController( + manifest: editorManifest, + editorLibrary: EditorLibrary.shared + ) + if #available(iOS 16.4, *) { viewController.webView.isInspectable = true } diff --git a/ios/Demo-iOS/Sources/GutenbergApp.swift b/ios/Demo-iOS/Sources/GutenbergApp.swift index 9622b807..0e0069d9 100644 --- a/ios/Demo-iOS/Sources/GutenbergApp.swift +++ b/ios/Demo-iOS/Sources/GutenbergApp.swift @@ -1,10 +1,18 @@ import SwiftUI +import GutenbergKit @main struct GutenbergApp: App { var body: some Scene { WindowGroup { - ContentView() + NavigationStack { + ContentView() + } } } } + +// We don't really care about dependency injection for our demo app, so we'll just make a bunch of singletons +extension EditorLibrary { + static let shared = EditorLibrary() +} 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..d0f23cdc --- /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 bundledManifest: LocalEditorManifest { + LocalEditorManifest(rootDirectory: Bundle.module.url(forResource: "Gutenberg", withExtension: nil)!) + } + + public nonisolated var rootDirectory: URL { + editorCacheRoot + } + + 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) } + } + + 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..3553b334 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(), manifest: LocalEditorManifest, editorLibrary: EditorLibrary = EditorLibrary()) { self.configuration = configuration + self.editorLibrary = editorLibrary + self.editorManifest = manifest // The `allowFileAccessFromFileURLs` allows the web view to access the // files from the local filesystem. @@ -106,15 +109,16 @@ 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 } + + print("Loading editor from \(editorManifest.editorURL.path)") + print("Giving access to \(editorManifest.rootDirectory.path)") + + webView.loadFileURL(self.editorManifest.editorURL, allowingReadAccessTo: self.editorManifest.rootDirectory) } private func getEditorConfiguration() -> WKUserScript { @@ -284,7 +288,7 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro /// Calls this at any moment before showing the actual editor. The warmup /// shaves a couple of hundred milliseconds off the first load. public static func warmup() { - let editorViewController = EditorViewController() + let editorViewController = EditorViewController(manifest: EditorLibrary().bundledManifest) _ = editorViewController.view // Trigger viewDidLoad // Retain for 5 seconds and let it prefetch stuff @@ -323,10 +327,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 { @@ -340,8 +351,10 @@ private final class GutenbergEditorController: NSObject, WKNavigationDelegate, W func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { guard let message = EditorJSMessage(message: message) else { - return NSLog("Unsupported message: \(message.body)") + print("Unsupported message: \(message.body)") + return } + MainActor.assumeIsolated { delegate?.controller(self, didReceiveMessage: message) } 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