From 7eab140e92d844ea8c85f9ef05cedb84f76f3e85 Mon Sep 17 00:00:00 2001 From: statix05 Date: Wed, 16 Sep 2026 14:34:45 +0300 Subject: [PATCH] =?UTF-8?q?Drag&drop=20(file=20promises=20=D0=BD=D0=B0?= =?UTF-8?q?=D1=80=D1=83=D0=B6=D1=83,=20drop=20=D0=B2=D0=BD=D1=83=D1=82?= =?UTF-8?q?=D1=80=D1=8C)=20+=20resumable-=D0=BF=D0=B5=D1=80=D0=B5=D0=B4?= =?UTF-8?q?=D0=B0=D1=87=D0=B8=20(Range-=D0=B4=D0=BE=D0=BA=D0=B0=D1=87?= =?UTF-8?q?=D0=BA=D0=B0=20.part,=20=D0=BF=D0=B5=D1=80=D1=81=D0=B8=D1=81?= =?UTF-8?q?=D1=82=D0=B5=D0=BD=D1=82=D0=BD=D0=B0=D1=8F=20=D0=BE=D1=87=D0=B5?= =?UTF-8?q?=D1=80=D0=B5=D0=B4=D1=8C),=20=D1=81=D0=B1=D0=BE=D1=80=D0=BA?= =?UTF-8?q?=D0=B0=20YanDisk=20Beta?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Resources/Info.plist | 4 +- Sources/YanDisk/Core/AppStore.swift | 54 ++-- Sources/YanDisk/Core/DiskAPIClient.swift | 155 ++++++++++- Sources/YanDisk/Sync/SyncEngine.swift | 8 +- Sources/YanDisk/Transfer/CloudFileDrag.swift | 95 +++++++ .../YanDisk/Transfer/TransferManager.swift | 248 ++++++++++++++++++ Sources/YanDisk/UI/MainSplitView.swift | 93 +++++-- 7 files changed, 614 insertions(+), 43 deletions(-) create mode 100644 Sources/YanDisk/Transfer/CloudFileDrag.swift create mode 100644 Sources/YanDisk/Transfer/TransferManager.swift diff --git a/Resources/Info.plist b/Resources/Info.plist index 980fb8d..848e05d 100644 --- a/Resources/Info.plist +++ b/Resources/Info.plist @@ -7,7 +7,7 @@ CFBundleExecutable YanDisk CFBundleIdentifier - local.yandisk.app + local.yandisk.beta CFBundleInfoDictionaryVersion 6.0 CFBundleName @@ -17,7 +17,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.0.0 + 1.1.0-beta CFBundleVersion 1 LSMinimumSystemVersion diff --git a/Sources/YanDisk/Core/AppStore.swift b/Sources/YanDisk/Core/AppStore.swift index 622f748..62fab79 100644 --- a/Sources/YanDisk/Core/AppStore.swift +++ b/Sources/YanDisk/Core/AppStore.swift @@ -72,6 +72,7 @@ final class AppStore { var pathStack: [String] = ["/"] let syncEngine = SyncEngine() + let transfer = TransferManager() private var api: DiskAPIClient? private static let favoritesKey = "FavoritePaths" @@ -96,6 +97,9 @@ final class AppStore { if let paths = UserDefaults.standard.stringArray(forKey: Self.favoritesKey) { favoritePaths = Set(paths) } + transfer.onQueueIdle = { [weak self] in + Task { await self?.reloadAfterTransfer() } + } } private var didBootstrap = false @@ -109,7 +113,9 @@ final class AppStore { if let token { let client = DiskAPIClient(token: token) api = client + TransferBridge.shared.client = client syncEngine.attach(api: client) + transfer.attach(api: client) Task { await refreshAll() } @@ -129,7 +135,9 @@ final class AppStore { showLoginSheet = false let client = DiskAPIClient(token: token) api = client + TransferBridge.shared.client = client syncEngine.attach(api: client) + transfer.attach(api: client) Task { await refreshAll() } } @@ -137,7 +145,9 @@ final class AppStore { KeychainStore.deleteToken() token = nil api = nil + TransferBridge.shared.client = nil syncEngine.detach() + transfer.detach() items = [] trashItems = [] recentItems = [] @@ -468,33 +478,37 @@ final class AppStore { } func downloadToComputer(_ item: DiskResource) async { - guard let api else { return } - let panel = NSSavePanel() - panel.nameFieldStringValue = item.displayName - panel.canCreateDirectories = true - guard panel.runModal() == .OK, let url = panel.url else { return } - do { - try await api.downloadFile(path: item.path, to: url) - statusMessage = "Файл сохранён: \(url.lastPathComponent)" - NSWorkspace.shared.activateFileViewerSelecting([url]) - } catch { - handleError(error) + if item.isDirectory { + let panel = NSOpenPanel() + panel.canChooseDirectories = true + panel.canCreateDirectories = true + panel.prompt = "Сюда" + guard panel.runModal() == .OK, let dir = panel.url else { return } + transfer.enqueueDownload(resource: item, toDirectory: dir) + statusMessage = "Папка «\(item.displayName)» ставится в очередь скачивания" + } else { + let panel = NSSavePanel() + panel.nameFieldStringValue = item.displayName + panel.canCreateDirectories = true + guard panel.runModal() == .OK, let url = panel.url else { return } + transfer.enqueueDownload(remotePath: item.path, name: url.lastPathComponent, to: url.deletingLastPathComponent(), totalBytes: item.displaySize) + statusMessage = "«\(item.displayName)» ставится в очередь скачивания" } } func uploadFiles(from urls: [URL]) async { - guard let api else { return } for url in urls { - let name = url.lastPathComponent - let remote = currentPath == "/" ? "/\(name)" : "\(currentPath)/\(name)" - do { - try await api.uploadFile(localURL: url, to: remote, overwrite: true) - } catch { - handleError(error) - } + transfer.enqueueUpload(localURL: url, to: currentPath) } + statusMessage = "В очередь загрузки поставлено: \(urls.count)" + await reloadAfterTransfer() + } + + /// Обновить текущий список после завершения передач. + func reloadAfterTransfer() async { + guard let api, !transfer.items.contains(where: { $0.state == .queued || $0.state == .running }) else { return } await loadDirectory(path: currentPath) - statusMessage = "Загружено объектов: \(urls.count)" + await loadDiskInfo() } // MARK: - Share diff --git a/Sources/YanDisk/Core/DiskAPIClient.swift b/Sources/YanDisk/Core/DiskAPIClient.swift index 90876c0..fc56472 100644 --- a/Sources/YanDisk/Core/DiskAPIClient.swift +++ b/Sources/YanDisk/Core/DiskAPIClient.swift @@ -10,7 +10,7 @@ actor DiskAPIClient { init(token: String) { let config = URLSessionConfiguration.ephemeral config.timeoutIntervalForRequest = 60 - config.timeoutIntervalForResource = 600 + config.timeoutIntervalForResource = 3600 config.httpAdditionalHeaders = [ "Accept": "application/json", "User-Agent": "YanDisk/1.0 (macOS)" @@ -273,6 +273,159 @@ actor DiskAPIClient { } } + // MARK: - Resumable transfers (устойчивы к обрывам) + + /// Скачивание с докачкой: файл пишется в `.part`, обрыв — повтор + /// с HTTP Range от последнего байта. По завершении `.part` атомарно + /// переименовывается в конечный файл. Если сервер не поддерживает Range + /// (ответ 200 вместо 206), передача начинается заново. + func downloadFileResumable( + path: String, + to localURL: URL, + progress: (@Sendable (Int64, Int64) -> Void)? = nil + ) async throws { + let fm = FileManager.default + try fm.createDirectory(at: localURL.deletingLastPathComponent(), withIntermediateDirectories: true) + let partURL = localURL.appendingPathExtension("part") + var lastError: Error = DiskAPIError.transport("Не удалось скачать файл") + + for attempt in 0.. 0 { + try await Task.sleep(nanoseconds: UInt64(min(30, pow(2, Double(attempt))) * 1_000_000_000)) + } + guard let remote = try? await downloadURL(path: path) else { + lastError = DiskAPIError.transport("Не получена ссылка скачивания") + continue + } + var request = URLRequest(url: remote) + + let partAttrs = try? fm.attributesOfItem(atPath: partURL.path) + var offset: Int64 = 0 + if let size = partAttrs?[.size] as? Int64 { offset = size } + if offset > 0 { + request.setValue("bytes=\(offset)-", forHTTPHeaderField: "Range") + } + + do { + let (stream, response) = try await session.bytes(for: request) + guard let http = response as? HTTPURLResponse else { + throw DiskAPIError.transport("Некорректный ответ сервера") + } + var appendFrom: Int64 = 0 + var expectedTotal: Int64 = 0 + switch http.statusCode { + case 206: + // Range поддержан: Content-Range: bytes o-e/total + appendFrom = offset + if let range = http.value(forHTTPHeaderField: "Content-Range"), + let total = range.split(separator: "/").last.flatMap({ Int64($0.trimmingCharacters(in: .whitespaces)) }) { + expectedTotal = total + } + case 200: + appendFrom = 0 + expectedTotal = Int64(http.expectedContentLength) + case 416: + // Файл уже скачан полностью? Проверим размер части против облака. + let complete = (try? await resource(path: path)).flatMap { $0.size } == offset + if complete, offset > 0 { + if fm.fileExists(atPath: localURL.path) { + try fm.removeItem(at: localURL) + } + try fm.moveItem(at: partURL, to: localURL) + progress?(offset, offset) + return + } + try? fm.removeItem(at: partURL) + lastError = DiskAPIError.transport("Скачивание оборвалось, начата заново") + continue + default: + throw DiskAPIError.transport("Ошибка скачивания: HTTP \(http.statusCode)") + } + + let handle: FileHandle + if fm.fileExists(atPath: partURL.path) { + handle = try FileHandle(forWritingTo: partURL) + if appendFrom == 0 { try handle.truncate(atOffset: 0) } + } else { + fm.createFile(atPath: partURL.path, contents: nil) + handle = try FileHandle(forWritingTo: partURL) + } + defer { try? handle.close() } + if appendFrom > 0 { try handle.seek(toOffset: UInt64(appendFrom)) } + + var done = appendFrom + var buffer = Data() + buffer.reserveCapacity(1 << 20) + for try await byte in stream { + buffer.append(byte) + if buffer.count >= 1 << 20 { + try handle.write(contentsOf: buffer) + buffer.removeAll(keepingCapacity: true) + } + done += 1 + if done % (512 * 1024) == 0 { progress?(done, max(expectedTotal, done)) } + } + if !buffer.isEmpty { try handle.write(contentsOf: buffer) } + try handle.close() + + if expectedTotal > 0, done != expectedTotal { + throw DiskAPIError.transport("Передача оборвалась на \(done) из \(expectedTotal) байт") + } + if fm.fileExists(atPath: localURL.path) { + try fm.removeItem(at: localURL) + } + try fm.moveItem(at: partURL, to: localURL) + progress?(done, done) + return + } catch { + lastError = error + } + } + throw lastError + } + + /// Загрузка с повторами: REST API Диска не поддерживает докачку PUT, + /// поэтому при обрыве запрашивается свежий upload-href и файл льётся заново. + /// Устойчивость к крашу обеспечивает персистентная очередь TransferManager. + func uploadFileResumable( + localURL: URL, + to path: String, + overwrite: Bool = true, + progress: (@Sendable (Int64, Int64) -> Void)? = nil + ) async throws { + let attrs = try FileManager.default.attributesOfItem(atPath: localURL.path) + let total = (attrs[.size] as? Int64) ?? 0 + progress?(0, total) + var lastError: Error = DiskAPIError.transport("Не удалось загрузить файл") + + for attempt in 0.. 0 { + try await Task.sleep(nanoseconds: UInt64(min(30, pow(2, Double(attempt))) * 1_000_000_000)) + } + guard let remote = try? await uploadURL(path: path, overwrite: overwrite) else { + lastError = DiskAPIError.transport("Не получена ссылка загрузки") + continue + } + var request = URLRequest(url: remote) + request.httpMethod = "PUT" + request.setValue("application/octet-stream", forHTTPHeaderField: "Content-Type") + do { + let (_, response) = try await session.upload(for: request, fromFile: localURL) + guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else { + let code = (response as? HTTPURLResponse)?.statusCode ?? -1 + throw DiskAPIError.transport("Ошибка загрузки: HTTP \(code)") + } + progress?(total, total) + return + } catch { + lastError = error + } + } + throw lastError + } + + private static let maxTransferAttempts = 6 + // MARK: - Publish / Share func publish(path: String, settings: ShareAccessSettings) async throws -> String { diff --git a/Sources/YanDisk/Sync/SyncEngine.swift b/Sources/YanDisk/Sync/SyncEngine.swift index 9ec36e6..89a45eb 100644 --- a/Sources/YanDisk/Sync/SyncEngine.swift +++ b/Sources/YanDisk/Sync/SyncEngine.swift @@ -192,7 +192,7 @@ final class SyncEngine: ObservableObject { if !exists { if itemMode == .everywhere || itemMode == .computerOnly { appendTask(kind: .download, path: item.path) - try await api.downloadFile(path: item.path, to: localURL) + try await api.downloadFileResumable(path: item.path, to: localURL) finishLastTask(success: true) } } else if itemMode == .everywhere { @@ -201,7 +201,7 @@ final class SyncEngine: ObservableObject { let remoteSize = item.size ?? -1 if remoteSize >= 0, localSize != remoteSize, localSize < remoteSize { appendTask(kind: .download, path: item.path) - try await api.downloadFile(path: item.path, to: localURL) + try await api.downloadFileResumable(path: item.path, to: localURL) finishLastTask(success: true) } } @@ -223,7 +223,7 @@ final class SyncEngine: ObservableObject { try await uploadLocalChanges(cloudPath: childCloud, api: api) } else { appendTask(kind: .upload, path: childCloud) - try await api.uploadFile(localURL: childLocal, to: childCloud, overwrite: true) + try await api.uploadFileResumable(localURL: childLocal, to: childCloud, overwrite: true) finishLastTask(success: true) } } @@ -255,7 +255,7 @@ final class SyncEngine: ObservableObject { let existsRemotely = (try? await api.resource(path: childCloud)) != nil if !existsRemotely { appendTask(kind: .upload, path: childCloud) - try await api.uploadFile(localURL: childLocal, to: childCloud, overwrite: true) + try await api.uploadFileResumable(localURL: childLocal, to: childCloud, overwrite: true) finishLastTask(success: true) } } diff --git a/Sources/YanDisk/Transfer/CloudFileDrag.swift b/Sources/YanDisk/Transfer/CloudFileDrag.swift new file mode 100644 index 0000000..95f79c6 --- /dev/null +++ b/Sources/YanDisk/Transfer/CloudFileDrag.swift @@ -0,0 +1,95 @@ +import CoreTransferable +import Foundation +import UniformTypeIdentifiers + +/// Мост для экспорта облачных файлов при drag & drop: драг даёт Finder +/// «обещание» файла, а реальное скачивание начинается в момент дропа. +@MainActor +final class TransferBridge { + static let shared = TransferBridge() + var client: DiskAPIClient? + var onActivity: ((String) -> Void)? + + /// Скачать ресурс во временную папку и вернуть готовый файл/архив. + func materialize(path: String, name: String, isDirectory: Bool) async throws -> URL { + guard let client else { throw DiskAPIError.unauthorized } + let fm = FileManager.default + let tmp = fm.temporaryDirectory + .appendingPathComponent("YanDiskExport-\(UUID().uuidString)", isDirectory: true) + try fm.createDirectory(at: tmp, withIntermediateDirectories: true) + + if isDirectory { + let folder = tmp.appendingPathComponent(name, isDirectory: true) + try await downloadDirectory(path: path, to: folder, client: client) + let zipURL = tmp.appendingPathComponent("\(name).zip") + try Self.zipDirectory(at: folder, to: zipURL) + try? fm.removeItem(at: folder) + return zipURL + } else { + let dest = tmp.appendingPathComponent(name) + try await client.downloadFileResumable(path: path, to: dest) + return dest + } + } + + func downloadDirectory(path: String, to folder: URL, client: DiskAPIClient) async throws { + try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) + for child in try await client.listDirectory(path: path) { + if child.isDirectory { + try await downloadDirectory(path: child.path, to: folder.appendingPathComponent(child.displayName, isDirectory: true), client: client) + } else { + try await client.downloadFileResumable(path: child.path, to: folder.appendingPathComponent(child.displayName)) + } + } + } + + private static func zipDirectory(at src: URL, to zipURL: URL) throws { + try? FileManager.default.removeItem(at: zipURL) + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/ditto") + process.arguments = ["-c", "-k", "--keepParent", src.path, zipURL.path] + try process.run() + process.waitUntilExit() + guard process.terminationStatus == 0 else { + throw DiskAPIError.transport("Не удалось упаковать папку в архив") + } + } + + /// Синхронный экспорт (требование API Transferable): ждём скачивание + /// через семафор — загрузка идёт в фоне, дроп просто дождётся файла. + nonisolated static func exportBlocking(path: String, name: String, isDirectory: Bool) throws -> URL { + let semaphore = DispatchSemaphore(value: 0) + nonisolated(unsafe) var outcome: Result? + Task.detached(priority: .userInitiated) { + let result: Result + do { + let url = try await TransferBridge.shared.materialize(path: path, name: name, isDirectory: isDirectory) + result = .success(url) + } catch { + result = .failure(error) + } + outcome = result + semaphore.signal() + } + semaphore.wait() + return try outcome.unsafelyUnwrapped.get() + } +} + +/// Элемент из облака: перетаскивается из приложения, материализуется при дропе. +/// Папка при дропе скачивается рекурсивно и отдаётся zip-архивом. +struct CloudItem: Transferable, Hashable, Sendable { + let path: String + let name: String + let isDirectory: Bool + + static var transferRepresentation: some TransferRepresentation { + FileRepresentation(exportedContentType: .data) { item in + SentTransferredFile(try TransferBridge.exportBlocking( + path: item.path, + name: item.isDirectory ? "\(item.name).zip" : item.name, + isDirectory: item.isDirectory + )) + } + } +} diff --git a/Sources/YanDisk/Transfer/TransferManager.swift b/Sources/YanDisk/Transfer/TransferManager.swift new file mode 100644 index 0000000..7009d0e --- /dev/null +++ b/Sources/YanDisk/Transfer/TransferManager.swift @@ -0,0 +1,248 @@ +import Foundation + +/// Элемент очереди передач. Персистентен: переживает краш и рестарт приложения. +struct TransferItem: Identifiable, Codable, Equatable { + enum Kind: String, Codable { + case upload, download + var title: String { self == .upload ? "Загрузка" : "Скачивание" } + var systemImage: String { self == .upload ? "arrow.up.circle" : "arrow.down.circle" } + } + + enum State: String, Codable { + case queued, running, completed, failed + var title: String { + switch self { + case .queued: return "В очереди" + case .running: return "Передаётся" + case .completed: return "Готово" + case .failed: return "Ошибка" + } + } + } + + let id: UUID + let kind: Kind + let remotePath: String + let destPath: String + var state: State = .queued + var bytesDone: Int64 = 0 + var bytesTotal: Int64 = 0 + var error: String? +} + +/// Очередь передач с восстановлением после краша: +/// — скачивание: HTTP Range + файл `*.part`, докачка с обрыва; +/// — загрузка: официальный upload-href с повторами; очередь персистентна, +/// после перезапуска недоделанные задачи возобновляются автоматически. +@MainActor +@Observable +final class TransferManager { + private(set) var items: [TransferItem] = [] + private var api: DiskAPIClient? + private var isPumping = false + private let maxAttempts = 6 + + /// Вызывается, когда очередь опустела (для обновления списков в UI). + var onQueueIdle: (() -> Void)? + + private static var manifestURL: URL { + let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] + return base.appendingPathComponent("YanDisk/transfers.json") + } + + init() { + loadManifest() + } + + func attach(api: DiskAPIClient) { + self.api = api + kick() + } + + func detach() { + api = nil + } + + // MARK: - Enqueue + + @discardableResult + func enqueueDownload(remotePath: String, name: String, to dest: URL, totalBytes: Int64 = 0) -> TransferItem { + let item = TransferItem( + id: UUID(), kind: .download, remotePath: remotePath, + destPath: dest.appendingPathComponent(name).path, + bytesTotal: totalBytes + ) + insert(item) + return item + } + + @discardableResult + func enqueueDownload(resource: DiskResource, toDirectory dest: URL) -> TransferItem { + enqueueDownload(remotePath: resource.path, name: resource.displayName, to: dest, totalBytes: resource.displaySize) + } + + @discardableResult + func enqueueUpload(localURL: URL, to remotePath: String) -> TransferItem { + let attrs = try? FileManager.default.attributesOfItem(atPath: localURL.path) + let size = (attrs?[.size] as? Int64) ?? 0 + var isDir: ObjCBool = false + FileManager.default.fileExists(atPath: localURL.path, isDirectory: &isDir) + let dest = remotePath == "/" ? "/\(localURL.lastPathComponent)" : "\(remotePath)/\(localURL.lastPathComponent)" + let item = TransferItem( + id: UUID(), kind: .upload, remotePath: dest, + destPath: localURL.path, bytesTotal: isDir.boolValue ? 0 : size + ) + insert(item) + return item + } + + func retryAll() { + for i in items.indices where items[i].state == .failed { + items[i].state = .queued + items[i].error = nil + } + persist() + kick() + } + + func clearFinished() { + items.removeAll { $0.state == .completed } + persist() + } + + // MARK: - Queue + + private func insert(_ item: TransferItem) { + items.insert(item, at: 0) + if items.count > 200 { items.removeLast(items.count - 200) } + persist() + kick() + } + + private func kick() { + Task { await pump() } + } + + private func pump() async { + guard !isPumping, api != nil else { return } + isPumping = true + defer { isPumping = false } + while let index = items.firstIndex(where: { $0.state == .queued }), let api { + items[index].state = .running + items[index].error = nil + persist() + do { + switch items[index].kind { + case .download: + try await runDownload(at: index, api: api) + case .upload: + try await runUpload(at: index, api: api) + } + items[index].state = .completed + if items[index].bytesTotal > 0 { items[index].bytesDone = items[index].bytesTotal } + } catch is CancellationError { + items[index].state = .queued + } catch { + items[index].state = .failed + items[index].error = error.localizedDescription + } + persist() + } + if !items.contains(where: { $0.state == .queued || $0.state == .running }) { + onQueueIdle?() + } + } + + private func runDownload(at index: Int, api: DiskAPIClient) async throws { + let item = items[index] + let dest = URL(fileURLWithPath: item.destPath) + let remote = try await api.resource(path: item.remotePath) + let parent = dest.deletingLastPathComponent() + if remote.isDirectory { + try await downloadDirectory(remote: item.remotePath, to: parent, name: dest.lastPathComponent, api: api, id: item.id) + } else { + try await api.downloadFileResumable(path: item.remotePath, to: dest) { [weak self] done, total in + Task { @MainActor in + guard let self, let i = self.items.firstIndex(where: { $0.id == item.id }) else { return } + self.items[i].bytesDone = done + self.items[i].bytesTotal = total + self.persist() + } + } + } + } + + private func runUpload(at index: Int, api: DiskAPIClient) async throws { + let item = items[index] + let local = URL(fileURLWithPath: item.destPath) + var isDir: ObjCBool = false + guard FileManager.default.fileExists(atPath: local.path, isDirectory: &isDir) else { + throw DiskAPIError.transport("Локальный файл исчез: \(local.lastPathComponent)") + } + if isDir.boolValue { + try await uploadDirectory(local: local, to: item.remotePath, api: api, id: item.id) + } else { + try await api.uploadFileResumable(localURL: local, to: item.remotePath, overwrite: true) { [weak self] done, total in + Task { @MainActor in + guard let self, let i = self.items.firstIndex(where: { $0.id == item.id }) else { return } + self.items[i].bytesDone = done + self.items[i].bytesTotal = total + } + } + } + } + + private func downloadDirectory(remote: String, to parentDir: URL, name: String, api: DiskAPIClient, id: UUID) async throws { + let folder = parentDir.appendingPathComponent(name, isDirectory: true) + try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) + let children = try await api.listDirectory(path: remote) + for child in children { + if child.isDirectory { + try await downloadDirectory(remote: child.path, to: folder, name: child.displayName, api: api, id: id) + } else { + try await api.downloadFileResumable(path: child.path, to: folder.appendingPathComponent(child.displayName)) + } + } + } + + private func uploadDirectory(local: URL, to remote: String, api: DiskAPIClient, id: UUID) async throws { + try await api.createDirectory(path: remote) + let fm = FileManager.default + guard let names = try? fm.contentsOfDirectory(atPath: local.path) else { return } + for name in names where !name.hasPrefix(".") { + let child = local.appendingPathComponent(name) + let childRemote = remote == "/" ? "/\(name)" : "\(remote)/\(name)" + var isDir: ObjCBool = false + guard fm.fileExists(atPath: child.path, isDirectory: &isDir) else { continue } + if isDir.boolValue { + try await uploadDirectory(local: child, to: childRemote, api: api, id: id) + } else { + try await api.uploadFileResumable(localURL: child, to: childRemote, overwrite: true) + } + } + } + + // MARK: - Persistence + + private func loadManifest() { + guard let data = try? Data(contentsOf: Self.manifestURL), + let saved = try? JSONDecoder().decode([TransferItem].self, from: data) else { return } + // Недоделанные задачи возобновляются после рестарта/краша. + items = saved.map { item in + var item = item + if item.state == .running || item.state == .queued { item.state = .queued } + return item + } + } + + private func persist() { + let url = Self.manifestURL + let snapshot = items + Task.detached(priority: .utility) { + try? FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + if let data = try? JSONEncoder().encode(snapshot) { + try? data.write(to: url, options: .atomic) + } + } + } +} diff --git a/Sources/YanDisk/UI/MainSplitView.swift b/Sources/YanDisk/UI/MainSplitView.swift index a93df3b..12fcaa1 100644 --- a/Sources/YanDisk/UI/MainSplitView.swift +++ b/Sources/YanDisk/UI/MainSplitView.swift @@ -1,5 +1,6 @@ +import CoreTransferable import SwiftUI -import AppKit +import UniformTypeIdentifiers struct MainSplitView: View { @Environment(AppStore.self) private var store @@ -244,6 +245,9 @@ struct MainSplitView: View { .foregroundStyle(.secondary) } } + .draggable(dragItem(for: item)) { + dragPreview(for: item) + } } TableColumn("Размер") { item in Text(item.isDirectory ? "—" : ByteFormatter.string(info: item.displaySize)) @@ -277,6 +281,24 @@ struct MainSplitView: View { Task { await store.uploadFiles(from: files) } return true } + .onDrop(of: [.fileURL], isTargeted: nil) { providers in + var urls: [URL] = [] + let group = DispatchGroup() + let lock = NSLock() + for provider in providers where provider.hasItemConformingToTypeIdentifier(UTType.fileURL.identifier) { + group.enter() + _ = provider.loadObject(ofClass: URL.self) { url, _ in + if let url { lock.lock(); urls.append(url); lock.unlock() } + group.leave() + } + } + group.wait() + guard !urls.isEmpty else { return false } + let files = urls.filter { $0.isFileURL } + guard !files.isEmpty else { return false } + Task { await store.uploadFiles(from: files) } + return true + } } } @@ -338,6 +360,18 @@ struct MainSplitView: View { } } + private func dragItem(for item: DiskResource) -> CloudItem { + CloudItem(path: item.path, name: item.displayName, isDirectory: item.isDirectory) + } + + private func dragPreview(for item: DiskResource) -> some View { + HStack(spacing: 6) { + Image(systemName: icon(for: item)) + Text(item.displayName) + } + .padding(6) + } + private func icon(for item: DiskResource) -> String { if item.isDirectory { return "folder.fill" } let ext = (item.displayName as NSString).pathExtension.lowercased() @@ -372,7 +406,8 @@ struct TasksView: View { @Environment(AppStore.self) private var store var body: some View { - if store.syncEngine.tasks.isEmpty { + let transfers = store.transfer.items + if transfers.isEmpty { VStack(spacing: 10) { Image(systemName: "arrow.triangle.2.circlepath") .font(.system(size: 36)) @@ -382,28 +417,54 @@ struct TasksView: View { } .frame(maxWidth: .infinity, maxHeight: .infinity) } else { - List(store.syncEngine.tasks) { task in - HStack { - Image(systemName: task.kind == .upload ? "arrow.up.circle" : "arrow.down.circle") - .foregroundStyle(task.isError ? Color.red : Color.accentColor) - VStack(alignment: .leading, spacing: 2) { - Text((task.path as NSString).lastPathComponent) + List(transfers) { item in + HStack(spacing: 10) { + Image(systemName: item.kind.systemImage) + .foregroundStyle(item.state == .failed ? Color.red : Color.accentColor) + VStack(alignment: .leading, spacing: 3) { + Text(displayName(for: item)) .lineLimit(1) - Text(task.kind.title) + Text("\(item.kind.title) — \(item.state.title)") .font(.caption) .foregroundStyle(.secondary) + if item.state == .running, item.bytesTotal > 0 { + ProgressView(value: Double(item.bytesDone), total: Double(item.bytesTotal)) + .controlSize(.small) + Text("\(ByteFormatter.string(info: item.bytesDone)) / \(ByteFormatter.string(info: item.bytesTotal))") + .font(.caption2) + .foregroundStyle(.secondary) + } + if let error = item.error { + Text(error) + .font(.caption2) + .foregroundStyle(.red) + .lineLimit(2) + } } Spacer() - if task.isFinished { - Image(systemName: task.isError ? "xmark.circle.fill" : "checkmark.circle.fill") - .foregroundStyle(task.isError ? .red : .green) - } else { - ProgressView() - .controlSize(.small) - } + stateBadge(item) } .padding(.vertical, 2) } } } + + private func displayName(for item: TransferItem) -> String { + let raw = item.kind == .upload ? item.destPath : item.remotePath + return (raw as NSString).lastPathComponent + } + + @ViewBuilder + private func stateBadge(_ item: TransferItem) -> some View { + switch item.state { + case .completed: + Image(systemName: "checkmark.circle.fill").foregroundStyle(.green) + case .failed: + Image(systemName: "xmark.circle.fill").foregroundStyle(.red) + case .running: + ProgressView().controlSize(.small) + case .queued: + Image(systemName: "clock").foregroundStyle(.secondary) + } + } }