Drag&drop (file promises наружу, drop внутрь) + resumable-передачи (Range-докачка .part, персистентная очередь), сборка YanDisk Beta
This commit is contained in:
@@ -7,7 +7,7 @@
|
|||||||
<key>CFBundleExecutable</key>
|
<key>CFBundleExecutable</key>
|
||||||
<string>YanDisk</string>
|
<string>YanDisk</string>
|
||||||
<key>CFBundleIdentifier</key>
|
<key>CFBundleIdentifier</key>
|
||||||
<string>local.yandisk.app</string>
|
<string>local.yandisk.beta</string>
|
||||||
<key>CFBundleInfoDictionaryVersion</key>
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
<string>6.0</string>
|
<string>6.0</string>
|
||||||
<key>CFBundleName</key>
|
<key>CFBundleName</key>
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>APPL</string>
|
<string>APPL</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
<string>1.0.0</string>
|
<string>1.1.0-beta</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>1</string>
|
<string>1</string>
|
||||||
<key>LSMinimumSystemVersion</key>
|
<key>LSMinimumSystemVersion</key>
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ final class AppStore {
|
|||||||
var pathStack: [String] = ["/"]
|
var pathStack: [String] = ["/"]
|
||||||
|
|
||||||
let syncEngine = SyncEngine()
|
let syncEngine = SyncEngine()
|
||||||
|
let transfer = TransferManager()
|
||||||
private var api: DiskAPIClient?
|
private var api: DiskAPIClient?
|
||||||
|
|
||||||
private static let favoritesKey = "FavoritePaths"
|
private static let favoritesKey = "FavoritePaths"
|
||||||
@@ -96,6 +97,9 @@ final class AppStore {
|
|||||||
if let paths = UserDefaults.standard.stringArray(forKey: Self.favoritesKey) {
|
if let paths = UserDefaults.standard.stringArray(forKey: Self.favoritesKey) {
|
||||||
favoritePaths = Set(paths)
|
favoritePaths = Set(paths)
|
||||||
}
|
}
|
||||||
|
transfer.onQueueIdle = { [weak self] in
|
||||||
|
Task { await self?.reloadAfterTransfer() }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var didBootstrap = false
|
private var didBootstrap = false
|
||||||
@@ -109,7 +113,9 @@ final class AppStore {
|
|||||||
if let token {
|
if let token {
|
||||||
let client = DiskAPIClient(token: token)
|
let client = DiskAPIClient(token: token)
|
||||||
api = client
|
api = client
|
||||||
|
TransferBridge.shared.client = client
|
||||||
syncEngine.attach(api: client)
|
syncEngine.attach(api: client)
|
||||||
|
transfer.attach(api: client)
|
||||||
Task {
|
Task {
|
||||||
await refreshAll()
|
await refreshAll()
|
||||||
}
|
}
|
||||||
@@ -129,7 +135,9 @@ final class AppStore {
|
|||||||
showLoginSheet = false
|
showLoginSheet = false
|
||||||
let client = DiskAPIClient(token: token)
|
let client = DiskAPIClient(token: token)
|
||||||
api = client
|
api = client
|
||||||
|
TransferBridge.shared.client = client
|
||||||
syncEngine.attach(api: client)
|
syncEngine.attach(api: client)
|
||||||
|
transfer.attach(api: client)
|
||||||
Task { await refreshAll() }
|
Task { await refreshAll() }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,7 +145,9 @@ final class AppStore {
|
|||||||
KeychainStore.deleteToken()
|
KeychainStore.deleteToken()
|
||||||
token = nil
|
token = nil
|
||||||
api = nil
|
api = nil
|
||||||
|
TransferBridge.shared.client = nil
|
||||||
syncEngine.detach()
|
syncEngine.detach()
|
||||||
|
transfer.detach()
|
||||||
items = []
|
items = []
|
||||||
trashItems = []
|
trashItems = []
|
||||||
recentItems = []
|
recentItems = []
|
||||||
@@ -468,33 +478,37 @@ final class AppStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func downloadToComputer(_ item: DiskResource) async {
|
func downloadToComputer(_ item: DiskResource) async {
|
||||||
guard let api else { return }
|
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()
|
let panel = NSSavePanel()
|
||||||
panel.nameFieldStringValue = item.displayName
|
panel.nameFieldStringValue = item.displayName
|
||||||
panel.canCreateDirectories = true
|
panel.canCreateDirectories = true
|
||||||
guard panel.runModal() == .OK, let url = panel.url else { return }
|
guard panel.runModal() == .OK, let url = panel.url else { return }
|
||||||
do {
|
transfer.enqueueDownload(remotePath: item.path, name: url.lastPathComponent, to: url.deletingLastPathComponent(), totalBytes: item.displaySize)
|
||||||
try await api.downloadFile(path: item.path, to: url)
|
statusMessage = "«\(item.displayName)» ставится в очередь скачивания"
|
||||||
statusMessage = "Файл сохранён: \(url.lastPathComponent)"
|
|
||||||
NSWorkspace.shared.activateFileViewerSelecting([url])
|
|
||||||
} catch {
|
|
||||||
handleError(error)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func uploadFiles(from urls: [URL]) async {
|
func uploadFiles(from urls: [URL]) async {
|
||||||
guard let api else { return }
|
|
||||||
for url in urls {
|
for url in urls {
|
||||||
let name = url.lastPathComponent
|
transfer.enqueueUpload(localURL: url, to: currentPath)
|
||||||
let remote = currentPath == "/" ? "/\(name)" : "\(currentPath)/\(name)"
|
|
||||||
do {
|
|
||||||
try await api.uploadFile(localURL: url, to: remote, overwrite: true)
|
|
||||||
} catch {
|
|
||||||
handleError(error)
|
|
||||||
}
|
}
|
||||||
|
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)
|
await loadDirectory(path: currentPath)
|
||||||
statusMessage = "Загружено объектов: \(urls.count)"
|
await loadDiskInfo()
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Share
|
// MARK: - Share
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ actor DiskAPIClient {
|
|||||||
init(token: String) {
|
init(token: String) {
|
||||||
let config = URLSessionConfiguration.ephemeral
|
let config = URLSessionConfiguration.ephemeral
|
||||||
config.timeoutIntervalForRequest = 60
|
config.timeoutIntervalForRequest = 60
|
||||||
config.timeoutIntervalForResource = 600
|
config.timeoutIntervalForResource = 3600
|
||||||
config.httpAdditionalHeaders = [
|
config.httpAdditionalHeaders = [
|
||||||
"Accept": "application/json",
|
"Accept": "application/json",
|
||||||
"User-Agent": "YanDisk/1.0 (macOS)"
|
"User-Agent": "YanDisk/1.0 (macOS)"
|
||||||
@@ -273,6 +273,159 @@ actor DiskAPIClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Resumable transfers (устойчивы к обрывам)
|
||||||
|
|
||||||
|
/// Скачивание с докачкой: файл пишется в `<dest>.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..<Self.maxTransferAttempts {
|
||||||
|
if attempt > 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..<Self.maxTransferAttempts {
|
||||||
|
if attempt > 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
|
// MARK: - Publish / Share
|
||||||
|
|
||||||
func publish(path: String, settings: ShareAccessSettings) async throws -> String {
|
func publish(path: String, settings: ShareAccessSettings) async throws -> String {
|
||||||
|
|||||||
@@ -192,7 +192,7 @@ final class SyncEngine: ObservableObject {
|
|||||||
if !exists {
|
if !exists {
|
||||||
if itemMode == .everywhere || itemMode == .computerOnly {
|
if itemMode == .everywhere || itemMode == .computerOnly {
|
||||||
appendTask(kind: .download, path: item.path)
|
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)
|
finishLastTask(success: true)
|
||||||
}
|
}
|
||||||
} else if itemMode == .everywhere {
|
} else if itemMode == .everywhere {
|
||||||
@@ -201,7 +201,7 @@ final class SyncEngine: ObservableObject {
|
|||||||
let remoteSize = item.size ?? -1
|
let remoteSize = item.size ?? -1
|
||||||
if remoteSize >= 0, localSize != remoteSize, localSize < remoteSize {
|
if remoteSize >= 0, localSize != remoteSize, localSize < remoteSize {
|
||||||
appendTask(kind: .download, path: item.path)
|
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)
|
finishLastTask(success: true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -223,7 +223,7 @@ final class SyncEngine: ObservableObject {
|
|||||||
try await uploadLocalChanges(cloudPath: childCloud, api: api)
|
try await uploadLocalChanges(cloudPath: childCloud, api: api)
|
||||||
} else {
|
} else {
|
||||||
appendTask(kind: .upload, path: childCloud)
|
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)
|
finishLastTask(success: true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -255,7 +255,7 @@ final class SyncEngine: ObservableObject {
|
|||||||
let existsRemotely = (try? await api.resource(path: childCloud)) != nil
|
let existsRemotely = (try? await api.resource(path: childCloud)) != nil
|
||||||
if !existsRemotely {
|
if !existsRemotely {
|
||||||
appendTask(kind: .upload, path: childCloud)
|
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)
|
finishLastTask(success: true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<URL, Error>?
|
||||||
|
Task.detached(priority: .userInitiated) {
|
||||||
|
let result: Result<URL, Error>
|
||||||
|
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
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
|
import CoreTransferable
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import AppKit
|
import UniformTypeIdentifiers
|
||||||
|
|
||||||
struct MainSplitView: View {
|
struct MainSplitView: View {
|
||||||
@Environment(AppStore.self) private var store
|
@Environment(AppStore.self) private var store
|
||||||
@@ -244,6 +245,9 @@ struct MainSplitView: View {
|
|||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.draggable(dragItem(for: item)) {
|
||||||
|
dragPreview(for: item)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
TableColumn("Размер") { item in
|
TableColumn("Размер") { item in
|
||||||
Text(item.isDirectory ? "—" : ByteFormatter.string(info: item.displaySize))
|
Text(item.isDirectory ? "—" : ByteFormatter.string(info: item.displaySize))
|
||||||
@@ -277,6 +281,24 @@ struct MainSplitView: View {
|
|||||||
Task { await store.uploadFiles(from: files) }
|
Task { await store.uploadFiles(from: files) }
|
||||||
return true
|
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 {
|
private func icon(for item: DiskResource) -> String {
|
||||||
if item.isDirectory { return "folder.fill" }
|
if item.isDirectory { return "folder.fill" }
|
||||||
let ext = (item.displayName as NSString).pathExtension.lowercased()
|
let ext = (item.displayName as NSString).pathExtension.lowercased()
|
||||||
@@ -372,7 +406,8 @@ struct TasksView: View {
|
|||||||
@Environment(AppStore.self) private var store
|
@Environment(AppStore.self) private var store
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
if store.syncEngine.tasks.isEmpty {
|
let transfers = store.transfer.items
|
||||||
|
if transfers.isEmpty {
|
||||||
VStack(spacing: 10) {
|
VStack(spacing: 10) {
|
||||||
Image(systemName: "arrow.triangle.2.circlepath")
|
Image(systemName: "arrow.triangle.2.circlepath")
|
||||||
.font(.system(size: 36))
|
.font(.system(size: 36))
|
||||||
@@ -382,28 +417,54 @@ struct TasksView: View {
|
|||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
} else {
|
} else {
|
||||||
List(store.syncEngine.tasks) { task in
|
List(transfers) { item in
|
||||||
HStack {
|
HStack(spacing: 10) {
|
||||||
Image(systemName: task.kind == .upload ? "arrow.up.circle" : "arrow.down.circle")
|
Image(systemName: item.kind.systemImage)
|
||||||
.foregroundStyle(task.isError ? Color.red : Color.accentColor)
|
.foregroundStyle(item.state == .failed ? Color.red : Color.accentColor)
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 3) {
|
||||||
Text((task.path as NSString).lastPathComponent)
|
Text(displayName(for: item))
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
Text(task.kind.title)
|
Text("\(item.kind.title) — \(item.state.title)")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.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()
|
Spacer()
|
||||||
if task.isFinished {
|
stateBadge(item)
|
||||||
Image(systemName: task.isError ? "xmark.circle.fill" : "checkmark.circle.fill")
|
|
||||||
.foregroundStyle(task.isError ? .red : .green)
|
|
||||||
} else {
|
|
||||||
ProgressView()
|
|
||||||
.controlSize(.small)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.padding(.vertical, 2)
|
.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)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user