From 19e83607128be5642568e91e78cf0e6610cd9e25 Mon Sep 17 00:00:00 2001 From: akhmed Date: Tue, 15 Sep 2026 22:12:49 +0300 Subject: [PATCH] =?UTF-8?q?v1.0.2:=20=D0=98=D0=B7=D0=B1=D1=80=D0=B0=D0=BD?= =?UTF-8?q?=D0=BD=D0=BE=D0=B5=20(=D0=BC=D0=B5=D1=82=D0=BA=D0=B0=20+=20?= =?UTF-8?q?=D0=B2=D0=BA=D0=BB=D0=B0=D0=B4=D0=BA=D0=B0),=20=D0=94=D0=BE?= =?UTF-8?q?=D1=81=D1=82=D1=83=D0=BF=D0=BD=D1=8B=D0=B5=20=D0=BC=D0=BD=D0=B5?= =?UTF-8?q?=20(=D1=80=D0=B5=D0=B5=D1=81=D1=82=D1=80=20=D0=BF=D1=83=D0=B1?= =?UTF-8?q?=D0=BB=D0=B8=D1=87=D0=BD=D1=8B=D1=85=20=D1=81=D1=81=D1=8B=D0=BB?= =?UTF-8?q?=D0=BE=D0=BA)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Избранное: правый клик → «В избранное», звезда в строке, метки в UserDefaults, вкладка подгружает ресурсы по путям - Доступные мне: кнопка «+» добавляет публичную ссылку; метаданные через официальный GET /v1/disk/public/resources; открытие/копирование/удаление - Ограничение платформы задокументировано (ПРАВКИ.md): REST API не отдаёт список shared-with-me - smoke_test: фикс пути бандла --- README.md | 4 +- Sources/YanDisk/Core/AppStore.swift | 138 ++++++++++++++++++++++- Sources/YanDisk/Core/DiskAPIClient.swift | 10 ++ Sources/YanDisk/UI/MainSplitView.swift | 64 ++++++++++- smoke_test.sh | 4 +- ПРАВКИ.md | 8 +- 6 files changed, 211 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 03e567d..d9647ce 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,9 @@ ## Возможности - Авторизация OAuth (токен в Keychain) -- Просмотр Диска: все файлы и папки, «Недавние», «Корзина», «Доступные мне» (в разработке) +- Просмотр Диска: все файлы и папки, «Недавние», «Корзина» +- **Избранное**: правый клик → «В избранное» (метка хранится локально, звезда в списке) +- **Доступные мне**: реестр публичных ссылок, полученных от других пользователей (кнопка «+» на вкладке; открытие в браузере, копирование, удаление) - Чтение / скачивание, загрузка (кнопка и drag-and-drop) - Копирование, перемещение, переименование, дублирование - Удаление в Корзину, восстановление, очистка Корзины diff --git a/Sources/YanDisk/Core/AppStore.swift b/Sources/YanDisk/Core/AppStore.swift index 8ac1f01..216f636 100644 --- a/Sources/YanDisk/Core/AppStore.swift +++ b/Sources/YanDisk/Core/AppStore.swift @@ -54,6 +54,9 @@ final class AppStore { var items: [DiskResource] = [] var trashItems: [DiskResource] = [] var recentItems: [DiskResource] = [] + var favoriteItems: [DiskResource] = [] + var publicItems: [DiskResource] = [] // «Доступные мне»: path = публичная ссылка + var favoritePaths: Set = [] var isLoading = false var errorMessage: String? var statusMessage: String? @@ -71,6 +74,9 @@ final class AppStore { let syncEngine = SyncEngine() private var api: DiskAPIClient? + private static let favoritesKey = "FavoritePaths" + private static let publicLinksKey = "PublicLinks" + var usedPercent: Double { guard let info = diskInfo, let total = info.totalSpace, total > 0 else { return 0 } return Double(info.usedSpace ?? 0) / Double(total) @@ -88,6 +94,9 @@ final class AppStore { if let saved = KeychainStore.loadToken(), !saved.isEmpty { token = saved } + if let paths = UserDefaults.standard.stringArray(forKey: Self.favoritesKey) { + favoritePaths = Set(paths) + } } private var didBootstrap = false @@ -133,6 +142,8 @@ final class AppStore { items = [] trashItems = [] recentItems = [] + favoriteItems = [] + publicItems = [] diskInfo = nil showLoginSheet = true } @@ -161,13 +172,12 @@ final class AppStore { func selectSidebar(_ item: SidebarItem) { sidebarSelection = item switch item { - case .disk, .favorites: + case .disk: navigate(to: "/") + case .favorites: + Task { await loadFavorites() } case .files: - // «Доступные мне» — пока заглушка, API shared-with-me в плане (см. ПРАВКИ.md) - items = [] - currentPath = "/" - pathStack = ["/"] + Task { await loadPublicItems() } case .recents: Task { await loadRecents() } case .screenshots: @@ -186,12 +196,114 @@ final class AppStore { switch sidebarSelection { case .trash: await loadTrash() case .recents: await loadRecents() - case .files: items = [] // «Доступные мне» — заглушка + case .favorites: await loadFavorites() + case .files: await loadPublicItems() default: await loadDirectory(path: currentPath) } 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 (public links) + + 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 { + guard let api else { return } + isLoading = true + defer { isLoading = false } + let links = UserDefaults.standard.stringArray(forKey: Self.publicLinksKey) ?? [] + if links.count != publicItems.count { + var loaded: [DiskResource] = [] + for link in links { + do { + let r = try await api.publicResource(publicKey: link) + loaded.append(.presentedByLink(r, link: link)) + } catch { + handleError(error) + } + } + publicItems = loaded + } + } + func loadDiskInfo() async { guard let api else { return } do { @@ -254,6 +366,10 @@ final class AppStore { // MARK: - File operations func open(_ item: DiskResource) { + if item.path.hasPrefix("http") { + NSWorkspace.shared.open(URL(string: item.path)!) + return + } if item.isDirectory { navigate(to: item.path) } else { @@ -467,6 +583,16 @@ 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 { static func string(info: Int64) -> String { let formatter = Foundation.ByteCountFormatter() diff --git a/Sources/YanDisk/Core/DiskAPIClient.swift b/Sources/YanDisk/Core/DiskAPIClient.swift index cc7d799..b138e07 100644 --- a/Sources/YanDisk/Core/DiskAPIClient.swift +++ b/Sources/YanDisk/Core/DiskAPIClient.swift @@ -361,4 +361,14 @@ actor DiskAPIClient { func filesList(limit: Int = 200) async throws -> [DiskResource] { 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) + } } diff --git a/Sources/YanDisk/UI/MainSplitView.swift b/Sources/YanDisk/UI/MainSplitView.swift index 80a47ff..094aa5a 100644 --- a/Sources/YanDisk/UI/MainSplitView.swift +++ b/Sources/YanDisk/UI/MainSplitView.swift @@ -6,6 +6,8 @@ struct MainSplitView: View { @Environment(\.openWindow) private var openWindow @State private var showNewFolderAlert = false @State private var newFolderName = "" + @State private var showAddLinkAlert = false + @State private var newLinkText = "" @State private var renameItem: DiskResource? @State private var renameText = "" @@ -56,6 +58,14 @@ struct MainSplitView: View { } .toolbar { ToolbarItemGroup { + if store.sidebarSelection == .files { + Button { + showAddLinkAlert = true + } label: { + Label("Добавить публичную ссылку", systemImage: "plus") + } + } + Button { showNewFolderAlert = true } label: { @@ -116,6 +126,17 @@ struct MainSplitView: View { } 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( get: { renameItem != nil }, set: { if !$0 { renameItem = nil } } @@ -182,11 +203,30 @@ struct MainSplitView: View { switch store.sidebarSelection { case .trash: return store.trashItems case .recents: return store.recentItems - case .files: return [] // «Доступные мне» — заглушка до реализации API + case .favorites: return store.favoriteItems + case .files: return store.publicItems 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 private func fileBrowser(store: AppStore) -> some View { let items = visibleItems(store: store) @@ -195,10 +235,10 @@ struct MainSplitView: View { .frame(maxWidth: .infinity, maxHeight: .infinity) } else if items.isEmpty { VStack(spacing: 12) { - Image(systemName: store.sidebarSelection == .trash ? "trash" : "folder") + Image(systemName: emptyIcon) .font(.system(size: 40)) .foregroundStyle(.tertiary) - Text(store.sidebarSelection == .trash ? "Корзина пуста" : "Папка пуста") + Text(emptyText) .foregroundStyle(.secondary) } .frame(maxWidth: .infinity, maxHeight: .infinity) @@ -214,6 +254,11 @@ struct MainSplitView: View { .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) @@ -262,7 +307,20 @@ struct MainSplitView: View { Button("Восстановить") { Task { await store.restoreFromTrash(item) } } + } else if store.sidebarSelection == .files { + Button("Открыть в браузере") { store.open(item) } + Button("Скопировать ссылку") { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(item.path, forType: .string) + } + Divider() + Button("Убрать из «Доступные мне»", role: .destructive) { + store.removePublicLink(item) + } } else { + Button(store.isFavorite(item) ? "Убрать из избранного" : "В избранное") { + store.toggleFavorite(item) + } Button("Открыть") { store.open(item) } Button("Показать в Finder") { store.revealInFinder(item) } Button("Создать копию на компьютере") { diff --git a/smoke_test.sh b/smoke_test.sh index 313e5d6..c713d36 100755 --- a/smoke_test.sh +++ b/smoke_test.sh @@ -46,8 +46,8 @@ print("ok") ' echo "4) app bundle" -APP="$ROOT/.build/YandexDisk.app" -test -x "$APP/Contents/MacOS/YandexDiskClient" +APP="$ROOT/.build/YanDisk.app" +test -x "$APP/Contents/MacOS/YanDisk" plutil -lint "$APP/Contents/Info.plist" codesign -dv "$APP" 2>&1 | head -5 echo "ALL SMOKE TESTS PASSED" diff --git a/ПРАВКИ.md b/ПРАВКИ.md index b24e382..3f01a98 100644 --- a/ПРАВКИ.md +++ b/ПРАВКИ.md @@ -12,11 +12,9 @@ | 4 | Режим синхронизации по умолчанию — «Только в облако» (`SyncPolicyStore.globalMode = .cloudOnly`) | `SyncEngine.swift` | ### По пункту 3 — «Доступные мне» -В открытом REST API Яндекс.Диска (`cloud-api.yandex.net`) нет эндпоинта «файлы, которыми поделились со мной». Варианты реализации (по убыванию реалистичности): -1. Квази-«Доступные мне»: список ресурсов с `public_url` + собственный реестр входящих ссылок (пользователь добавляет публичную ссылку в приложение; монтируем в виртуальную папку через `/public/resources`). -2. Неофициальный web-API disk.yandex.ru (папка «Общий доступ») — хрупко, зависит от внутренней авторизации. -3. Вкладка «Приглашения» через веб-интерфейс. -Решение о выборе — отдельно. +В открытом REST API Яндекс.Диска (`cloud-api.yandex.net`) нет эндпоинта «файлы, которыми поделились со мной». Проверено эмпирически (15.09.2026): web-API `disk.yandex.ru/api/v2` за сессией+капчей (OAuth-токен не принимается, 302 → captcha/passport), в WebDAV и в дереве Диска общие папки не отображаются, в `system_folders` соответствующего пути нет. + +**Реализовано (v1.0.2):** вкладка «Доступные мне» = реестр публичных ссылок, добавленных пользователем (кнопка «+»); метаданные тянутся официальным `GET /v1/disk/public/resources?public_key=…`, открытие в браузере, копирование и удаление ссылки из реестра. Хранилище — UserDefaults (`PublicLinks`). ---