10 changed files with 927 additions and 179 deletions
+1 -1
View File
@@ -7,7 +7,7 @@
- Авторизация OAuth (токен в Keychain) - Авторизация OAuth (токен в Keychain)
- Просмотр Диска: все файлы и папки, «Недавние», «Корзина» - Просмотр Диска: все файлы и папки, «Недавние», «Корзина»
- **Избранное**: правый клик → «В избранное» (метка хранится локально, звезда в списке) - **Избранное**: правый клик → «В избранное» (метка хранится локально, звезда в списке)
- **Доступные мне**: реестр публичных ссылок, полученных от других пользователей (кнопка «+» на вкладке; открытие в браузере, копирование, удаление) - **Доступные мне**: папки, к которым доступ дали другие пользователи (обнаружение по полю `share.is_owned == false` в листинге API; права rw/r в контекстном меню)
- Чтение / скачивание, загрузка (кнопка и drag-and-drop) - Чтение / скачивание, загрузка (кнопка и drag-and-drop)
- Копирование, перемещение, переименование, дублирование - Копирование, перемещение, переименование, дублирование
- Удаление в Корзину, восстановление, очистка Корзины - Удаление в Корзину, восстановление, очистка Корзины
+3 -3
View File
@@ -7,17 +7,17 @@
<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>
<string>YanDisk</string> <string>YanDisk</string>
<key>CFBundleDisplayName</key> <key>CFBundleDisplayName</key>
<string>YanDisk</string> <string>YanDisk Beta</string>
<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>
+78 -78
View File
@@ -19,7 +19,7 @@ final class AppStore {
var title: String { var title: String {
switch self { switch self {
case .disk: return "YanDisk" case .disk: return "YanDisk"
case .files: return "Доступные мне" case .files: return "Общий доступ"
case .recents: return "Недавние" case .recents: return "Недавние"
case .favorites: return "Избранное" case .favorites: return "Избранное"
case .screenshots: return "Скриншоты" case .screenshots: return "Скриншоты"
@@ -72,10 +72,10 @@ 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"
private static let publicLinksKey = "PublicLinks"
var usedPercent: Double { var usedPercent: Double {
guard let info = diskInfo, let total = info.totalSpace, total > 0 else { return 0 } guard let info = diskInfo, let total = info.totalSpace, total > 0 else { return 0 }
@@ -97,6 +97,31 @@ 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() }
}
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 private var didBootstrap = false
@@ -110,7 +135,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()
} }
@@ -130,7 +157,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() }
} }
@@ -138,7 +167,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 = []
@@ -250,57 +281,32 @@ final class AppStore {
} }
} }
// MARK: - Shared with me (public links) // MARK: - Shared with me (field `share`, is_owned == false)
func addPublicLink(_ rawLink: String) async {
let link = rawLink.trimmingCharacters(in: .whitespacesAndNewlines)
let links = UserDefaults.standard.stringArray(forKey: Self.publicLinksKey) ?? []
let isDiskLink = link.contains("disk.yandex.") || link.contains("yadi.sk")
guard isDiskLink, !publicItems.contains(where: { $0.path == link }),
!links.contains(link) else {
errorMessage = "Некорректная публичная ссылка"
return
}
guard let api else { return }
isLoading = true
defer { isLoading = false }
do {
let resource = try await api.publicResource(publicKey: link)
publicItems.append(.presentedByLink(resource, link: link))
var links = UserDefaults.standard.stringArray(forKey: Self.publicLinksKey) ?? []
links.append(link)
UserDefaults.standard.set(links, forKey: Self.publicLinksKey)
statusMessage = "Добавлено: \(resource.displayName)"
errorMessage = nil
} catch {
handleError(error)
}
}
func removePublicLink(_ item: DiskResource) {
publicItems.removeAll { $0.path == item.path }
var links = UserDefaults.standard.stringArray(forKey: Self.publicLinksKey) ?? []
links.removeAll { $0 == item.path }
UserDefaults.standard.set(links, forKey: Self.publicLinksKey)
statusMessage = "Убрано: \(item.displayName)"
}
/// Сначала показываем общие папки корня (быстро), затем углубляем скан в фоне.
func loadPublicItems() async { func loadPublicItems() async {
guard let api else { return } guard let api else { return }
isLoading = true isLoading = true
defer { isLoading = false } defer { isLoading = false }
let links = UserDefaults.standard.stringArray(forKey: Self.publicLinksKey) ?? [] do {
if links.count != publicItems.count { let shallow = try await api.sharedWithMe(maxDepth: 0)
var loaded: [DiskResource] = [] publicItems = Self.sorted(shallow)
for link in links { errorMessage = nil
do { } catch {
let r = try await api.publicResource(publicKey: link) handleError(error)
loaded.append(.presentedByLink(r, link: link)) return
} catch { }
handleError(error) // Глубокий скан — в фоне, чтобы не блокировать вкладку.
} Task { [weak self] in
} guard let full = try? await api.sharedWithMe(maxDepth: 3) else { return }
publicItems = loaded 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
} }
} }
@@ -494,33 +500,37 @@ final class AppStore {
} }
func downloadToComputer(_ item: DiskResource) async { func downloadToComputer(_ item: DiskResource) async {
guard let api else { return } if item.isDirectory {
let panel = NSSavePanel() let panel = NSOpenPanel()
panel.nameFieldStringValue = item.displayName panel.canChooseDirectories = true
panel.canCreateDirectories = true panel.canCreateDirectories = true
guard panel.runModal() == .OK, let url = panel.url else { return } panel.prompt = "Сюда"
do { guard panel.runModal() == .OK, let dir = panel.url else { return }
try await api.downloadFile(path: item.path, to: url) transfer.enqueueDownload(resource: item, toDirectory: dir)
statusMessage = "Файл сохранён: \(url.lastPathComponent)" statusMessage = "Папка «\(item.displayName)» ставится в очередь скачивания"
NSWorkspace.shared.activateFileViewerSelecting([url]) } else {
} catch { let panel = NSSavePanel()
handleError(error) 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 { 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
@@ -583,16 +593,6 @@ final class AppStore {
} }
} }
extension DiskResource {
/// Ресурс, представленный публичной ссылкой: path = сама ссылка.
static func presentedByLink(_ resource: DiskResource, link: String) -> DiskResource {
DiskResource(
name: resource.name, created: resource.created, modified: resource.modified,
path: link, type: resource.type, md5: nil, size: resource.size, mimeType: resource.mimeType,
preview: resource.preview, publicKey: link, publicUrl: link, originPath: nil, embedded: nil)
}
}
enum ByteFormatter { enum ByteFormatter {
static func string(info: Int64) -> String { static func string(info: Int64) -> String {
let formatter = Foundation.ByteCountFormatter() let formatter = Foundation.ByteCountFormatter()
+176 -1
View File
@@ -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 {
@@ -371,4 +524,26 @@ actor DiskAPIClient {
let (data, _) = try await send(request) let (data, _) = try await send(request)
return try decode(DiskResource.self, from: data) return try decode(DiskResource.self, from: data)
} }
/// Ресурсы, к которым доступ дали другие пользователи.
/// Ищем по дереву поле `share` с `is_owned == false` (недокументированное, но в каждом ответе API).
func sharedWithMe(from root: String = "/", maxDepth: Int = 3) async throws -> [DiskResource] {
var result: [DiskResource] = []
func walk(_ path: String, depth: Int) async throws {
guard depth <= maxDepth else { return }
for item in try await listDirectory(path: path) {
if item.share?.isSharedWithMe == true {
result.append(item)
continue // внутрь чужих общих папок не заходим — это отдельные корни
}
if item.isDirectory {
try await walk(item.path, depth: depth + 1)
}
}
}
try await walk(root, depth: 0)
return result
}
} }
+18 -1
View File
@@ -5,6 +5,22 @@ enum ResourceType: String, Codable, Sendable {
case dir case dir
} }
/// Информация об общем доступе к ресурсу (недокументированное поле `share`).
struct ShareInfo: Codable, Hashable, Sendable {
let rights: String?
let isOwned: Bool?
let isRoot: Bool?
enum CodingKeys: String, CodingKey {
case rights
case isOwned = "is_owned"
case isRoot = "is_root"
}
/// Ресурс, к которому доступ дали другие пользователи.
var isSharedWithMe: Bool { isOwned == false }
}
struct DiskResource: Codable, Identifiable, Hashable, Sendable { struct DiskResource: Codable, Identifiable, Hashable, Sendable {
let name: String? let name: String?
let created: String? let created: String?
@@ -18,10 +34,11 @@ struct DiskResource: Codable, Identifiable, Hashable, Sendable {
let publicKey: String? let publicKey: String?
let publicUrl: String? let publicUrl: String?
let originPath: String? let originPath: String?
let share: ShareInfo?
let embedded: ResourceList? let embedded: ResourceList?
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case name, created, modified, path, type, md5, size, preview case name, created, modified, path, type, md5, size, preview, share
case mimeType = "mime_type" case mimeType = "mime_type"
case publicKey = "public_key" case publicKey = "public_key"
case publicUrl = "public_url" case publicUrl = "public_url"
+4 -4
View File
@@ -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,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)
}
}
}
}
+164 -89
View File
@@ -1,13 +1,12 @@
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
@Environment(\.openWindow) private var openWindow @Environment(\.openWindow) private var openWindow
@State private var showNewFolderAlert = false @State private var showNewFolderAlert = false
@State private var newFolderName = "" @State private var newFolderName = ""
@State private var showAddLinkAlert = false
@State private var newLinkText = ""
@State private var renameItem: DiskResource? @State private var renameItem: DiskResource?
@State private var renameText = "" @State private var renameText = ""
@@ -45,6 +44,7 @@ struct MainSplitView: View {
if let status = store.statusMessage { if let status = store.statusMessage {
Divider() Divider()
HStack { HStack {
TransferActivityIndicator()
Image(systemName: "checkmark.circle") Image(systemName: "checkmark.circle")
Text(status) Text(status)
Spacer() Spacer()
@@ -53,19 +53,20 @@ struct MainSplitView: View {
.padding(.horizontal, 12) .padding(.horizontal, 12)
.padding(.vertical, 6) .padding(.vertical, 6)
.background(.thinMaterial) .background(.thinMaterial)
} else {
Divider()
HStack {
TransferActivityIndicator()
Spacer()
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(.thinMaterial)
} }
} }
} }
.toolbar { .toolbar {
ToolbarItemGroup { ToolbarItemGroup {
if store.sidebarSelection == .files {
Button {
showAddLinkAlert = true
} label: {
Label("Добавить публичную ссылку", systemImage: "plus")
}
}
Button { Button {
showNewFolderAlert = true showNewFolderAlert = true
} label: { } label: {
@@ -126,17 +127,6 @@ struct MainSplitView: View {
} }
Button("Отмена", role: .cancel) {} Button("Отмена", role: .cancel) {}
} }
.alert("Публичная ссылка", isPresented: $showAddLinkAlert) {
TextField("https://disk.yandex.ru/d/…", text: $newLinkText)
Button("Добавить") {
let link = newLinkText
newLinkText = ""
Task { await store.addPublicLink(link) }
}
Button("Отмена", role: .cancel) {}
} message: {
Text("Вставьте ссылку, которой поделился другой пользователь (папка «Общий доступ» на disk.yandex.ru → «Поделиться ссылкой»).")
}
.alert("Переименовать", isPresented: Binding( .alert("Переименовать", isPresented: Binding(
get: { renameItem != nil }, get: { renameItem != nil },
set: { if !$0 { renameItem = nil } } set: { if !$0 { renameItem = nil } }
@@ -152,6 +142,16 @@ struct MainSplitView: View {
Button("Отмена", role: .cancel) { renameItem = nil } Button("Отмена", role: .cancel) { renameItem = nil }
} }
.onAppear { store.bootstrap() } .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 .onChange(of: store.sidebarSelection) { _, newValue in
store.selectSidebar(newValue) store.selectSidebar(newValue)
} }
@@ -192,7 +192,7 @@ struct MainSplitView: View {
case .recents: return "Недавние" case .recents: return "Недавние"
case .favorites: return "Избранное" case .favorites: return "Избранное"
case .screenshots: return "Скриншоты" case .screenshots: return "Скриншоты"
case .files: return "Доступные мне" case .files: return "Общий доступ"
default: default:
if store.currentPath == "/" { return "YanDisk" } if store.currentPath == "/" { return "YanDisk" }
return store.currentPath return store.currentPath
@@ -222,7 +222,7 @@ struct MainSplitView: View {
switch store.sidebarSelection { switch store.sidebarSelection {
case .trash: return "Корзина пуста" case .trash: return "Корзина пуста"
case .favorites: return "Нет избранного — правый клик по файлу → «В избранное»" case .favorites: return "Нет избранного — правый клик по файлу → «В избранное»"
case .files: return "Нет общих ссылок — нажмите «+», чтобы добавить публичную ссылку" case .files: return "Нет папок с общим доступом"
default: return "Папка пуста" default: return "Папка пуста"
} }
} }
@@ -243,64 +243,80 @@ struct MainSplitView: View {
} }
.frame(maxWidth: .infinity, maxHeight: .infinity) .frame(maxWidth: .infinity, maxHeight: .infinity)
} else { } else {
Table(items, selection: Binding( List(selection: Binding(
get: { store.selection }, get: { store.selection },
set: { store.selection = $0 } set: { store.selection = $0 }
)) { )) {
TableColumn("Имя") { item in ForEach(items) { item in
HStack(spacing: 8) { row(store: store, item: item)
Image(systemName: icon(for: item)) .tag(item.path)
.foregroundStyle(item.isDirectory ? Color.accentColor : .secondary)
.frame(width: 18)
Text(item.displayName)
.lineLimit(1)
if store.sidebarSelection != .files && store.isFavorite(item) {
Image(systemName: "star.fill")
.font(.caption2)
.foregroundStyle(.yellow)
}
if item.publicUrl != nil {
Image(systemName: "link")
.font(.caption2)
.foregroundStyle(.secondary)
}
}
}
TableColumn("Размер") { item in
Text(item.isDirectory ? "—" : ByteFormatter.string(info: item.displaySize))
.foregroundStyle(.secondary)
}
.width(100)
TableColumn("Изменён") { item in
Text(formattedDate(item.modified))
.foregroundStyle(.secondary)
}
.width(140)
TableColumn("Синхр.") { item in
Text(store.syncEngine.policy.mode(for: item.path).shortTitle)
.foregroundStyle(.secondary)
.font(.caption)
}
.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)
} }
} }
.listStyle(.inset(alternatesRowBackgrounds: true))
.dropDestination(for: URL.self) { urls, _ in .dropDestination(for: URL.self) { urls, _ in
let files = urls.filter { $0.isFileURL } let files = urls.filter { $0.isFileURL }
guard !files.isEmpty else { return false } guard !files.isEmpty else { return false }
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
}
} }
} }
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) {
Image(systemName: "star.fill")
.font(.caption2)
.foregroundStyle(.yellow)
}
if item.publicUrl != nil {
Image(systemName: "link")
.font(.caption2)
.foregroundStyle(.secondary)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
Text(item.isDirectory ? "—" : ByteFormatter.string(info: item.displaySize))
.foregroundStyle(.secondary)
.frame(width: 100, alignment: .trailing)
Text(formattedDate(item.modified))
.foregroundStyle(.secondary)
.frame(width: 140, alignment: .leading)
Text(store.syncEngine.policy.mode(for: item.path).shortTitle)
.foregroundStyle(.secondary)
.font(.caption)
.frame(width: 100, alignment: .leading)
}
.contentShape(Rectangle())
.onDrag { CloudDrag.provider(for: item) }
.contextMenu { contextMenu(store: store, item: item) }
.onTapGesture(count: 2) { store.open(item) }
}
@ViewBuilder @ViewBuilder
private func contextMenu(store: AppStore, item: DiskResource) -> some View { private func contextMenu(store: AppStore, item: DiskResource) -> some View {
if store.sidebarSelection == .trash { if store.sidebarSelection == .trash {
@@ -308,15 +324,18 @@ struct MainSplitView: View {
Task { await store.restoreFromTrash(item) } Task { await store.restoreFromTrash(item) }
} }
} else if store.sidebarSelection == .files { } else if store.sidebarSelection == .files {
Button("Открыть в браузере") { store.open(item) } Button("Открыть") { store.open(item) }
Button("Скопировать ссылку") { if let url = item.publicUrl, !url.isEmpty {
NSPasteboard.general.clearContents() Button("Скопировать публичную ссылку") {
NSPasteboard.general.setString(item.path, forType: .string) NSPasteboard.general.clearContents()
NSPasteboard.general.setString(url, forType: .string)
}
} }
Divider() Divider()
Button("Убрать из «Доступные мне»", role: .destructive) { Menu("Права доступа") {
store.removePublicLink(item) Text(item.share?.rights == "rw" ? "Редактирование (rw)" : "Только просмотр (r)")
} }
.disabled(true)
} else { } else {
Button(store.isFavorite(item) ? "Убрать из избранного" : "В избранное") { Button(store.isFavorite(item) ? "Убрать из избранного" : "В избранное") {
store.toggleFavorite(item) store.toggleFavorite(item)
@@ -386,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 { 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))
@@ -400,28 +449,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)
}
}
} }
+2 -2
View File
@@ -12,9 +12,9 @@
| 4 | Режим синхронизации по умолчанию — «Только в облако» (`SyncPolicyStore.globalMode = .cloudOnly`) | `SyncEngine.swift` | | 4 | Режим синхронизации по умолчанию — «Только в облако» (`SyncPolicyStore.globalMode = .cloudOnly`) | `SyncEngine.swift` |
### По пункту 3 — «Доступные мне» ### По пункту 3 — «Доступные мне»
В открытом REST API Яндекс.Диска (`cloud-api.yandex.net`) нет эндпоинта «файлы, которыми поделились со мной». Проверено эмпирически (15.09.2026): web-API `disk.yandex.ru/api/v2` за сессией+капчей (OAuth-токен не принимается, 302 → captcha/passport), в WebDAV и в дереве Диска общие папки не отображаются, в `system_folders` соответствующего пути нет. В документации REST API нет эндпоинта «файлы, которыми поделились со мной», и web-API `disk.yandex.ru/api/v2` требует браузерную сессию (302 → капча).
**Реализовано (v1.0.2):** вкладка «Доступные мне» = реестр публичных ссылок, добавленных пользователем (кнопка «+»); метаданные тянутся официальным `GET /v1/disk/public/resources?public_key=…`, открытие в браузере, копирование и удаление ссылки из реестра. Хранилище — UserDefaults (`PublicLinks`). **Найдено и реализовано (v1.0.3):** в ответе `GET /v1/disk/resources` (листинг любого каталога) каждый ресурс несёт недокументированное поле `share: {rights, is_owned, is_root}`. Папки, к которым доступ дали другие пользователи, лежат прямо в дереве Диска с `is_owned: false`. `DiskAPIClient.sharedWithMe()` обходит дерево (до глубины 3, внутрь чужих общих корней не заходит) и собирает все ресурсы с `is_owned == false`. Вкладка «Доступные мне» показывает их с правами (`rw`/`r`) в контекстном меню.
--- ---