Skip to content

Commit 4fbade0

Browse files
committed
Add inital PhotosKit integration
1 parent 5b29d50 commit 4fbade0

File tree

4 files changed

+225
-0
lines changed

4 files changed

+225
-0
lines changed
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import Foundation
2+
import PhotosUI
3+
import SwiftUI
4+
import UniformTypeIdentifiers
5+
6+
/// Manages media files for the editor, handling imports, storage, and cleanup.
7+
///
8+
/// Files are stored in the Library/GutenbergKit/Uploads directory and served via
9+
/// a custom `gbk-media-file://` URL scheme. Old files are automatically cleaned up.
10+
actor MediaFileManager {
11+
/// Shared instance for app-wide media management
12+
static let shared = MediaFileManager()
13+
14+
private let fileManager = FileManager.default
15+
private let rootURL: URL
16+
private let uploadsDirectory: URL
17+
18+
init(rootURL: URL = URL.libraryDirectory.appendingPathComponent("GutenbergKit")) {
19+
self.rootURL = rootURL
20+
self.uploadsDirectory = self.rootURL.appendingPathComponent("Uploads")
21+
Task {
22+
await cleanupOldFiles()
23+
}
24+
}
25+
26+
/// Imports a photo picker item and saves it to the uploads directory.
27+
///
28+
/// - Returns: MediaInfo with a `gbk-media-file://` URL and detected media type
29+
func `import`(_ item: PhotosPickerItem) async throws -> MediaInfo {
30+
guard let data = try await item.loadTransferable(type: Data.self) else {
31+
throw URLError(.unknown)
32+
}
33+
let contentType = item.supportedContentTypes.first
34+
let fileExtension = contentType?.preferredFilenameExtension ?? "jpg"
35+
36+
let fileURL = try await writeData(data, withExtension: fileExtension)
37+
return MediaInfo(url: fileURL.absoluteString, type: contentType?.mediaType)
38+
}
39+
40+
/// Saves media data to the uploads directory and returns a URL with a
41+
/// custom scheme.
42+
func writeData(_ data: Data, withExtension ext: String) async throws -> URL {
43+
let fileName = "\(UUID().uuidString).\(ext)"
44+
let destinationURL = uploadsDirectory.appendingPathComponent(fileName)
45+
46+
try fileManager.createDirectory(at: uploadsDirectory, withIntermediateDirectories: true)
47+
try data.write(to: destinationURL)
48+
49+
return URL(string: "\(MediaFileSchemeHandler.scheme):///Uploads/\(fileName)")!
50+
}
51+
52+
/// Gets URLResponse and data for a `gbk-media-file` URL
53+
func getData(for url: URL) async throws -> Data {
54+
// Convert `gbk-media-file:///Uploads/filename.jpg` to actual file path
55+
let fileURL = rootURL.appendingPathComponent(url.path)
56+
return try Data(contentsOf: fileURL)
57+
}
58+
59+
/// Cleans up files older than 2 days
60+
private func cleanupOldFiles() {
61+
let sevenDaysAgo = Date().addingTimeInterval(-2 * 24 * 60 * 60)
62+
63+
do {
64+
let contents = try fileManager.contentsOfDirectory(
65+
at: uploadsDirectory,
66+
includingPropertiesForKeys: [.creationDateKey],
67+
options: .skipsHiddenFiles
68+
)
69+
70+
for fileURL in contents {
71+
if let attributes = try? fileManager.attributesOfItem(atPath: fileURL.path),
72+
let creationDate = attributes[.creationDate] as? Date,
73+
creationDate < sevenDaysAgo {
74+
try? fileManager.removeItem(at: fileURL)
75+
}
76+
}
77+
} catch {
78+
#if DEBUG
79+
print("Failed to clean up old files: \(error)")
80+
#endif
81+
}
82+
}
83+
}
84+
85+
private extension UTType {
86+
var mediaType: String? {
87+
if conforms(to: .image) {
88+
return "image"
89+
} else if conforms(to: .movie) {
90+
return "video"
91+
} else if conforms(to: .audio) {
92+
return "audio"
93+
} else {
94+
return "file"
95+
}
96+
}
97+
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import Foundation
2+
import WebKit
3+
4+
/// Handles `gbk-media-file://` URL scheme requests in WKWebView.
5+
/// Serves media files from MediaFileManager with appropriate CORS headers.
6+
final class MediaFileSchemeHandler: NSObject, WKURLSchemeHandler {
7+
/// The custom URL scheme handled by this class
8+
nonisolated static let scheme = "gbk-media-file"
9+
10+
func webView(_ webView: WKWebView, start urlSchemeTask: WKURLSchemeTask) {
11+
guard let url = urlSchemeTask.request.url else {
12+
urlSchemeTask.didFailWithError(URLError(.badURL))
13+
return
14+
}
15+
Task {
16+
do {
17+
let (response, data) = try await getResponse(for: url)
18+
urlSchemeTask.didReceive(response)
19+
urlSchemeTask.didReceive(data)
20+
urlSchemeTask.didFinish()
21+
} catch {
22+
urlSchemeTask.didFailWithError(error)
23+
}
24+
}
25+
}
26+
27+
private func getResponse(for url: URL) async throws -> (URLResponse, Data) {
28+
let data = try await MediaFileManager.shared.getData(for: url)
29+
30+
let headers = [
31+
"Access-Control-Allow-Origin": "*",
32+
"Access-Control-Allow-Methods": "GET, HEAD, OPTIONS",
33+
"Access-Control-Allow-Headers": "*",
34+
"Cache-Control": "no-cache"
35+
]
36+
37+
guard let response = HTTPURLResponse(
38+
url: url,
39+
statusCode: 200,
40+
httpVersion: "HTTP/1.1",
41+
headerFields: headers
42+
) else {
43+
throw URLError(.unknown)
44+
}
45+
46+
return (response, data)
47+
}
48+
49+
func webView(_ webView: WKWebView, stop urlSchemeTask: WKURLSchemeTask) {
50+
// Nothing to do here for simple file serving
51+
}
52+
}

ios/Sources/GutenbergKit/Sources/Views/BlockInserter/BlockInserterView.swift

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ struct BlockInserterView: View {
1111
@StateObject private var viewModel: BlockInserterViewModel
1212
@StateObject private var iconCache = BlockIconCache()
1313

14+
@State private var selectedMediaItems: [PhotosPickerItem] = []
15+
1416
private let maxSelectionCount = 10
1517

1618
@Environment(\.dismiss) private var dismiss
@@ -35,10 +37,17 @@ struct BlockInserterView: View {
3537
.background(Material.ultraThin)
3638
.searchable(text: $viewModel.searchText)
3739
.navigationBarTitleDisplayMode(.inline)
40+
.disabled(viewModel.isProcessingMedia)
41+
.animation(.smooth(duration: 2), value: viewModel.isProcessingMedia)
3842
.environmentObject(iconCache)
3943
.toolbar {
4044
toolbar
4145
}
46+
.onDisappear {
47+
if viewModel.isProcessingMedia {
48+
viewModel.cancelProcessing()
49+
}
50+
}
4251
}
4352

4453
private var content: some View {
@@ -68,6 +77,14 @@ struct BlockInserterView: View {
6877
}
6978

7079
ToolbarItemGroup(placement: .topBarTrailing) {
80+
PhotosPicker(selection: $selectedMediaItems) {
81+
Image(systemName: "photo.on.rectangle.angled")
82+
}
83+
.onChange(of: selectedMediaItems) { _, selection in
84+
insertMedia(selection)
85+
selectedMediaItems = []
86+
}
87+
7188
if let mediaPicker {
7289
MediaPickerMenu(picker: mediaPicker, context: presentationContext) {
7390
dismiss()
@@ -83,6 +100,15 @@ struct BlockInserterView: View {
83100
dismiss()
84101
onBlockSelected(block)
85102
}
103+
104+
private func insertMedia(_ items: [PhotosPickerItem]) {
105+
Task {
106+
await viewModel.processMediaItems(items) { mediaInfo in
107+
dismiss()
108+
onMediaSelected(mediaInfo)
109+
}
110+
}
111+
}
86112
}
87113

88114
// MARK: - Preview

ios/Sources/GutenbergKit/Sources/Views/BlockInserter/BlockInserterViewModel.swift

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,21 @@ import Combine
55
@MainActor
66
class BlockInserterViewModel: ObservableObject {
77
@Published var searchText = ""
8+
@Published var error: MediaError?
89
@Published private(set) var sections: [BlockInserterSection] = []
10+
@Published private(set) var isProcessingMedia = false
911

1012
private let blocks: [EditorBlock]
1113
private let allSections: [BlockInserterSection]
14+
private let fileManager: MediaFileManager = .shared
15+
private var processingTask: Task<Void, Never>?
1216
private var cancellables = Set<AnyCancellable>()
1317

18+
struct MediaError: Identifiable {
19+
let id = UUID()
20+
let message: String
21+
}
22+
1423
init(blocks: [EditorBlock]) {
1524
let blocks = blocks.filter { $0.name != "core/missing" }
1625

@@ -74,6 +83,47 @@ class BlockInserterViewModel: ObservableObject {
7483

7584
return sections
7685
}
86+
87+
// MARK: - Media Processing
88+
89+
func processMediaItems(_ items: [PhotosPickerItem], completion: @escaping ([MediaInfo]) -> Void) async {
90+
isProcessingMedia = true
91+
defer { isProcessingMedia = false }
92+
93+
processingTask = Task { @MainActor in
94+
var results: [MediaInfo] = []
95+
var anyError: Error?
96+
await withTaskGroup(of: Void.self) { group in
97+
for item in items {
98+
group.addTask {
99+
do {
100+
let item = try await self.fileManager.import(item)
101+
results.append(item)
102+
} catch {
103+
anyError = error
104+
}
105+
}
106+
}
107+
}
108+
109+
guard !Task.isCancelled else { return }
110+
111+
if results.isEmpty {
112+
// TODO: fix error handling
113+
self.error = MediaError(message: anyError?.localizedDescription ?? "")
114+
}
115+
116+
completion(results)
117+
}
118+
119+
await processingTask?.value
120+
}
121+
122+
func cancelProcessing() {
123+
processingTask?.cancel()
124+
processingTask = nil
125+
isProcessingMedia = false
126+
}
77127
}
78128

79129
// MARK: Ordering

0 commit comments

Comments
 (0)