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
16 changes: 14 additions & 2 deletions ios/Sources/GutenbergKit/Sources/EditorViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro
for scheme in CachedAssetSchemeHandler.supportedURLSchemes {
config.setURLSchemeHandler(schemeHandler, forURLScheme: scheme)
}
config.setURLSchemeHandler(MediaFileSchemeHandler(), forURLScheme: MediaFileSchemeHandler.scheme)
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This is the same approach we use for the assets. It's currently the only way to let WKWebView access the files on disk, but I hope we can simplify it in the future by extracting the bundled editor on disk and pointing WKWebView to that directly and not the readonly bundle.


self.webView = GBWebView(frame: .zero, configuration: config)
self.webView.scrollView.keyboardDismissMode = .interactive
Expand Down Expand Up @@ -263,8 +264,8 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro
onBlockSelected: { [weak self] block in
self?.insertBlockFromInserter(block.id)
},
onMediaSelected: {
print("insert media:", $0)
onMediaSelected: { [weak self] selection in
self?.insertMediaFromInserter(selection)
}
)
})
Expand All @@ -278,6 +279,17 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro
evaluate("window.blockInserter.insertBlock('\(blockID)')")
}

private func insertMediaFromInserter(_ selection: [MediaInfo]) {
guard !selection.isEmpty else { return }

guard let data = try? JSONEncoder().encode(selection),
let string = String(data: data, encoding: .utf8) else {
debugPrint("Failed to serialize media array to JSON")
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This should never happen.

return
}
evaluate("window.blockInserter.insertMedia(\(string))")
}

private func openMediaLibrary(_ config: OpenMediaLibraryAction) {
delegate?.editor(self, didRequestMediaFromSiteMediaLibrary: config)
}
Expand Down
83 changes: 83 additions & 0 deletions ios/Sources/GutenbergKit/Sources/Media/MediaFileManager.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import Foundation
import PhotosUI
import SwiftUI
import UniformTypeIdentifiers

