Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
632cd992bc | ||
|
|
7eab140e92 | ||
|
|
73afb23842 | ||
|
|
f2160a7768 | ||
|
|
19e8360712 |
@@ -5,7 +5,9 @@
|
|||||||
## Возможности
|
## Возможности
|
||||||
|
|
||||||
- Авторизация OAuth (токен в Keychain)
|
- Авторизация OAuth (токен в Keychain)
|
||||||
- Просмотр Диска: все файлы и папки, «Недавние», «Корзина», «Доступные мне» (в разработке)
|
- Просмотр Диска: все файлы и папки, «Недавние», «Корзина»
|
||||||
|
- **Избранное**: правый клик → «В избранное» (метка хранится локально, звезда в списке)
|
||||||
|
- **Доступные мне**: папки, к которым доступ дали другие пользователи (обнаружение по полю `share.is_owned == false` в листинге API; права rw/r в контекстном меню)
|
||||||
- Чтение / скачивание, загрузка (кнопка и drag-and-drop)
|
- Чтение / скачивание, загрузка (кнопка и drag-and-drop)
|
||||||
- Копирование, перемещение, переименование, дублирование
|
- Копирование, перемещение, переименование, дублирование
|
||||||
- Удаление в Корзину, восстановление, очистка Корзины
|
- Удаление в Корзину, восстановление, очистка Корзины
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 "Скриншоты"
|
||||||
@@ -54,6 +54,9 @@ final class AppStore {
|
|||||||
var items: [DiskResource] = []
|
var items: [DiskResource] = []
|
||||||
var trashItems: [DiskResource] = []
|
var trashItems: [DiskResource] = []
|
||||||
var recentItems: [DiskResource] = []
|
var recentItems: [DiskResource] = []
|
||||||
|
var favoriteItems: [DiskResource] = []
|
||||||
|
var publicItems: [DiskResource] = [] // «Доступные мне»: path = публичная ссылка
|
||||||
|
var favoritePaths: Set<String> = []
|
||||||
var isLoading = false
|
var isLoading = false
|
||||||
var errorMessage: String?
|
var errorMessage: String?
|
||||||
var statusMessage: String?
|
var statusMessage: String?
|
||||||
@@ -69,8 +72,11 @@ 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"
|
||||||
|
|
||||||
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 }
|
||||||
return Double(info.usedSpace ?? 0) / Double(total)
|
return Double(info.usedSpace ?? 0) / Double(total)
|
||||||
@@ -88,6 +94,34 @@ final class AppStore {
|
|||||||
if let saved = KeychainStore.loadToken(), !saved.isEmpty {
|
if let saved = KeychainStore.loadToken(), !saved.isEmpty {
|
||||||
token = saved
|
token = saved
|
||||||
}
|
}
|
||||||
|
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
|
private var didBootstrap = false
|
||||||
@@ -101,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()
|
||||||
}
|
}
|
||||||
@@ -121,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() }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,10 +167,14 @@ 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 = []
|
||||||
|
favoriteItems = []
|
||||||
|
publicItems = []
|
||||||
diskInfo = nil
|
diskInfo = nil
|
||||||
showLoginSheet = true
|
showLoginSheet = true
|
||||||
}
|
}
|
||||||
@@ -161,13 +203,12 @@ final class AppStore {
|
|||||||
func selectSidebar(_ item: SidebarItem) {
|
func selectSidebar(_ item: SidebarItem) {
|
||||||
sidebarSelection = item
|
sidebarSelection = item
|
||||||
switch item {
|
switch item {
|
||||||
case .disk, .favorites:
|
case .disk:
|
||||||
navigate(to: "/")
|
navigate(to: "/")
|
||||||
|
case .favorites:
|
||||||
|
Task { await loadFavorites() }
|
||||||
case .files:
|
case .files:
|
||||||
// «Доступные мне» — пока заглушка, API shared-with-me в плане (см. ПРАВКИ.md)
|
Task { await loadPublicItems() }
|
||||||
items = []
|
|
||||||
currentPath = "/"
|
|
||||||
pathStack = ["/"]
|
|
||||||
case .recents:
|
case .recents:
|
||||||
Task { await loadRecents() }
|
Task { await loadRecents() }
|
||||||
case .screenshots:
|
case .screenshots:
|
||||||
@@ -186,12 +227,89 @@ final class AppStore {
|
|||||||
switch sidebarSelection {
|
switch sidebarSelection {
|
||||||
case .trash: await loadTrash()
|
case .trash: await loadTrash()
|
||||||
case .recents: await loadRecents()
|
case .recents: await loadRecents()
|
||||||
case .files: items = [] // «Доступные мне» — заглушка
|
case .favorites: await loadFavorites()
|
||||||
|
case .files: await loadPublicItems()
|
||||||
default: await loadDirectory(path: currentPath)
|
default: await loadDirectory(path: currentPath)
|
||||||
}
|
}
|
||||||
await syncEngine.syncNow()
|
await syncEngine.syncNow()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Favorites
|
||||||
|
|
||||||
|
private func saveFavorites() {
|
||||||
|
UserDefaults.standard.set(Array(favoritePaths).sorted(), forKey: Self.favoritesKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
func isFavorite(_ item: DiskResource) -> Bool {
|
||||||
|
favoritePaths.contains(item.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
func toggleFavorite(_ item: DiskResource) {
|
||||||
|
if favoritePaths.contains(item.path) {
|
||||||
|
favoritePaths.remove(item.path)
|
||||||
|
favoriteItems.removeAll { $0.path == item.path }
|
||||||
|
statusMessage = "Убрано из избранного: \(item.displayName)"
|
||||||
|
} else {
|
||||||
|
favoritePaths.insert(item.path)
|
||||||
|
statusMessage = "Добавлено в избранное: \(item.displayName)"
|
||||||
|
}
|
||||||
|
saveFavorites()
|
||||||
|
if sidebarSelection == .favorites {
|
||||||
|
Task { await loadFavorites() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadFavorites() async {
|
||||||
|
guard let api else { return }
|
||||||
|
isLoading = true
|
||||||
|
defer { isLoading = false }
|
||||||
|
var loaded: [DiskResource] = []
|
||||||
|
for path in favoritePaths.sorted() {
|
||||||
|
do {
|
||||||
|
loaded.append(try await api.resource(path: path))
|
||||||
|
} catch DiskAPIError.notFound {
|
||||||
|
// файл удалён — забываем метку
|
||||||
|
favoritePaths.remove(path)
|
||||||
|
} catch {
|
||||||
|
handleError(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if favoritePaths.isEmpty == false { saveFavorites() }
|
||||||
|
favoriteItems = loaded.sorted { a, b in
|
||||||
|
if a.isDirectory != b.isDirectory { return a.isDirectory }
|
||||||
|
return a.displayName.localizedCaseInsensitiveCompare(b.displayName) == .orderedAscending
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Shared with me (field `share`, is_owned == false)
|
||||||
|
|
||||||
|
/// Сначала показываем общие папки корня (быстро), затем углубляем скан в фоне.
|
||||||
|
func loadPublicItems() async {
|
||||||
|
guard let api else { return }
|
||||||
|
isLoading = true
|
||||||
|
defer { isLoading = false }
|
||||||
|
do {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func loadDiskInfo() async {
|
func loadDiskInfo() async {
|
||||||
guard let api else { return }
|
guard let api else { return }
|
||||||
do {
|
do {
|
||||||
@@ -254,6 +372,10 @@ final class AppStore {
|
|||||||
// MARK: - File operations
|
// MARK: - File operations
|
||||||
|
|
||||||
func open(_ item: DiskResource) {
|
func open(_ item: DiskResource) {
|
||||||
|
if item.path.hasPrefix("http") {
|
||||||
|
NSWorkspace.shared.open(URL(string: item.path)!)
|
||||||
|
return
|
||||||
|
}
|
||||||
if item.isDirectory {
|
if item.isDirectory {
|
||||||
navigate(to: item.path)
|
navigate(to: item.path)
|
||||||
} else {
|
} else {
|
||||||
@@ -378,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
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -361,4 +514,36 @@ actor DiskAPIClient {
|
|||||||
func filesList(limit: Int = 200) async throws -> [DiskResource] {
|
func filesList(limit: Int = 200) async throws -> [DiskResource] {
|
||||||
try await listDirectory(path: "/").filter { !$0.isDirectory }
|
try await listDirectory(path: "/").filter { !$0.isDirectory }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Public (shared) resources
|
||||||
|
|
||||||
|
/// Метаданные ресурса по публичной ссылке (официальный API).
|
||||||
|
func publicResource(publicKey: String) async throws -> DiskResource {
|
||||||
|
let query = [URLQueryItem(name: "public_key", value: publicKey)]
|
||||||
|
let request = try makeRequest(path: "public/resources", query: query)
|
||||||
|
let (data, _) = try await send(request)
|
||||||
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -43,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()
|
||||||
@@ -51,6 +53,15 @@ 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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -131,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)
|
||||||
}
|
}
|
||||||
@@ -171,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
|
||||||
@@ -182,11 +203,30 @@ struct MainSplitView: View {
|
|||||||
switch store.sidebarSelection {
|
switch store.sidebarSelection {
|
||||||
case .trash: return store.trashItems
|
case .trash: return store.trashItems
|
||||||
case .recents: return store.recentItems
|
case .recents: return store.recentItems
|
||||||
case .files: return [] // «Доступные мне» — заглушка до реализации API
|
case .favorites: return store.favoriteItems
|
||||||
|
case .files: return store.publicItems
|
||||||
default: return store.filteredItems
|
default: return store.filteredItems
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var emptyIcon: String {
|
||||||
|
switch store.sidebarSelection {
|
||||||
|
case .trash: return "trash"
|
||||||
|
case .favorites: return "star"
|
||||||
|
case .files: return "person.2"
|
||||||
|
default: return "folder"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var emptyText: String {
|
||||||
|
switch store.sidebarSelection {
|
||||||
|
case .trash: return "Корзина пуста"
|
||||||
|
case .favorites: return "Нет избранного — правый клик по файлу → «В избранное»"
|
||||||
|
case .files: return "Нет папок с общим доступом"
|
||||||
|
default: return "Папка пуста"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private func fileBrowser(store: AppStore) -> some View {
|
private func fileBrowser(store: AppStore) -> some View {
|
||||||
let items = visibleItems(store: store)
|
let items = visibleItems(store: store)
|
||||||
@@ -195,59 +235,43 @@ struct MainSplitView: View {
|
|||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
} else if items.isEmpty {
|
} else if items.isEmpty {
|
||||||
VStack(spacing: 12) {
|
VStack(spacing: 12) {
|
||||||
Image(systemName: store.sidebarSelection == .trash ? "trash" : "folder")
|
Image(systemName: emptyIcon)
|
||||||
.font(.system(size: 40))
|
.font(.system(size: 40))
|
||||||
.foregroundStyle(.tertiary)
|
.foregroundStyle(.tertiary)
|
||||||
Text(store.sidebarSelection == .trash ? "Корзина пуста" : "Папка пуста")
|
Text(emptyText)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
.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)
|
.listStyle(.inset(alternatesRowBackgrounds: true))
|
||||||
.lineLimit(1)
|
.dropDestination(for: URL.self) { urls, _ in
|
||||||
if item.publicUrl != nil {
|
let files = urls.filter { $0.isFileURL }
|
||||||
Image(systemName: "link")
|
guard !files.isEmpty else { return false }
|
||||||
.font(.caption2)
|
Task { await store.uploadFiles(from: files) }
|
||||||
.foregroundStyle(.secondary)
|
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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
TableColumn("Размер") { item in
|
group.wait()
|
||||||
Text(item.isDirectory ? "—" : ByteFormatter.string(info: item.displaySize))
|
guard !urls.isEmpty else { return false }
|
||||||
.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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.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) }
|
||||||
@@ -256,13 +280,66 @@ struct MainSplitView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
Button("Восстановить") {
|
Button("Восстановить") {
|
||||||
Task { await store.restoreFromTrash(item) }
|
Task { await store.restoreFromTrash(item) }
|
||||||
}
|
}
|
||||||
|
} else if store.sidebarSelection == .files {
|
||||||
|
Button("Открыть") { store.open(item) }
|
||||||
|
if let url = item.publicUrl, !url.isEmpty {
|
||||||
|
Button("Скопировать публичную ссылку") {
|
||||||
|
NSPasteboard.general.clearContents()
|
||||||
|
NSPasteboard.general.setString(url, forType: .string)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Divider()
|
||||||
|
Menu("Права доступа") {
|
||||||
|
Text(item.share?.rights == "rw" ? "Редактирование (rw)" : "Только просмотр (r)")
|
||||||
|
}
|
||||||
|
.disabled(true)
|
||||||
} else {
|
} else {
|
||||||
|
Button(store.isFavorite(item) ? "Убрать из избранного" : "В избранное") {
|
||||||
|
store.toggleFavorite(item)
|
||||||
|
}
|
||||||
Button("Открыть") { store.open(item) }
|
Button("Открыть") { store.open(item) }
|
||||||
Button("Показать в Finder") { store.revealInFinder(item) }
|
Button("Показать в Finder") { store.revealInFinder(item) }
|
||||||
Button("Создать копию на компьютере") {
|
Button("Создать копию на компьютере") {
|
||||||
@@ -328,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))
|
||||||
@@ -342,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
@@ -46,8 +46,8 @@ print("ok")
|
|||||||
'
|
'
|
||||||
|
|
||||||
echo "4) app bundle"
|
echo "4) app bundle"
|
||||||
APP="$ROOT/.build/YandexDisk.app"
|
APP="$ROOT/.build/YanDisk.app"
|
||||||
test -x "$APP/Contents/MacOS/YandexDiskClient"
|
test -x "$APP/Contents/MacOS/YanDisk"
|
||||||
plutil -lint "$APP/Contents/Info.plist"
|
plutil -lint "$APP/Contents/Info.plist"
|
||||||
codesign -dv "$APP" 2>&1 | head -5
|
codesign -dv "$APP" 2>&1 | head -5
|
||||||
echo "ALL SMOKE TESTS PASSED"
|
echo "ALL SMOKE TESTS PASSED"
|
||||||
|
|||||||
@@ -12,11 +12,9 @@
|
|||||||
| 4 | Режим синхронизации по умолчанию — «Только в облако» (`SyncPolicyStore.globalMode = .cloudOnly`) | `SyncEngine.swift` |
|
| 4 | Режим синхронизации по умолчанию — «Только в облако» (`SyncPolicyStore.globalMode = .cloudOnly`) | `SyncEngine.swift` |
|
||||||
|
|
||||||
### По пункту 3 — «Доступные мне»
|
### По пункту 3 — «Доступные мне»
|
||||||
В открытом REST API Яндекс.Диска (`cloud-api.yandex.net`) нет эндпоинта «файлы, которыми поделились со мной». Варианты реализации (по убыванию реалистичности):
|
В документации REST API нет эндпоинта «файлы, которыми поделились со мной», и web-API `disk.yandex.ru/api/v2` требует браузерную сессию (302 → капча).
|
||||||
1. Квази-«Доступные мне»: список ресурсов с `public_url` + собственный реестр входящих ссылок (пользователь добавляет публичную ссылку в приложение; монтируем в виртуальную папку через `/public/resources`).
|
|
||||||
2. Неофициальный web-API disk.yandex.ru (папка «Общий доступ») — хрупко, зависит от внутренней авторизации.
|
**Найдено и реализовано (v1.0.3):** в ответе `GET /v1/disk/resources` (листинг любого каталога) каждый ресурс несёт недокументированное поле `share: {rights, is_owned, is_root}`. Папки, к которым доступ дали другие пользователи, лежат прямо в дереве Диска с `is_owned: false`. `DiskAPIClient.sharedWithMe()` обходит дерево (до глубины 3, внутрь чужих общих корней не заходит) и собирает все ресурсы с `is_owned == false`. Вкладка «Доступные мне» показывает их с правами (`rw`/`r`) в контекстном меню.
|
||||||
3. Вкладка «Приглашения» через веб-интерфейс.
|
|
||||||
Решение о выборе — отдельно.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user