Skip to content

Commit f17b76b

Browse files
authored
Add Uploader API for external file uploads (#1568)
* Handle reply even if diff merging fails Signed-off-by: Carson Katri <Carson.katri@gmail.com> * Mark more fields `@_spi(LiveForm)` Signed-off-by: Carson Katri <Carson.katri@gmail.com> * Add Uploader API Signed-off-by: Carson Katri <Carson.katri@gmail.com> * Add uploaders config option Signed-off-by: Carson Katri <Carson.katri@gmail.com> * Depends on core RC 5 --------- Signed-off-by: Carson Katri <Carson.katri@gmail.com>
1 parent 5bbdee0 commit f17b76b

File tree

5 files changed

+190
-18
lines changed

5 files changed

+190
-18
lines changed

Package.resolved

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Package.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ let package = Package(
2525
dependencies: [
2626
// Dependencies declare other packages that this package depends on.
2727
.package(url: "https://github.com/apple/swift-async-algorithms", from: "1.0.0"),
28-
.package(url: "https://github.com/liveview-native/liveview-native-core", exact: "0.4.1-rc-3"),
28+
.package(url: "https://github.com/liveview-native/liveview-native-core", exact: "0.4.1-rc-5"),
2929

3030
.package(url: "https://github.com/apple/swift-argument-parser", from: "1.5.0"),
3131

Sources/LiveViewNative/Coordinators/LiveSessionConfiguration.swift

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ public struct LiveSessionConfiguration {
3636

3737
public var eventConfirmation: ((String, ElementNode) async -> Bool)?
3838

39+
public var uploaders: [String:any Uploader] = [:]
40+
3941
/// Constructs a default, empty configuration.
4042
public init() {
4143
}
@@ -46,14 +48,16 @@ public struct LiveSessionConfiguration {
4648
urlSessionConfiguration: URLSessionConfiguration = .default,
4749
transition: AnyTransition? = nil,
4850
reconnectBehavior: ReconnectBehavior = .exponential,
49-
eventConfirmation: ((String, ElementNode) async -> Bool)? = nil
51+
eventConfirmation: ((String, ElementNode) async -> Bool)? = nil,
52+
uploaders: [String:any Uploader] = [:]
5053
) {
5154
self.headers = headers
5255
self.connectParams = connectParams
5356
self.urlSessionConfiguration = urlSessionConfiguration
5457
self.transition = transition
5558
self.reconnectBehavior = reconnectBehavior
5659
self.eventConfirmation = eventConfirmation
60+
self.uploaders = uploaders
5761
}
5862

5963
public struct ReconnectBehavior: Sendable {

Sources/LiveViewNative/Coordinators/LiveViewCoordinator.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -223,7 +223,7 @@ public class LiveViewCoordinator<R: RootRegistry>: ObservableObject {
223223
switch json {
224224
case let .object(object):
225225
if case let .object(diff) = object["diff"] {
226-
try self.handleDiff(payload: .object(object: diff), baseURL: self.url)
226+
try? self.handleDiff(payload: .object(object: diff), baseURL: self.url)
227227
if case let .object(reply) = diff["r"] {
228228
return reply
229229
}

Sources/LiveViewNative/ViewModel.swift

Lines changed: 181 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ public class FormModel: ObservableObject, CustomDebugStringConvertible {
7474
public struct FileUpload: Identifiable {
7575
public let id: String
7676
public let data: Data
77+
public let ref: Int
7778
let upload: () async throws -> ()
7879
}
7980

@@ -267,13 +268,13 @@ public class FormModel: ObservableObject, CustomDebugStringConvertible {
267268
)
268269
}
269270

270-
public func queueFileUpload(
271+
public func queueFileUpload<R: RootRegistry>(
271272
name: String,
272273
id: String,
273274
contents: Data,
274275
fileType: UTType,
275276
fileName: String,
276-
coordinator: LiveViewCoordinator<some RootRegistry>
277+
coordinator: LiveViewCoordinator<R>
277278
) async throws {
278279
guard let liveChannel = coordinator.liveChannel
279280
else { return }
@@ -285,6 +286,19 @@ public class FormModel: ObservableObject, CustomDebugStringConvertible {
285286
"",
286287
id
287288
)
289+
290+
let ref = coordinator.nextUploadRef()
291+
292+
let fileMetadata = Json.object(object: [
293+
"path": .str(string: name),
294+
"ref": .str(string: "\(ref)"),
295+
"last_modified": .numb(number: .posInt(pos: UInt64(Date().timeIntervalSince1970 * 1000))), // in milliseconds
296+
"name": .str(string: fileName),
297+
"relative_path": .str(string: ""),
298+
"type": .str(string: fileType.preferredMIMEType!),
299+
"size": .numb(number: .posInt(pos: UInt64(contents.count)))
300+
])
301+
288302
if let changeEventName {
289303
let replyPayload = try await coordinator.liveChannel!.channel().call(
290304
event: .user(user: "event"),
@@ -294,28 +308,103 @@ public class FormModel: ObservableObject, CustomDebugStringConvertible {
294308
"value": .str(string: "_target=\(name)"),
295309
"uploads": .object(object: [
296310
id: .array(array: [
297-
.object(object: [
298-
"path": .str(string: fileName),
299-
"ref": .str(string: String(coordinator.nextUploadRef())),
300-
"last_modified": .numb(number: .posInt(pos: UInt64(Date().timeIntervalSince1970 * 1000))), // in milliseconds
301-
"name": .str(string: fileName),
302-
"relative_path": .str(string: ""),
303-
"type": .str(string: fileType.preferredMIMEType!),
304-
"size": .numb(number: .posInt(pos: UInt64(contents.count)))
305-
])
311+
fileMetadata
306312
])
307313
])
308314
])),
309315
timeout: 10_000
310316
)
311317
try await coordinator.handleEventReplyPayload(replyPayload)
312318
}
313-
self.fileUploads.append(.init(
319+
self.fileUploads.append(FileUpload(
314320
id: id,
315321
data: contents,
316-
upload: { try await liveChannel.uploadFile(file) }
322+
ref: ref,
323+
upload: {
324+
do {
325+
let entries = Json.array(array: [
326+
fileMetadata
327+
])
328+
329+
let payload = LiveViewNativeCore.Payload.jsonPayload(json: .object(object: [
330+
"ref": .str(string: id),
331+
"entries": entries,
332+
]))
333+
334+
print("sending preflight request \(ref)")
335+
336+
let response = try await coordinator.liveChannel!.channel().call(
337+
event: .user(user: "allow_upload"),
338+
payload: payload,
339+
timeout: 10_000
340+
)
341+
342+
try await coordinator.handleEventReplyPayload(response)
343+
344+
print("got preflight response \(response)")
345+
346+
// LiveUploader.initAdapterUpload
347+
// UploadEntry.uploader
348+
// utils.channelUploader
349+
// EntryUploader
350+
let reply = switch response {
351+
case let .jsonPayload(json: json):
352+
json
353+
default:
354+
fatalError()
355+
}
356+
print(reply)
357+
358+
let allowUploadReply = try JsonDecoder().decode(AllowUploadReply.self, from: reply)
359+
360+
let entry: Json = switch reply {
361+
case let .object(object: object):
362+
switch object["entries"] {
363+
case let .object(object: object):
364+
object["\(ref)"]!
365+
default:
366+
fatalError()
367+
}
368+
default:
369+
fatalError()
370+
}
371+
372+
373+
let uploadEntry = UploadEntry<R>(data: contents, ref: allowUploadReply.ref, entryRef: ref, meta: entry, config: allowUploadReply.config, coordinator: coordinator)
374+
switch entry {
375+
case let .object(object: meta):
376+
switch meta["uploader"]! {
377+
case let .str(string: uploader):
378+
try await coordinator.session.configuration.uploaders[uploader]!.upload(uploadEntry, for: coordinator)
379+
default:
380+
fatalError()
381+
}
382+
case let .str(string: uploadToken):
383+
try await UploadEntry<R>.ChannelUploader().upload(uploadEntry, for: coordinator)
384+
default:
385+
fatalError()
386+
}
387+
388+
print("done")
389+
} catch {
390+
fatalError(error.localizedDescription)
391+
}
392+
}
317393
))
318394
}
395+
396+
public struct UploadConfig: Codable {
397+
public let chunk_size: Int
398+
public let max_entries: Int
399+
public let chunk_timeout: Int
400+
public let max_file_size: Int
401+
}
402+
403+
fileprivate struct AllowUploadReply: Codable {
404+
let ref: String
405+
let config: UploadConfig
406+
// let entries: [String:String]
407+
}
319408
}
320409

321410
private extension URLComponents {
@@ -330,3 +419,82 @@ private extension URLComponents {
330419
return components.query!
331420
}
332421
}
422+
423+
public final class UploadEntry<R: RootRegistry> {
424+
public let data: Data
425+
public let ref: String
426+
public let entryRef: Int
427+
public let meta: Json
428+
public let config: FormModel.UploadConfig
429+
private weak var coordinator: LiveViewCoordinator<R>?
430+
431+
init(data: Data, ref: String, entryRef: Int, meta: Json, config: FormModel.UploadConfig, coordinator: LiveViewCoordinator<R>) {
432+
self.data = data
433+
self.ref = ref
434+
self.entryRef = entryRef
435+
self.meta = meta
436+
self.config = config
437+
self.coordinator = coordinator
438+
}
439+
440+
@MainActor
441+
public func progress(_ progress: Int) async throws {
442+
let progressReply = try await coordinator!.liveChannel!.channel().call(
443+
event: .user(user: "progress"),
444+
payload: .jsonPayload(json: .object(object: [
445+
"event": .null,
446+
"ref": .str(string: ref),
447+
"entry_ref": .str(string: "\(entryRef)"),
448+
"progress": .numb(number: .posInt(pos: UInt64(progress))),
449+
])),
450+
timeout: 10_000
451+
)
452+
print(progressReply)
453+
_ = try await coordinator!.handleEventReplyPayload(progressReply)
454+
}
455+
456+
@MainActor
457+
public func error(_ error: some Error) async throws {
458+
459+
}
460+
461+
@MainActor
462+
public func pause() async throws {
463+
464+
}
465+
466+
public struct ChannelUploader: Uploader {
467+
public init() {}
468+
469+
public func upload<Root: RootRegistry>(
470+
_ entry: UploadEntry<Root>,
471+
for coordinator: LiveViewCoordinator<Root>
472+
) async throws {
473+
let uploadChannel = try await coordinator.session.liveSocket!.socket().channel(topic: .fromString(topic: "lvu:\(entry.entryRef)"), payload: .jsonPayload(json: .object(object: [
474+
"token": entry.meta
475+
])))
476+
_ = try await uploadChannel.join(timeout: 10_000)
477+
478+
let stream = InputStream(data: entry.data)
479+
var buf = [UInt8](repeating: 0, count: entry.config.chunk_size)
480+
stream.open()
481+
var amountRead = 0
482+
while case let amount = stream.read(&buf, maxLength: entry.config.chunk_size), amount > 0 {
483+
let resp = try await uploadChannel.call(event: .user(user: "chunk"), payload: .binary(bytes: Data(buf[..<amount])), timeout: 10_000)
484+
print("uploaded chunk: \(resp)")
485+
amountRead += amount
486+
487+
try await entry.progress(Int((Double(amountRead) / Double(entry.data.count)) * 100))
488+
}
489+
stream.close()
490+
491+
print("finished uploading chunks")
492+
try await entry.progress(100)
493+
}
494+
}
495+
}
496+
497+
public protocol Uploader {
498+
@MainActor
499+
func upload<R: RootRegistry>(_ entry: UploadEntry<R>, for coordinator: LiveViewCoordinator<R>) async throws
500+
}

0 commit comments

Comments
 (0)