Initial scaffold — NASBackup iOS app
Full project scaffold: XcodeGen project.yml, Core models/protocols/errors, SMBService (kishikawakatsumi/SMBClient), SFTPService (Citadel 0.9.2), PhotoLibraryService, LANMonitor, BackupEngine, BackgroundTaskManager, all feature UIs (Login, Connect, Browse, Backup, History, Settings), Shared components and theme. Builds clean with zero errors. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
72
Services/BackgroundTaskManager.swift
Normal file
72
Services/BackgroundTaskManager.swift
Normal file
@@ -0,0 +1,72 @@
|
||||
import Foundation
|
||||
import BackgroundTasks
|
||||
|
||||
final class BackgroundTaskManager {
|
||||
static let backupTaskID = "com.albert.nasbackup.backup"
|
||||
static let refreshTaskID = "com.albert.nasbackup.refresh"
|
||||
|
||||
static func registerTasks() {
|
||||
BGTaskScheduler.shared.register(
|
||||
forTaskWithIdentifier: backupTaskID,
|
||||
using: nil
|
||||
) { task in
|
||||
guard let task = task as? BGProcessingTask else { return }
|
||||
BackgroundTaskManager.handleBackupTask(task)
|
||||
}
|
||||
|
||||
BGTaskScheduler.shared.register(
|
||||
forTaskWithIdentifier: refreshTaskID,
|
||||
using: nil
|
||||
) { task in
|
||||
guard let task = task as? BGAppRefreshTask else { return }
|
||||
BackgroundTaskManager.handleRefreshTask(task)
|
||||
}
|
||||
}
|
||||
|
||||
static func scheduleBackup(delay: TimeInterval = 30) {
|
||||
let request = BGProcessingTaskRequest(identifier: backupTaskID)
|
||||
request.requiresNetworkConnectivity = true
|
||||
request.requiresExternalPower = false
|
||||
request.earliestBeginDate = Date(timeIntervalSinceNow: delay)
|
||||
do {
|
||||
try BGTaskScheduler.shared.submit(request)
|
||||
} catch {
|
||||
print("BGTask schedule failed: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
static func scheduleAppRefresh() {
|
||||
let request = BGAppRefreshTaskRequest(identifier: refreshTaskID)
|
||||
request.earliestBeginDate = Date(timeIntervalSinceNow: 15 * 60)
|
||||
do {
|
||||
try BGTaskScheduler.shared.submit(request)
|
||||
} catch {
|
||||
print("BGRefresh schedule failed: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
private static func handleBackupTask(_ task: BGProcessingTask) {
|
||||
scheduleBackup()
|
||||
let engine = BackupEngine.shared
|
||||
task.expirationHandler = { Task { await engine.cancel() } }
|
||||
Task {
|
||||
do {
|
||||
let store = ConnectionStore.shared
|
||||
guard let connection = store.savedConnection else {
|
||||
task.setTaskCompleted(success: false)
|
||||
return
|
||||
}
|
||||
let filter = store.backupFilter
|
||||
_ = try await engine.run(connection: connection, filter: filter, triggeredByLAN: true)
|
||||
task.setTaskCompleted(success: true)
|
||||
} catch {
|
||||
task.setTaskCompleted(success: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func handleRefreshTask(_ task: BGAppRefreshTask) {
|
||||
scheduleAppRefresh()
|
||||
task.setTaskCompleted(success: true)
|
||||
}
|
||||
}
|
||||
153
Services/BackupEngine.swift
Normal file
153
Services/BackupEngine.swift
Normal file
@@ -0,0 +1,153 @@
|
||||
import Foundation
|
||||
import UserNotifications
|
||||
|
||||
@MainActor
|
||||
final class BackupEngine: ObservableObject {
|
||||
static let shared = BackupEngine()
|
||||
|
||||
@Published private(set) var job: BackupJob = BackupJob()
|
||||
|
||||
private let photoService: PhotoLibraryProtocol = PhotoLibraryService()
|
||||
private var activeTransfer: (any NASTransferProtocol)?
|
||||
private var isCancelled = false
|
||||
|
||||
private init() {}
|
||||
|
||||
func run(
|
||||
connection: NASConnection,
|
||||
filter: BackupFilter,
|
||||
triggeredByLAN: Bool = false
|
||||
) async throws -> BackupResult {
|
||||
isCancelled = false
|
||||
let startDate = Date()
|
||||
|
||||
// 1. Fetch assets
|
||||
job.prepare()
|
||||
let assets = photoService.fetchAssets(filter: filter)
|
||||
|
||||
// 2. Connect to NAS
|
||||
let transfer: any NASTransferProtocol
|
||||
switch connection.nasProtocol {
|
||||
case .smb: transfer = SMBService()
|
||||
case .sftp: transfer = SFTPService()
|
||||
}
|
||||
|
||||
try await transfer.connect(
|
||||
to: connection.host,
|
||||
port: connection.port,
|
||||
username: connection.username,
|
||||
password: connection.password
|
||||
)
|
||||
activeTransfer = transfer
|
||||
|
||||
defer {
|
||||
transfer.disconnect()
|
||||
activeTransfer = nil
|
||||
}
|
||||
|
||||
// 3. Start job tracking
|
||||
job.start(totalFiles: assets.count, totalBytes: 0)
|
||||
|
||||
var uploaded = 0
|
||||
var skipped = 0
|
||||
var failed = 0
|
||||
var totalBytes: Int64 = 0
|
||||
var speedTracker = SpeedTracker()
|
||||
|
||||
// 4. Transfer loop
|
||||
for asset in assets {
|
||||
if isCancelled { break }
|
||||
while case .paused = job.status {
|
||||
try await Task.sleep(nanoseconds: 500_000_000)
|
||||
}
|
||||
|
||||
let remotePath = "\(connection.remotePath)/\(asset.filename)"
|
||||
|
||||
// Skip if exists (newFilesOnly = true in filter)
|
||||
if filter.newFilesOnly, (try? await transfer.fileExists(at: remotePath)) == true {
|
||||
job.fileCompleted(skipped: true)
|
||||
skipped += 1
|
||||
continue
|
||||
}
|
||||
|
||||
do {
|
||||
let localURL = try await photoService.exportAsset(asset)
|
||||
defer { try? FileManager.default.removeItem(at: localURL) }
|
||||
|
||||
let fileSize = (try? localURL.resourceValues(forKeys: [.fileSizeKey]).fileSize).flatMap { Int64($0) } ?? 0
|
||||
|
||||
try await transfer.upload(localURL: localURL, remotePath: remotePath) { sent, total in
|
||||
let speed = speedTracker.update(bytesSent: sent)
|
||||
Task { @MainActor [weak self] in
|
||||
self?.job.updateProgress(
|
||||
fileName: asset.filename,
|
||||
fileSize: total,
|
||||
bytesTransferred: totalBytes + sent,
|
||||
speed: speed
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
totalBytes += fileSize
|
||||
job.fileCompleted(skipped: false)
|
||||
uploaded += 1
|
||||
} catch {
|
||||
job.fileFailed()
|
||||
failed += 1
|
||||
}
|
||||
}
|
||||
|
||||
let duration = Date().timeIntervalSince(startDate)
|
||||
let result = BackupResult(
|
||||
uploadedCount: uploaded,
|
||||
skippedCount: skipped,
|
||||
failedCount: failed,
|
||||
duration: duration,
|
||||
totalBytes: totalBytes,
|
||||
date: startDate
|
||||
)
|
||||
|
||||
job.finish(result: result)
|
||||
|
||||
// 5. Persist history
|
||||
let entry = BackupHistoryEntry(result: result, nasHost: connection.host, triggeredByLAN: triggeredByLAN)
|
||||
ConnectionStore.shared.appendHistoryEntry(entry)
|
||||
|
||||
// 6. Push notification
|
||||
await sendCompletionNotification(result: result)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func cancel() {
|
||||
isCancelled = true
|
||||
job.cancel()
|
||||
}
|
||||
|
||||
func pause() { job.pause() }
|
||||
func resume() { job.resume() }
|
||||
|
||||
private func sendCompletionNotification(result: BackupResult) async {
|
||||
let content = UNMutableNotificationContent()
|
||||
content.title = result.hasErrors ? "Backup completed with errors" : "Backup complete"
|
||||
content.body = "\(result.uploadedCount) uploaded · \(result.skippedCount) skipped · \(result.failedCount) errors"
|
||||
content.sound = .default
|
||||
let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil)
|
||||
try? await UNUserNotificationCenter.current().add(request)
|
||||
}
|
||||
}
|
||||
|
||||
private struct SpeedTracker {
|
||||
private var lastBytes: Int64 = 0
|
||||
private var lastTime: Date = Date()
|
||||
|
||||
mutating func update(bytesSent: Int64) -> Double {
|
||||
let now = Date()
|
||||
let elapsed = now.timeIntervalSince(lastTime)
|
||||
guard elapsed > 0.1 else { return 0 }
|
||||
let speed = Double(bytesSent - lastBytes) / elapsed
|
||||
lastBytes = bytesSent
|
||||
lastTime = now
|
||||
return max(0, speed)
|
||||
}
|
||||
}
|
||||
59
Services/LANMonitor.swift
Normal file
59
Services/LANMonitor.swift
Normal file
@@ -0,0 +1,59 @@
|
||||
import Foundation
|
||||
import Network
|
||||
import NetworkExtension
|
||||
import Combine
|
||||
|
||||
@MainActor
|
||||
final class LANMonitor: ObservableObject {
|
||||
static let shared = LANMonitor()
|
||||
|
||||
@Published private(set) var isOnNetwork: Bool = false
|
||||
@Published private(set) var currentSSID: String? = nil
|
||||
|
||||
private let monitor = NWPathMonitor()
|
||||
private let queue = DispatchQueue(label: "com.albert.nasbackup.lan", qos: .utility)
|
||||
|
||||
var onTrustedNetworkJoined: ((String) -> Void)?
|
||||
|
||||
private init() {}
|
||||
|
||||
func start() {
|
||||
monitor.pathUpdateHandler = { [weak self] path in
|
||||
guard let self else { return }
|
||||
Task { @MainActor in
|
||||
let satisfied = path.status == .satisfied
|
||||
self.isOnNetwork = satisfied
|
||||
|
||||
if satisfied {
|
||||
await self.checkSSID()
|
||||
} else {
|
||||
self.currentSSID = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
monitor.start(queue: queue)
|
||||
}
|
||||
|
||||
func stop() {
|
||||
monitor.cancel()
|
||||
}
|
||||
|
||||
private func checkSSID() async {
|
||||
let ssid = await fetchCurrentSSID()
|
||||
self.currentSSID = ssid
|
||||
}
|
||||
|
||||
func fetchCurrentSSID() async -> String? {
|
||||
await withCheckedContinuation { continuation in
|
||||
NEHotspotNetwork.fetchCurrent { network in
|
||||
continuation.resume(returning: network?.ssid)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func checkAndTriggerIfTrusted(trustedSSIDs: [String]) async {
|
||||
let ssid = await fetchCurrentSSID()
|
||||
guard let ssid, trustedSSIDs.contains(ssid) else { return }
|
||||
onTrustedNetworkJoined?(ssid)
|
||||
}
|
||||
}
|
||||
115
Services/PhotoLibraryService.swift
Normal file
115
Services/PhotoLibraryService.swift
Normal file
@@ -0,0 +1,115 @@
|
||||
import Foundation
|
||||
import Photos
|
||||
|
||||
final class PhotoLibraryService: PhotoLibraryProtocol {
|
||||
var authorizationStatus: PHAuthorizationStatus {
|
||||
PHPhotoLibrary.authorizationStatus(for: .readWrite)
|
||||
}
|
||||
|
||||
func requestAuthorization() async -> PHAuthorizationStatus {
|
||||
await PHPhotoLibrary.requestAuthorization(for: .readWrite)
|
||||
}
|
||||
|
||||
func fetchAssets(filter: BackupFilter) -> [PhotoAsset] {
|
||||
let options = PHFetchOptions()
|
||||
options.sortDescriptors = [NSSortDescriptor(key: "creationDate", ascending: true)]
|
||||
|
||||
var subpredicates: [NSPredicate] = []
|
||||
|
||||
var mediaTypes: [PHAssetMediaType] = []
|
||||
if filter.includePhotos { mediaTypes.append(.image) }
|
||||
if filter.includeVideos { mediaTypes.append(.video) }
|
||||
|
||||
if !mediaTypes.isEmpty {
|
||||
let typePredicate = NSPredicate(
|
||||
format: "mediaType IN %@",
|
||||
mediaTypes.map(\.rawValue)
|
||||
)
|
||||
subpredicates.append(typePredicate)
|
||||
}
|
||||
|
||||
if !filter.includeScreenshots {
|
||||
subpredicates.append(NSPredicate(
|
||||
format: "NOT ((mediaSubtype & %d) != 0)",
|
||||
PHAssetMediaSubtype.photoScreenshot.rawValue
|
||||
))
|
||||
}
|
||||
|
||||
if subpredicates.count > 1 {
|
||||
options.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: subpredicates)
|
||||
} else {
|
||||
options.predicate = subpredicates.first
|
||||
}
|
||||
|
||||
let result = PHAsset.fetchAssets(with: options)
|
||||
var assets: [PhotoAsset] = []
|
||||
result.enumerateObjects { asset, _, _ in
|
||||
let isScreenshot = asset.mediaSubtypes.contains(.photoScreenshot)
|
||||
let resources = PHAssetResource.assetResources(for: asset)
|
||||
// RAW files are identified by an alternate photo resource (RAW+JPEG pairs)
|
||||
let isRAW = resources.contains { $0.type == .alternatePhoto }
|
||||
if isRAW && !filter.includeRAW { return }
|
||||
|
||||
let filename = resources.first?.originalFilename ?? "\(asset.localIdentifier).jpg"
|
||||
|
||||
assets.append(PhotoAsset(
|
||||
localIdentifier: asset.localIdentifier,
|
||||
filename: filename,
|
||||
creationDate: asset.creationDate,
|
||||
mediaType: asset.mediaType,
|
||||
pixelWidth: asset.pixelWidth,
|
||||
pixelHeight: asset.pixelHeight,
|
||||
duration: asset.duration,
|
||||
isScreenshot: isScreenshot,
|
||||
isRAW: isRAW
|
||||
))
|
||||
}
|
||||
return assets
|
||||
}
|
||||
|
||||
func exportAsset(_ asset: PhotoAsset) async throws -> URL {
|
||||
let fetchResult = PHAsset.fetchAssets(
|
||||
withLocalIdentifiers: [asset.localIdentifier],
|
||||
options: nil
|
||||
)
|
||||
guard let phAsset = fetchResult.firstObject else {
|
||||
throw BackupError.uploadFailed(asset.filename, underlying: NSError(domain: "PhotoExport", code: -1))
|
||||
}
|
||||
|
||||
let tmpURL = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(UUID().uuidString)
|
||||
.appendingPathExtension((asset.filename as NSString).pathExtension)
|
||||
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
if phAsset.mediaType == .video {
|
||||
let options = PHVideoRequestOptions()
|
||||
options.isNetworkAccessAllowed = true
|
||||
PHImageManager.default().requestAVAsset(forVideo: phAsset, options: options) { avAsset, _, _ in
|
||||
guard let urlAsset = avAsset as? AVURLAsset else {
|
||||
continuation.resume(throwing: BackupError.uploadFailed(asset.filename, underlying: NSError(domain: "AVExport", code: -1)))
|
||||
return
|
||||
}
|
||||
continuation.resume(returning: urlAsset.url)
|
||||
}
|
||||
} else {
|
||||
let options = PHImageRequestOptions()
|
||||
options.isNetworkAccessAllowed = true
|
||||
options.version = .current
|
||||
options.deliveryMode = .highQualityFormat
|
||||
|
||||
PHImageManager.default().requestImageDataAndOrientation(for: phAsset, options: options) { data, _, _, _ in
|
||||
guard let data else {
|
||||
continuation.resume(throwing: BackupError.uploadFailed(asset.filename, underlying: NSError(domain: "PhotoExport", code: -2)))
|
||||
return
|
||||
}
|
||||
do {
|
||||
try data.write(to: tmpURL)
|
||||
continuation.resume(returning: tmpURL)
|
||||
} catch {
|
||||
continuation.resume(throwing: BackupError.uploadFailed(asset.filename, underlying: error))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
100
Services/SFTPService.swift
Normal file
100
Services/SFTPService.swift
Normal file
@@ -0,0 +1,100 @@
|
||||
import Foundation
|
||||
import NIO
|
||||
import Citadel
|
||||
|
||||
final class SFTPService: NASTransferProtocol {
|
||||
private var client: SSHClient?
|
||||
private var sftp: SFTPClient?
|
||||
private(set) var isConnected: Bool = false
|
||||
|
||||
func connect(to host: String, port: Int, username: String, password: String) async throws {
|
||||
do {
|
||||
let c = try await SSHClient.connect(
|
||||
host: host,
|
||||
port: port,
|
||||
authenticationMethod: .passwordBased(username: username, password: password),
|
||||
hostKeyValidator: .acceptAnything(),
|
||||
reconnect: .never
|
||||
)
|
||||
let sftpClient = try await c.openSFTP()
|
||||
self.client = c
|
||||
self.sftp = sftpClient
|
||||
self.isConnected = true
|
||||
} catch {
|
||||
throw BackupError.connectionFailed(host)
|
||||
}
|
||||
}
|
||||
|
||||
func disconnect() {
|
||||
Task {
|
||||
try? await sftp?.close()
|
||||
client = nil
|
||||
}
|
||||
sftp = nil
|
||||
isConnected = false
|
||||
}
|
||||
|
||||
func listDirectory(at path: String) async throws -> [NASItem] {
|
||||
guard let sftp else { throw BackupError.connectionFailed("Not connected") }
|
||||
do {
|
||||
let batches = try await sftp.listDirectory(atPath: path)
|
||||
// listDirectory returns [SFTPMessage.Name]; each Name contains multiple SFTPPathComponent
|
||||
let components = batches.flatMap { $0.components }
|
||||
return components.compactMap { component -> NASItem? in
|
||||
guard component.filename != "." && component.filename != ".." else { return nil }
|
||||
let fullPath = path.hasSuffix("/")
|
||||
? "\(path)\(component.filename)"
|
||||
: "\(path)/\(component.filename)"
|
||||
let isDir = component.attributes.permissions.map { ($0 & 0o170000) == 0o040000 } ?? false
|
||||
return NASItem(
|
||||
name: component.filename,
|
||||
path: fullPath,
|
||||
isDirectory: isDir,
|
||||
size: Int64(component.attributes.size ?? 0),
|
||||
modifiedDate: nil
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
throw BackupError.directoryListFailed(path)
|
||||
}
|
||||
}
|
||||
|
||||
func createDirectory(at path: String) async throws {
|
||||
guard let sftp else { throw BackupError.connectionFailed("Not connected") }
|
||||
do {
|
||||
try await sftp.createDirectory(atPath: path)
|
||||
} catch {
|
||||
throw BackupError.directoryCreateFailed(path)
|
||||
}
|
||||
}
|
||||
|
||||
func fileExists(at remotePath: String) async throws -> Bool {
|
||||
guard let sftp else { throw BackupError.connectionFailed("Not connected") }
|
||||
do {
|
||||
_ = try await sftp.getAttributes(at: remotePath)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func upload(localURL: URL, remotePath: String, progress: @escaping (Int64, Int64) -> Void) async throws {
|
||||
guard let sftp else { throw BackupError.connectionFailed("Not connected") }
|
||||
let filename = localURL.lastPathComponent
|
||||
do {
|
||||
let data = try Data(contentsOf: localURL)
|
||||
let total = Int64(data.count)
|
||||
progress(0, total)
|
||||
try await sftp.withFile(
|
||||
filePath: remotePath,
|
||||
flags: [.write, .create, .truncate]
|
||||
) { file in
|
||||
var buffer = ByteBuffer(data: data)
|
||||
try await file.write(buffer, at: 0)
|
||||
}
|
||||
progress(total, total)
|
||||
} catch {
|
||||
throw BackupError.uploadFailed(filename, underlying: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
85
Services/SMBService.swift
Normal file
85
Services/SMBService.swift
Normal file
@@ -0,0 +1,85 @@
|
||||
import Foundation
|
||||
import SMBClient
|
||||
|
||||
final class SMBService: NASTransferProtocol {
|
||||
private var client: SMBClient?
|
||||
private(set) var isConnected: Bool = false
|
||||
|
||||
// remotePath format: "/ShareName/optional/subpath"
|
||||
// splitPath returns ("ShareName", "optional/subpath")
|
||||
private func splitPath(_ path: String) -> (share: String, relativePath: String) {
|
||||
let trimmed = path.trimmingCharacters(in: .init(charactersIn: "/"))
|
||||
let parts = trimmed.split(separator: "/", maxSplits: 1, omittingEmptySubsequences: true)
|
||||
let share = parts.isEmpty ? "" : String(parts[0])
|
||||
let rel = parts.count > 1 ? String(parts[1]) : ""
|
||||
return (share, rel)
|
||||
}
|
||||
|
||||
func connect(to host: String, port: Int, username: String, password: String) async throws {
|
||||
let c = SMBClient(host: host, port: port)
|
||||
do {
|
||||
try await c.login(username: username, password: password)
|
||||
} catch {
|
||||
throw BackupError.authenticationFailed
|
||||
}
|
||||
self.client = c
|
||||
self.isConnected = true
|
||||
}
|
||||
|
||||
func disconnect() {
|
||||
Task { try? await client?.logoff() }
|
||||
client = nil
|
||||
isConnected = false
|
||||
}
|
||||
|
||||
func listDirectory(at path: String) async throws -> [NASItem] {
|
||||
guard let client else { throw BackupError.connectionFailed("Not connected") }
|
||||
let (share, rel) = splitPath(path)
|
||||
try await client.connectShare(share)
|
||||
let files = try await client.listDirectory(path: rel.isEmpty ? "" : rel)
|
||||
return files.compactMap { file -> NASItem? in
|
||||
guard file.name != "." && file.name != ".." else { return nil }
|
||||
let fullPath = rel.isEmpty ? "/\(share)/\(file.name)" : "/\(share)/\(rel)/\(file.name)"
|
||||
return NASItem(
|
||||
name: file.name,
|
||||
path: fullPath,
|
||||
isDirectory: file.isDirectory,
|
||||
size: Int64(file.size),
|
||||
modifiedDate: file.lastWriteTime
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func createDirectory(at path: String) async throws {
|
||||
guard let client else { throw BackupError.connectionFailed("Not connected") }
|
||||
let (share, rel) = splitPath(path)
|
||||
try await client.connectShare(share)
|
||||
try await client.createDirectory(path: rel)
|
||||
}
|
||||
|
||||
func fileExists(at remotePath: String) async throws -> Bool {
|
||||
guard let client else { throw BackupError.connectionFailed("Not connected") }
|
||||
let (share, rel) = splitPath(remotePath)
|
||||
try await client.connectShare(share)
|
||||
let dir = (rel as NSString).deletingLastPathComponent
|
||||
let filename = (rel as NSString).lastPathComponent
|
||||
let files = try await client.listDirectory(path: dir)
|
||||
return files.contains { $0.name == filename }
|
||||
}
|
||||
|
||||
func upload(localURL: URL, remotePath: String, progress: @escaping (Int64, Int64) -> Void) async throws {
|
||||
guard let client else { throw BackupError.connectionFailed("Not connected") }
|
||||
let (share, rel) = splitPath(remotePath)
|
||||
try await client.connectShare(share)
|
||||
let filename = localURL.lastPathComponent
|
||||
do {
|
||||
try await client.upload(localPath: localURL, remotePath: rel) { _, _, bytesSent in
|
||||
let total = (try? localURL.resourceValues(forKeys: [.fileSizeKey]).fileSize)
|
||||
.flatMap { Int64($0) } ?? 0
|
||||
progress(bytesSent, total)
|
||||
}
|
||||
} catch {
|
||||
throw BackupError.uploadFailed(filename, underlying: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user