/// Manages media files for the editor, handling imports, storage, and cleanup.
///
/// Files are stored in the Library/GutenbergKit/Uploads directory and served via
/// a custom `gbk-media-file://` URL scheme. Old files are automatically cleaned up.
actor MediaFileManager {
/// Shared instance for app-wide media management
static let shared = MediaFileManager()

private let fileManager = FileManager.default
private let rootURL: URL
private let uploadsDirectory: URL

init(rootURL: URL = URL.libraryDirectory.appendingPathComponent("GutenbergKit")) {
self.rootURL = rootURL
self.uploadsDirectory = self.rootURL.appendingPathComponent("Uploads")
Task {
await cleanupOldFiles()
}
}

/// Imports a photo picker item and saves it to the uploads directory.
///
/// - Returns: MediaInfo with a `gbk-media-file://` URL and detected media type
func `import`(_ item: PhotosPickerItem) async throws -> MediaInfo {
guard let data = try await item.loadTransferable(type: Data.self) else {
throw URLError(.unknown)
}
let contentType = item.supportedContentTypes.first
let fileExtension = contentType?.preferredFilenameExtension ?? "jpeg"

let fileURL = try await writeData(data, withExtension: fileExtension)
return MediaInfo(url: fileURL.absoluteString, type: contentType?.preferredMIMEType)
}

/// Saves media data to the uploads directory and returns a URL with a
/// custom scheme.
func writeData(_ data: Data, withExtension ext: String) async throws -> URL {
let fileName = "\(UUID().uuidString).\(ext)"
let destinationURL = uploadsDirectory.appendingPathComponent(fileName)

try fileManager.createDirectory(at: uploadsDirectory, withIntermediateDirectories: true)
try data.write(to: destinationURL)

return URL(string: "\(MediaFileSchemeHandler.scheme):///Uploads/\(fileName)")!
}

/// Gets URLResponse and data for a `gbk-media-file` URL
func getData(for url: URL) async throws -> Data {
// Convert `gbk-media-file:///Uploads/filename.jpg` to actual file path
let fileURL = rootURL.appendingPathComponent(url.path)
return try Data(contentsOf: fileURL)
}

/// Cleans up files older than 2 days
private func cleanupOldFiles() {
let sevenDaysAgo = Date().addingTimeInterval(-2 * 24 * 60 * 60)

do {
let contents = try fileManager.contentsOfDirectory(
at: uploadsDirectory,
includingPropertiesForKeys: [.creationDateKey],
options: .skipsHiddenFiles
)

for fileURL in contents {
if let attributes = try? fileManager.attributesOfItem(atPath: fileURL.path),
let creationDate = attributes[.creationDate] as? Date,
creationDate < sevenDaysAgo {
try? fileManager.removeItem(at: fileURL)
}
}
} catch {
#if DEBUG
print("Failed to clean up old files: \(error)")
#endif
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import Foundation
import WebKit

/// Handles `gbk-media-file://` URL scheme requests in WKWebView.
/// Serves media files from MediaFileManager with appropriate CORS headers.
final class MediaFileSchemeHandler: NSObject, WKURLSchemeHandler {
/// The custom URL scheme handled by this class
nonisolated static let scheme = "gbk-media-file"

func webView(_ webView: WKWebView, start urlSchemeTask: WKURLSchemeTask) {
guard let url = urlSchemeTask.request.url else {
urlSchemeTask.didFailWithError(URLError(.badURL))
return
}
Task {
do {
let (response, data) = try await getResponse(for: url)
urlSchemeTask.didReceive(response)
urlSchemeTask.didReceive(data)
urlSchemeTask.didFinish()
} catch {
urlSchemeTask.didFailWithError(error)
}
}
}

private func getResponse(for url: URL) async throws -> (URLResponse, Data) {
let data = try await MediaFileManager.shared.getData(for: url)

let headers = [
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, HEAD, OPTIONS",
"Access-Control-Allow-Headers": "*",
"Cache-Control": "no-cache"
]

guard let response = HTTPURLResponse(
url: url,
statusCode: 200,
httpVersion: "HTTP/1.1",
headerFields: headers
) else {
throw URLError(.unknown)
}

return (response, data)
}

func webView(_ webView: WKWebView, stop urlSchemeTask: WKURLSchemeTask) {
// Nothing to do here for simple file serving
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ struct BlockInserterView: View {
@StateObject private var viewModel: BlockInserterViewModel
@StateObject private var iconCache = BlockIconCache()

@State private var selectedMediaItems: [PhotosPickerItem] = []

private let maxSelectionCount = 10

@Environment(\.dismiss) private var dismiss
Expand All @@ -38,10 +40,17 @@ struct BlockInserterView: View {
.background(Material.ultraThin)
.searchable(text: $viewModel.searchText)
.navigationBarTitleDisplayMode(.inline)
.disabled(viewModel.isProcessingMedia)
.animation(.smooth(duration: 2), value: viewModel.isProcessingMedia)
.environmentObject(iconCache)
.toolbar {
toolbar
}
.onDisappear {
if viewModel.isProcessingMedia {
viewModel.cancelProcessing()
}
}
}

private var content: some View {
Expand Down Expand Up @@ -71,6 +80,16 @@ struct BlockInserterView: View {
}

ToolbarItemGroup(placement: .topBarTrailing) {
PhotosPicker(selection: $selectedMediaItems, preferredItemEncoding: .compatible) {
Image(systemName: "photo.on.rectangle.angled")
}
.onChange(of: selectedMediaItems) { _, selection in
if !selection.isEmpty {
insertMedia(selection)
}
selectedMediaItems = []
}

if let mediaPicker {
MediaPickerMenu(picker: mediaPicker, context: presentationContext) {
dismiss()
Expand All @@ -86,6 +105,16 @@ struct BlockInserterView: View {
dismiss()
onBlockSelected(block)
}

private func insertMedia(_ items: [PhotosPickerItem]) {
Task {
let items = await viewModel.processSelectedPhotosPickerItems(items)
if !items.isEmpty {
dismiss()
onMediaSelected(items)
}
}
}
}

// MARK: - Preview
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,20 @@ import Combine
@MainActor
class BlockInserterViewModel: ObservableObject {
@Published var searchText = ""
@Published var error: MediaError?
@Published private(set) var sections: [BlockInserterSection] = []
@Published private(set) var isProcessingMedia = false

private let allSections: [BlockInserterSection]
private let fileManager: MediaFileManager = .shared
private var processingTask: Task<[MediaInfo], Never>?
private var cancellables = Set<AnyCancellable>()

struct MediaError: Identifiable {
let id = UUID()
let message: String
}

init(sections: [BlockInserterSection]) {
self.allSections = sections
self.sections = sections
Expand Down Expand Up @@ -41,4 +50,47 @@ class BlockInserterViewModel: ObservableObject {
}
}
}

// MARK: - Media Processing

func processSelectedPhotosPickerItems(_ items: [PhotosPickerItem]) async -> [MediaInfo] {
isProcessingMedia = true
defer { isProcessingMedia = false }

let task = Task<[MediaInfo], Never> { @MainActor in
var results: [MediaInfo] = []
var anyError: Error?
await withTaskGroup(of: Void.self) { group in
for item in items {
group.addTask {
do {
let item = try await self.fileManager.import(item)
results.append(item)
} catch {
anyError = error
}
}
}
}

guard !Task.isCancelled else {
return []
}

if results.isEmpty {
// TODO: CMM-874 add localization
self.error = MediaError(message: anyError?.localizedDescription ?? "Failed to insert media")
}

return results
}
processingTask = task
return await task.value
}

func cancelProcessing() {
processingTask?.cancel()
processingTask = nil
isProcessingMedia = false
}
}
Loading