7 changed files with 869 additions and 94 deletions
+3 -3
View File
@@ -7,17 +7,17 @@
<key>CFBundleExecutable</key>
<string>YanDisk</string>
<key>CFBundleIdentifier</key>
<string>local.yandisk.app</string>
<string>local.yandisk.beta</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>YanDisk</string>
<key>CFBundleDisplayName</key>
<string>YanDisk</string>
<string>YanDisk Beta</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.0.0</string>
<string>1.1.0-beta</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>LSMinimumSystemVersion</key>
+68 -20
View File
@@ -19,7 +19,7 @@ final class AppStore {
var title: String {
switch self {
case .disk: return "YanDisk"
case .files: return "Доступные мне"
case .files: return "Общий доступ"
case .recents: return "Недавние"
case .favorites: return "Избранное"
case .screenshots: return "Скриншоты"
@@ -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,31 @@ final class AppStore {
if let paths = UserDefaults.standard.stringArray(forKey: Self.favoritesKey) {
favoritePaths = Set(paths)
}
transfer.onQueueIdle = { [weak self] in
Task { await self?.reloadAfterTransfer() }
}
TransferBridge.shared.transfer = transfer
}
// MARK: - Copy / Paste (⌘C — обещания файлов в Finder, ⌘V — загрузка)
func copySelectionToPasteboard() {
let selected = visibleSelection
guard !selected.isEmpty else { return }
CloudPasteboard.copy(items: selected)
statusMessage = "Скопировано в буфер: \(selected.count). Вставь ⌘V в Finder — файл скачается."
}
func pasteFromPasteboard() {
let urls = CloudPasteboard.pasteboardFileURLs()
guard !urls.isEmpty else { return }
Task { await uploadFiles(from: urls) }
}
/// Выбранные ресурсы (по path) среди видимых элементов.
var visibleSelection: [DiskResource] {
let all = items
return all.filter { selection.contains($0.path) }
}
private var didBootstrap = false
@@ -109,7 +135,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 +157,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 +167,9 @@ final class AppStore {
KeychainStore.deleteToken()
token = nil
api = nil
TransferBridge.shared.client = nil
syncEngine.detach()
transfer.detach()
items = []
trashItems = []
recentItems = []
@@ -251,18 +283,30 @@ final class AppStore {
// MARK: - Shared with me (field `share`, is_owned == false)
/// Сначала показываем общие папки корня (быстро), затем углубляем скан в фоне.
func loadPublicItems() async {
guard let api else { return }
isLoading = true
defer { isLoading = false }
do {
let shared = try await api.sharedWithMe()
publicItems = shared.sorted { a, b in
a.displayName.localizedCaseInsensitiveCompare(b.displayName) == .orderedAscending
}
let shallow = try await api.sharedWithMe(maxDepth: 0)
publicItems = Self.sorted(shallow)
errorMessage = nil
} catch {
handleError(error)
return
}
// Глубокий скан — в фоне, чтобы не блокировать вкладку.
Task { [weak self] in
guard let full = try? await api.sharedWithMe(maxDepth: 3) else { return }
guard let self, self.isAuthorized else { return }
self.publicItems = Self.sorted(full)
}
}
private static func sorted(_ items: [DiskResource]) -> [DiskResource] {
items.sorted {
$0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending
}
}
@@ -456,33 +500,37 @@ final class AppStore {
}
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()
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)
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
+154 -1
View File
@@ -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 (устойчивы к обрывам)
/// Скачивание с докачкой: файл пишется в `<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
func publish(path: String, settings: ShareAccessSettings) async throws -> String {
+4 -4
View File
@@ -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)
}
}
@@ -0,0 +1,183 @@
import AppKit
import Foundation
import UniformTypeIdentifiers
/// Мост для экспорта облачных файлов при drag & drop и копировании (⌘C):
/// драг/пастборд даёт Finder «обещание» файла (NSFilePromiseProvider),
/// реальное скачивание начинается в момент дропа/вставки — без блокировок.
@MainActor
final class TransferBridge {
static let shared = TransferBridge()
var client: DiskAPIClient?
weak var transfer: TransferManager?
/// Скачать ресурс во временную папку и вернуть готовый файл/архив
/// (для драгов через NSItemProvider).
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("Не удалось упаковать папку в архив")
}
}
}
// MARK: - Drag source (NSItemProvider с ленивой file representation)
/// NSItemProvider для драга облачного ресурса: файл скачивается только
/// тогда, когда получатель (Finder) запросит его — на drop.
@MainActor
enum CloudDrag {
static func provider(for item: DiskResource) -> NSItemProvider {
let provider = NSItemProvider()
let name = item.isDirectory ? "\(item.displayName).zip" : item.displayName
provider.suggestedName = name
let ext = (item.displayName as NSString).pathExtension.lowercased()
let type = item.isDirectory
? UTType.zip
: (UTType(filenameExtension: ext) ?? .data)
provider.registerFileRepresentation(
forTypeIdentifier: type.identifier,
fileOptions: [],
visibility: .all
) { completion in
Task.detached(priority: .userInitiated) {
do {
let url = try await TransferBridge.shared.materialize(
path: item.path,
name: name,
isDirectory: item.isDirectory
)
completion(url, false, nil)
} catch {
completion(nil, false, error)
}
}
return nil
}
return provider
}
}
// MARK: - Pasteboard promises (⌘C → ⌘V в Finder)
/// Делегат NSFilePromiseProvider: по запросу получателя скачивает ресурс
/// точно в указанное место (через очередь передач — с докачкой и прогрессом).
final class CloudPromiseDelegate: NSObject, NSFilePromiseProviderDelegate {
let remotePath: String
let displayName: String
let isDirectory: Bool
init(remotePath: String, displayName: String, isDirectory: Bool) {
self.remotePath = remotePath
self.displayName = displayName
self.isDirectory = isDirectory
super.init()
}
func filePromiseProvider(_ filePromiseProvider: NSFilePromiseProvider, fileNameForType fileType: String) -> String {
isDirectory ? "\(displayName).zip" : displayName
}
func operationQueue(for filePromiseProvider: NSFilePromiseProvider) -> OperationQueue {
.main
}
func filePromiseProvider(_ filePromiseProvider: NSFilePromiseProvider, writePromiseTo url: URL, completionHandler: @escaping (Error?) -> Void) {
Task { @MainActor in
let fileName = isDirectory ? "\(displayName).zip" : displayName
if isDirectory {
// Папка: скачиваем во временную папку, zip'уем, кладём в url.
Task.detached(priority: .userInitiated) { [remotePath, displayName] in
do {
let zip = try await TransferBridge.shared.materialize(path: remotePath, name: displayName, isDirectory: true)
let fm = FileManager.default
if fm.fileExists(atPath: url.path) { try fm.removeItem(at: url) }
try fm.moveItem(at: zip, to: url)
await MainActor.run { completionHandler(nil) }
} catch {
await MainActor.run { completionHandler(error) }
}
}
} else {
guard let transfer = TransferBridge.shared.transfer else {
completionHandler(DiskAPIError.unauthorized)
return
}
transfer.enqueuePromisedDownload(remotePath: remotePath, destFile: url.appendingPathComponent(fileName)) { error in
completionHandler(error)
}
}
}
}
func promiseProvider() -> NSFilePromiseProvider {
let ext = (displayName as NSString).pathExtension.lowercased()
let type = isDirectory
? UTType.zip.identifier
: (UTType(filenameExtension: ext) ?? UTType.data).identifier
return NSFilePromiseProvider(fileType: type, delegate: self)
}
}
@MainActor
enum CloudPasteboard {
/// ⌘C: положить в пастборд обещания файлов для выбранных ресурсов.
static func copy(items: [DiskResource]) {
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
let providers: [NSFilePromiseProvider] = items.map { item in
CloudPromiseDelegate(
remotePath: item.path,
displayName: item.displayName,
isDirectory: item.isDirectory
).promiseProvider()
}
pasteboard.writeObjects(providers)
}
/// ⌘V внутри приложения: файлы из пастборда — в очередь загрузки.
static func pasteboardFileURLs() -> [URL] {
let pasteboard = NSPasteboard.general
guard let urls = pasteboard.readObjects(forClasses: [NSURL.self], options: [.urlReadingFileURLsOnly: true]) as? [URL] else {
return []
}
return urls.filter { $0.isFileURL }
}
}
@@ -0,0 +1,298 @@
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 var completions: [UUID: (Error?) -> Void] = [:]
// MARK: - Индикаторы активности (⬇ зелёный / ⬆ оранжевый)
struct ActivityIndicator: Equatable {
var isActive = false
var progress: Double = 0
var isIndeterminate = true
}
var downloadActivity: ActivityIndicator {
activity(for: .download)
}
var uploadActivity: ActivityIndicator {
activity(for: .upload)
}
private func activity(for kind: TransferItem.Kind) -> ActivityIndicator {
let active = items.filter { $0.kind == kind && ($0.state == .queued || $0.state == .running) }
guard !active.isEmpty else { return ActivityIndicator() }
let done = active.reduce(Int64(0)) { $0 + $1.bytesDone }
let total = active.reduce(Int64(0)) { $0 + $1.bytesTotal }
if total > 0, done >= 0 {
return ActivityIndicator(isActive: true, progress: min(1, Double(done) / Double(total)), isIndeterminate: false)
}
return ActivityIndicator(isActive: true, progress: 0, isIndeterminate: true)
}
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
}
/// Скачивание по промису (⌘C → ⌘V в Finder): файл кладётся ровно в destFile,
/// completion вызывается по завершении (Finder ждёт его).
@discardableResult
func enqueuePromisedDownload(remotePath: String, destFile: URL, completion: @escaping (Error?) -> Void) -> TransferItem {
let item = TransferItem(
id: UUID(), kind: .download, remotePath: remotePath,
destPath: destFile.path
)
completions[item.id] = completion
items.insert(item, at: 0)
if items.count > 200 { items.removeLast(items.count - 200) }
persist()
kick()
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
}
let finishedItem = items[index]
if let completion = completions.removeValue(forKey: finishedItem.id) {
completion(finishedItem.state == .completed ? nil : DiskAPIError.transport(finishedItem.error ?? "Передача не выполнена"))
}
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)
}
}
}
}
+138 -45
View File
@@ -1,5 +1,6 @@
import CoreTransferable
import SwiftUI
import AppKit
import UniformTypeIdentifiers
struct MainSplitView: View {
@Environment(AppStore.self) private var store
@@ -43,6 +44,7 @@ struct MainSplitView: View {
if let status = store.statusMessage {
Divider()
HStack {
TransferActivityIndicator()
Image(systemName: "checkmark.circle")
Text(status)
Spacer()
@@ -51,6 +53,15 @@ struct MainSplitView: View {
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(.thinMaterial)
} else {
Divider()
HStack {
TransferActivityIndicator()
Spacer()
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(.thinMaterial)
}
}
}
@@ -131,6 +142,16 @@ struct MainSplitView: View {
Button("Отмена", role: .cancel) { renameItem = nil }
}
.onAppear { store.bootstrap() }
.background {
// Горячие клавиши: ⌘C — обещания файлов в буфер (⌘V в Finder скачает),
// ⌘V — файлы из буфера в очередь загрузки.
Button("Копировать") { store.copySelectionToPasteboard() }
.keyboardShortcut("c", modifiers: .command)
.opacity(0)
Button("Вставить") { store.pasteFromPasteboard() }
.keyboardShortcut("v", modifiers: .command)
.opacity(0)
}
.onChange(of: store.sidebarSelection) { _, newValue in
store.selectSidebar(newValue)
}
@@ -171,7 +192,7 @@ struct MainSplitView: View {
case .recents: return "Недавние"
case .favorites: return "Избранное"
case .screenshots: return "Скриншоты"
case .files: return "Доступные мне"
case .files: return "Общий доступ"
default:
if store.currentPath == "/" { return "YanDisk" }
return store.currentPath
@@ -222,15 +243,49 @@ struct MainSplitView: View {
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
Table(items, selection: Binding(
List(selection: Binding(
get: { store.selection },
set: { store.selection = $0 }
)) {
TableColumn("Имя") { item in
ForEach(items) { item in
row(store: store, item: item)
.tag(item.path)
}
}
.listStyle(.inset(alternatesRowBackgrounds: true))
.dropDestination(for: URL.self) { urls, _ in
let files = urls.filter { $0.isFileURL }
guard !files.isEmpty else { return false }
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
}
}
}
private func row(store: AppStore, item: DiskResource) -> some View {
HStack(spacing: 8) {
Image(systemName: icon(for: item))
.foregroundStyle(item.isDirectory ? Color.accentColor : .secondary)
.frame(width: 18)
HStack(spacing: 4) {
Text(item.displayName)
.lineLimit(1)
if store.sidebarSelection != .files && store.isFavorite(item) {
@@ -244,40 +299,22 @@ struct MainSplitView: View {
.foregroundStyle(.secondary)
}
}
}
TableColumn("Размер") { item in
.frame(maxWidth: .infinity, alignment: .leading)
Text(item.isDirectory ? "—" : ByteFormatter.string(info: item.displaySize))
.foregroundStyle(.secondary)
}
.width(100)
TableColumn("Изменён") { item in
.frame(width: 100, alignment: .trailing)
Text(formattedDate(item.modified))
.foregroundStyle(.secondary)
}
.width(140)
TableColumn("Синхр.") { item in
.frame(width: 140, alignment: .leading)
Text(store.syncEngine.policy.mode(for: item.path).shortTitle)
.foregroundStyle(.secondary)
.font(.caption)
.frame(width: 100, alignment: .leading)
}
.width(100)
}
.contextMenu(forSelectionType: String.self) { ids in
if let path = ids.first, let item = items.first(where: { $0.path == path }) {
contextMenu(store: store, item: item)
}
} primaryAction: { ids in
if let path = ids.first, let item = items.first(where: { $0.path == path }) {
store.open(item)
}
}
.dropDestination(for: URL.self) { urls, _ in
let files = urls.filter { $0.isFileURL }
guard !files.isEmpty else { return false }
Task { await store.uploadFiles(from: files) }
return true
}
}
.contentShape(Rectangle())
.onDrag { CloudDrag.provider(for: item) }
.contextMenu { contextMenu(store: store, item: item) }
.onTapGesture(count: 2) { store.open(item) }
}
@ViewBuilder
@@ -368,11 +405,41 @@ struct MainSplitView: View {
}
}
struct TransferActivityIndicator: View {
@Environment(AppStore.self) private var store
var body: some View {
HStack(spacing: 12) {
gauge(color: .green, icon: "arrow.down", activity: store.transfer.downloadActivity, label: "Download")
gauge(color: .orange, icon: "arrow.up", activity: store.transfer.uploadActivity, label: "Upload")
}
}
private func gauge(color: Color, icon: String, activity: TransferManager.ActivityIndicator, label: String) -> some View {
HStack(spacing: 5) {
Image(systemName: icon)
.foregroundStyle(activity.isActive ? color : color.opacity(0.35))
Group {
if activity.isIndeterminate || !activity.isActive {
ProgressView(value: activity.isActive ? 0.05 : 0)
.tint(color.opacity(activity.isActive ? 1 : 0.25))
} else {
ProgressView(value: activity.progress)
.tint(color)
}
}
.frame(width: 90)
}
.help("\(label): \(activity.isActive ? "идёт передача" : "нет активности")")
}
}
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 +449,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)
}
}
}