Some checks failed
Build & Test / Analyze (javascript, blocksuite) (push) Failing after 3s
Build & Test / Rust test filter (push) Failing after 3s
Build & Test / E2E BlockSuite Test (2) (push) Failing after 3s
Build & Test / E2E BlockSuite Test (3) (push) Failing after 4s
Build & Test / E2E BlockSuite Test (4) (push) Failing after 3s
Build & Test / E2E BlockSuite Cross Browser Test (push) Failing after 3s
Build & Test / E2E Mobile Test (1) (push) Failing after 3s
Build & Test / E2E Mobile Test (2) (push) Failing after 3s
Build & Test / Build AFFiNE native (x86_64-unknown-linux-gnu) (push) Failing after 4s
Build & Test / Unit Test (1) (push) Has been skipped
Build & Test / Unit Test (2) (push) Has been skipped
Build & Test / Unit Test (3) (push) Has been skipped
Build & Test / Build Server native (push) Failing after 3s
Build & Test / Check Git Status (push) Has been skipped
Build & Test / Server Test (0, 4) (push) Has been skipped
Build & Test / Server Test (1, 4) (push) Has been skipped
Build & Test / Analyze (javascript, affine) (push) Failing after 3s
Build & Test / Analyze (typescript, affine) (push) Failing after 3s
Build & Test / Analyze (typescript, blocksuite) (push) Failing after 3s
Build & Test / Mobile native build filter (push) Failing after 3s
Build & Test / Check yarn binary (push) Failing after 3s
Build & Test / E2E BlockSuite Test (1) (push) Failing after 4s
Build & Test / E2E BlockSuite Test (5) (push) Failing after 3s
Build & Test / Unit Test (4) (push) Has been skipped
Build & Test / Unit Test (5) (push) Has been skipped
Build & Test / Native Unit Test (push) Has been skipped
Build & Test / Server Test (2, 4) (push) Has been skipped
Build & Test / Server Test (3, 4) (push) Has been skipped
Build & Test / Server Test with Elasticsearch (push) Has been skipped
Build & Test / Server Test with Embedded Search (push) Has been skipped
Build & Test / Server Test with Manticore Search (push) Has been skipped
Build & Test / Server E2E Test (push) Has been skipped
Build & Test / Cloud Desktop E2E Test (push) Has been skipped
Build & Test / Cloud E2E Test 1/2 (push) Has been skipped
Build & Test / Cloud E2E Test 2/2 (push) Has been skipped
Build & Test / Build @affine/electron renderer (push) Failing after 3s
Build & Test / Copilot test filter (push) Failing after 4s
Build & Test / Lint Rust (push) Has been skipped
Build & Test / Build Android app (push) Has been skipped
Build & Test / Test Android app (push) Has been skipped
Build & Test / Run native tests (push) Has been skipped
Build & Test / Server Copilot Api Test (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (3, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (4, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (1, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (2, 5) (push) Has been skipped
Build & Test / Frontend Copilot E2E Test (5, 5) (push) Has been skipped
Build & Test / Desktop Test (ubuntu-latest, linux, x64, x86_64-unknown-linux-gnu, true) (push) Has been cancelled
Build & Test / Desktop Test (windows-latest, windows, x64, x86_64-pc-windows-msvc, true) (push) Has been cancelled
Build & Test / 3, 2, 1 Launch (push) Has been cancelled
Build & Test / Lint (push) Has been cancelled
Build & Test / Typecheck (push) Has been cancelled
Build & Test / Build iOS app (push) Has been cancelled
Build & Test / Build AFFiNE native (aarch64-apple-darwin) (push) Has been cancelled
Build & Test / E2E Test (1) (push) Has been cancelled
Build & Test / E2E Test (2) (push) Has been cancelled
Build & Test / E2E Test (3) (push) Has been cancelled
Build & Test / E2E Test (4) (push) Has been cancelled
Build & Test / E2E Test (5) (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-apple-darwin) (push) Has been cancelled
Build & Test / Build AFFiNE native (aarch64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Build AFFiNE native (x86_64-pc-windows-msvc) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, arm64, aarch64-apple-darwin, true) (push) Has been cancelled
Build & Test / Desktop Test (macos-latest, macos, x64, x86_64-apple-darwin, false) (push) Has been cancelled
481 lines
16 KiB
Swift
481 lines
16 KiB
Swift
//
|
|
// ShareInboxStore.swift
|
|
// Shared between AFFiNE and ShareExtension
|
|
//
|
|
|
|
import Foundation
|
|
|
|
final class ShareInboxStore {
|
|
typealias DataWriter = (Data, URL, Data.WritingOptions) throws -> Void
|
|
typealias AttachmentFileCopy = (URL, URL) throws -> Void
|
|
typealias ItemRemover = (URL) throws -> Void
|
|
|
|
static let shared = ShareInboxStore()
|
|
|
|
private let fileManager: FileManager
|
|
private let configuredContainerURL: URL?
|
|
private let writeData: DataWriter
|
|
private let copyFile: AttachmentFileCopy
|
|
private let removeItem: ItemRemover
|
|
private let encoder: JSONEncoder = {
|
|
let encoder = JSONEncoder()
|
|
encoder.dateEncodingStrategy = .iso8601
|
|
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
|
return encoder
|
|
}()
|
|
|
|
private let decoder: JSONDecoder = {
|
|
let decoder = JSONDecoder()
|
|
decoder.dateDecodingStrategy = .iso8601
|
|
return decoder
|
|
}()
|
|
|
|
init(
|
|
fileManager: FileManager = .default,
|
|
containerURL: URL? = nil,
|
|
writeData: DataWriter? = nil,
|
|
copyFile: AttachmentFileCopy? = nil,
|
|
removeItem: ItemRemover? = nil
|
|
) {
|
|
self.fileManager = fileManager
|
|
configuredContainerURL = containerURL
|
|
self.writeData = writeData ?? Self.writeAtomically
|
|
self.copyFile = copyFile ?? ShareInboxFileCopy.copyCoordinatedFile
|
|
self.removeItem = removeItem ?? { try fileManager.removeItem(at: $0) }
|
|
}
|
|
|
|
var containerURL: URL? {
|
|
configuredContainerURL
|
|
?? fileManager.containerURL(forSecurityApplicationGroupIdentifier: ShareInboxConstants.appGroupId)
|
|
}
|
|
|
|
private var inboxDirectoryURL: URL? {
|
|
guard let containerURL else { return nil }
|
|
return containerURL
|
|
.appendingPathComponent(ShareInboxConstants.inboxDirectoryName, isDirectory: true)
|
|
}
|
|
|
|
private var attachmentsDirectoryURL: URL? {
|
|
guard let inboxDirectoryURL else { return nil }
|
|
return inboxDirectoryURL
|
|
.appendingPathComponent(ShareInboxConstants.attachmentsDirectoryName, isDirectory: true)
|
|
}
|
|
|
|
private var invalidDirectoryURL: URL? {
|
|
guard let inboxDirectoryURL else { return nil }
|
|
return inboxDirectoryURL
|
|
.appendingPathComponent(ShareInboxConstants.invalidDirectoryName, isDirectory: true)
|
|
}
|
|
|
|
@discardableResult
|
|
func ensureDirectories() -> Bool {
|
|
guard let inboxDirectoryURL, let attachmentsDirectoryURL, let invalidDirectoryURL else {
|
|
return false
|
|
}
|
|
do {
|
|
try fileManager.createDirectory(at: inboxDirectoryURL, withIntermediateDirectories: true)
|
|
try fileManager.createDirectory(at: attachmentsDirectoryURL, withIntermediateDirectories: true)
|
|
try fileManager.createDirectory(at: invalidDirectoryURL, withIntermediateDirectories: true)
|
|
removeStaleAttachmentDirectories()
|
|
return true
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
func enqueue(
|
|
_ item: ShareInboxItem,
|
|
attachmentFiles: [(ShareInboxAttachment, URL)] = []
|
|
) throws {
|
|
guard ensureDirectories() else {
|
|
throw ShareInboxError.containerUnavailable
|
|
}
|
|
guard let fileURL = manifestURL(for: item.id) else {
|
|
throw ShareInboxError.invalidPayload
|
|
}
|
|
try ensureManifestCanMutate(at: fileURL)
|
|
|
|
guard attachmentFiles.count == item.attachments.count else {
|
|
throw ShareInboxError.invalidPayload
|
|
}
|
|
let attachmentDirectory = attachmentsDirectoryURL?.appendingPathComponent(item.id, isDirectory: true)
|
|
let temporaryAttachmentDirectory = attachmentsDirectoryURL?
|
|
.appendingPathComponent(".\(item.id).tmp", isDirectory: true)
|
|
if !attachmentFiles.isEmpty,
|
|
attachmentDirectory == nil || temporaryAttachmentDirectory == nil
|
|
{
|
|
throw ShareInboxError.containerUnavailable
|
|
}
|
|
var publishedAttachmentDirectory: URL?
|
|
do {
|
|
if !attachmentFiles.isEmpty,
|
|
let attachmentDirectory,
|
|
let temporaryAttachmentDirectory
|
|
{
|
|
guard !fileManager.fileExists(atPath: attachmentDirectory.path),
|
|
!fileManager.fileExists(atPath: temporaryAttachmentDirectory.path)
|
|
else {
|
|
throw ShareInboxError.invalidPayload
|
|
}
|
|
try fileManager.createDirectory(at: temporaryAttachmentDirectory, withIntermediateDirectories: false)
|
|
for (index, entry) in attachmentFiles.enumerated() {
|
|
let (attachment, sourceURL) = entry
|
|
guard attachment == item.attachments[index],
|
|
isValidAttachment(attachment, for: item),
|
|
fileManager.fileExists(atPath: sourceURL.path)
|
|
else {
|
|
throw ShareInboxError.invalidPayload
|
|
}
|
|
let destination = temporaryAttachmentDirectory.appendingPathComponent(attachment.fileName)
|
|
try copyFile(sourceURL, destination)
|
|
}
|
|
try fileManager.moveItem(at: temporaryAttachmentDirectory, to: attachmentDirectory)
|
|
publishedAttachmentDirectory = attachmentDirectory
|
|
}
|
|
|
|
let data = try encoder.encode(item)
|
|
try writeData(data, fileURL, .atomic)
|
|
} catch {
|
|
if let temporaryAttachmentDirectory {
|
|
try? fileManager.removeItem(at: temporaryAttachmentDirectory)
|
|
}
|
|
if let publishedAttachmentDirectory {
|
|
try? fileManager.removeItem(at: publishedAttachmentDirectory)
|
|
}
|
|
throw error
|
|
}
|
|
}
|
|
|
|
private func isValidAttachment(_ attachment: ShareInboxAttachment, for item: ShareInboxItem) -> Bool {
|
|
guard !attachment.fileName.isEmpty,
|
|
attachment.fileName == (attachment.fileName as NSString).lastPathComponent,
|
|
attachment.fileName != ".",
|
|
attachment.fileName != "..",
|
|
attachment.relativePath == "\(item.id)/\(attachment.fileName)",
|
|
attachmentURL(for: attachment) != nil
|
|
else {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func update(_ item: ShareInboxItem) throws {
|
|
guard ensureDirectories(), let fileURL = manifestURL(for: item.id) else {
|
|
throw ShareInboxError.containerUnavailable
|
|
}
|
|
try ensureManifestCanMutate(at: fileURL)
|
|
try writeData(encoder.encode(item), fileURL, .atomic)
|
|
}
|
|
|
|
func updateWorkspaceMode(_ mode: ShareWorkspaceMode) throws {
|
|
guard let containerURL else { throw ShareInboxError.containerUnavailable }
|
|
try writeData(encoder.encode(ShareWorkspaceModeSnapshot(mode: mode)),
|
|
containerURL.appendingPathComponent(ShareInboxConstants.workspaceModeFileName), .atomic)
|
|
}
|
|
|
|
func workspaceMode() -> ShareWorkspaceMode {
|
|
guard let containerURL,
|
|
let data = try? Data(contentsOf: containerURL.appendingPathComponent(ShareInboxConstants.workspaceModeFileName)),
|
|
let snapshot = try? decoder.decode(ShareWorkspaceModeSnapshot.self, from: data),
|
|
snapshot.version == 1
|
|
else { return .unknown }
|
|
return snapshot.mode
|
|
}
|
|
|
|
func pendingItems() -> [ShareInboxPendingEntry] {
|
|
readPendingItems().compactMap { entry in
|
|
guard case let .ready(item) = entry, item.result != nil else {
|
|
return entry
|
|
}
|
|
try? remove(item)
|
|
return nil
|
|
}
|
|
}
|
|
|
|
private func readPendingItems() -> [ShareInboxPendingEntry] {
|
|
guard ensureDirectories(), let inboxDirectoryURL else { return [] }
|
|
guard let urls = try? fileManager.contentsOfDirectory(
|
|
at: inboxDirectoryURL,
|
|
includingPropertiesForKeys: [.contentModificationDateKey],
|
|
options: [.skipsHiddenFiles]
|
|
) else {
|
|
return []
|
|
}
|
|
|
|
return urls
|
|
.filter { $0.pathExtension.lowercased() == "json" }
|
|
.compactMap { url -> ShareInboxPendingEntry? in
|
|
guard let data = try? Data(contentsOf: url) else {
|
|
quarantine(url)
|
|
return nil
|
|
}
|
|
guard let schemaVersion = ShareInboxSafety.manifestSchemaVersion(from: data) else {
|
|
quarantine(url)
|
|
return nil
|
|
}
|
|
if schemaVersion > ShareInboxItem.currentSchemaVersion {
|
|
return .unsupportedVersion(
|
|
itemId: url.deletingPathExtension().lastPathComponent,
|
|
schemaVersion: schemaVersion
|
|
)
|
|
}
|
|
guard ShareInboxSafety.normalizedManifestID(url.deletingPathExtension().lastPathComponent) != nil
|
|
else {
|
|
quarantine(url)
|
|
return nil
|
|
}
|
|
guard var item = try? decoder.decode(ShareInboxItem.self, from: data),
|
|
let expectedURL = manifestURL(for: item.id),
|
|
expectedURL.lastPathComponent.caseInsensitiveCompare(url.lastPathComponent) == .orderedSame
|
|
else {
|
|
quarantine(url)
|
|
return nil
|
|
}
|
|
if item.schemaVersion < ShareInboxItem.currentSchemaVersion {
|
|
item.schemaVersion = ShareInboxItem.currentSchemaVersion
|
|
guard (try? update(item)) != nil else { return nil }
|
|
}
|
|
return .ready(item)
|
|
}
|
|
.sorted { $0.createdAt < $1.createdAt }
|
|
}
|
|
|
|
func complete(itemId: String, docId: String, committedAt: Date) throws {
|
|
guard !docId.isEmpty,
|
|
let item = readPendingItems().compactMap({ entry -> ShareInboxItem? in
|
|
guard case let .ready(item) = entry else { return nil }
|
|
return item
|
|
}).first(where: { $0.id == itemId }),
|
|
item.documentId == docId
|
|
else {
|
|
throw ShareInboxError.invalidPayload
|
|
}
|
|
|
|
if let result = item.result {
|
|
guard result.docId == docId else {
|
|
throw ShareInboxError.invalidPayload
|
|
}
|
|
try remove(item)
|
|
return
|
|
}
|
|
|
|
var completedItem = item
|
|
completedItem.result = ShareInboxResult(
|
|
docId: docId,
|
|
committedAt: committedAt
|
|
)
|
|
completedItem.lastError = nil
|
|
try update(completedItem)
|
|
try remove(completedItem)
|
|
}
|
|
|
|
func attachmentURL(for attachment: ShareInboxAttachment) -> URL? {
|
|
guard let attachmentsDirectoryURL else { return nil }
|
|
guard !attachment.relativePath.isEmpty,
|
|
!attachment.relativePath.hasPrefix("/"),
|
|
!attachment.relativePath.split(separator: "/").contains("..")
|
|
else {
|
|
return nil
|
|
}
|
|
let base = attachmentsDirectoryURL.standardizedFileURL
|
|
let candidate = base.appendingPathComponent(attachment.relativePath).standardizedFileURL
|
|
let resolvedBase = base.resolvingSymlinksInPath().standardizedFileURL
|
|
let resolvedCandidate = candidate.resolvingSymlinksInPath().standardizedFileURL
|
|
guard candidate.path.hasPrefix(base.path + "/"),
|
|
resolvedCandidate.path.hasPrefix(resolvedBase.path + "/")
|
|
else { return nil }
|
|
return candidate
|
|
}
|
|
|
|
func resolveAttachment(for item: ShareInboxItem) -> ShareInboxResolvedAttachment? {
|
|
guard item.attachments.count == 1,
|
|
let attachment = item.attachments.first,
|
|
isValidAttachment(attachment, for: item),
|
|
let url = attachmentURL(for: attachment),
|
|
fileManager.fileExists(atPath: url.path),
|
|
let values = try? url.resourceValues(forKeys: [.isRegularFileKey, .fileSizeKey, .isSymbolicLinkKey]),
|
|
values.isSymbolicLink != true,
|
|
values.isRegularFile == true,
|
|
let size = values.fileSize,
|
|
size > 0
|
|
else {
|
|
return nil
|
|
}
|
|
let prefix: Data
|
|
do {
|
|
prefix = try ShareInboxFileCopy.readPrefix(from: url)
|
|
} catch {
|
|
return nil
|
|
}
|
|
switch item.content.kind {
|
|
case .image:
|
|
guard size <= 12 * 1024 * 1024,
|
|
ShareInboxSafety.detectRasterImageMimeType(prefix) == attachment.mimeType
|
|
else { return nil }
|
|
case .pdf:
|
|
guard size <= ShareInboxConstants.maxShareAttachmentBytes,
|
|
ShareInboxSafety.detectPDFMimeType(prefix) == attachment.mimeType
|
|
else { return nil }
|
|
case .url, .text:
|
|
return nil
|
|
}
|
|
return ShareInboxResolvedAttachment(
|
|
itemId: item.id,
|
|
url: url,
|
|
relativePath: attachment.relativePath,
|
|
name: attachment.fileName,
|
|
mimeType: attachment.mimeType,
|
|
size: size
|
|
)
|
|
}
|
|
|
|
func remove(_ item: ShareInboxItem) throws {
|
|
guard let fileURL = manifestURL(for: item.id) else {
|
|
throw ShareInboxError.invalidPayload
|
|
}
|
|
try ensureManifestCanMutate(at: fileURL)
|
|
if let attachmentDirectory = attachmentDirectoryURL(for: item.id),
|
|
fileManager.fileExists(atPath: attachmentDirectory.path)
|
|
{
|
|
try removeItem(attachmentDirectory)
|
|
}
|
|
if fileManager.fileExists(atPath: fileURL.path) {
|
|
try removeItem(fileURL)
|
|
}
|
|
}
|
|
|
|
private func attachmentDirectoryURL(for itemId: String) -> URL? {
|
|
guard let attachmentsDirectoryURL,
|
|
ShareInboxSafety.normalizedManifestID(itemId) != nil
|
|
else {
|
|
return nil
|
|
}
|
|
let base = attachmentsDirectoryURL.standardizedFileURL
|
|
let candidate = base
|
|
.appendingPathComponent(itemId, isDirectory: true)
|
|
.standardizedFileURL
|
|
guard candidate.path.hasPrefix(base.path + "/") else { return nil }
|
|
return candidate
|
|
}
|
|
|
|
private func manifestURL(for itemId: String) -> URL? {
|
|
guard let inboxDirectoryURL,
|
|
let normalizedId = ShareInboxSafety.normalizedManifestID(itemId)
|
|
else {
|
|
return nil
|
|
}
|
|
let base = inboxDirectoryURL.standardizedFileURL
|
|
let candidate = base
|
|
.appendingPathComponent("\(normalizedId).json")
|
|
.standardizedFileURL
|
|
guard candidate.path.hasPrefix(base.path + "/") else { return nil }
|
|
return candidate
|
|
}
|
|
|
|
private func quarantine(_ url: URL) {
|
|
guard let invalidDirectoryURL else { return }
|
|
let destination = invalidDirectoryURL.appendingPathComponent(url.lastPathComponent)
|
|
try? fileManager.removeItem(at: destination)
|
|
try? fileManager.moveItem(at: url, to: destination)
|
|
}
|
|
|
|
private func removeStaleAttachmentDirectories() {
|
|
guard let attachmentsDirectoryURL,
|
|
let urls = try? fileManager.contentsOfDirectory(
|
|
at: attachmentsDirectoryURL,
|
|
includingPropertiesForKeys: [.contentModificationDateKey, .isDirectoryKey],
|
|
options: []
|
|
)
|
|
else { return }
|
|
let staleBefore = Date.now.addingTimeInterval(-ShareInboxConstants.stagingMaxAge)
|
|
for url in urls {
|
|
guard let values = try? url.resourceValues(
|
|
forKeys: [.contentModificationDateKey, .isDirectoryKey]
|
|
),
|
|
values.isDirectory == true,
|
|
let modifiedAt = values.contentModificationDate,
|
|
modifiedAt < staleBefore
|
|
else { continue }
|
|
let name = url.lastPathComponent
|
|
let isTemporary = name.hasPrefix(".") && name.hasSuffix(".tmp")
|
|
let isPublishedOrphan = ShareInboxSafety.normalizedManifestID(name) != nil
|
|
&& manifestURL(for: name).map { !fileManager.fileExists(atPath: $0.path) } == true
|
|
if isTemporary || isPublishedOrphan {
|
|
try? removeItem(url)
|
|
}
|
|
}
|
|
}
|
|
|
|
private static func writeAtomically(
|
|
_ data: Data,
|
|
_ url: URL,
|
|
_ options: Data.WritingOptions
|
|
) throws {
|
|
try data.write(to: url, options: options)
|
|
}
|
|
|
|
private func ensureManifestCanMutate(at url: URL) throws {
|
|
guard fileManager.fileExists(atPath: url.path) else { return }
|
|
let data = try Data(contentsOf: url)
|
|
guard let schemaVersion = ShareInboxSafety.manifestSchemaVersion(from: data),
|
|
schemaVersion > ShareInboxItem.currentSchemaVersion
|
|
else { return }
|
|
throw ShareInboxError.unsupportedVersion
|
|
}
|
|
}
|
|
|
|
enum ShareInboxError: Error {
|
|
case containerUnavailable
|
|
case invalidPayload
|
|
case payloadTooLarge
|
|
case unsupportedVersion
|
|
}
|
|
|
|
enum ShareInboxFileCopy {
|
|
private static let chunkSize = 64 * 1024
|
|
|
|
static func copyCoordinatedFile(from sourceURL: URL, to destinationURL: URL) throws {
|
|
try withCoordinatedRead(sourceURL) { source in
|
|
try copyChunkedFile(from: source, to: destinationURL)
|
|
}
|
|
}
|
|
|
|
static func withCoordinatedRead(
|
|
_ sourceURL: URL,
|
|
_ read: (URL) throws -> Void
|
|
) throws {
|
|
var coordinationError: NSError?
|
|
var readError: Error?
|
|
let coordinator = NSFileCoordinator()
|
|
coordinator.coordinate(readingItemAt: sourceURL, options: [], error: &coordinationError) { source in
|
|
do {
|
|
try read(source)
|
|
} catch {
|
|
readError = error
|
|
}
|
|
}
|
|
if let coordinationError { throw coordinationError }
|
|
if let readError { throw readError }
|
|
}
|
|
|
|
static func readPrefix(from url: URL, count: Int = 12) throws -> Data {
|
|
let handle = try FileHandle(forReadingFrom: url)
|
|
defer { try? handle.close() }
|
|
return try handle.read(upToCount: count) ?? Data()
|
|
}
|
|
|
|
static func copyChunkedFile(from sourceURL: URL, to destinationURL: URL) throws {
|
|
guard FileManager.default.createFile(atPath: destinationURL.path, contents: nil) else {
|
|
throw ShareInboxError.invalidPayload
|
|
}
|
|
let input = try FileHandle(forReadingFrom: sourceURL)
|
|
defer { try? input.close() }
|
|
let output = try FileHandle(forWritingTo: destinationURL)
|
|
defer { try? output.close() }
|
|
while let data = try input.read(upToCount: chunkSize), !data.isEmpty {
|
|
try output.write(contentsOf: data)
|
|
}
|
|
try output.synchronize()
|
|
}
|
|
}
|