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)
+ }
+ }
}