v1.0.3: Доступные мне — реальное обнаружение общих папок (поле share/is_owned)

- Models: ShareInfo (rights, is_owned, is_root) + поле share в DiskResource
- DiskAPIClient.sharedWithMe(): обход дерева, сбор ресурсов с is_owned == false
- Вкладка «Доступные мне» показывает общие папки, в контекстном меню права rw/r
- Реестр публичных ссылок из v1.0.2 убран (был workaround)
This commit is contained in:
2026-09-15 22:22:52 +03:00
parent 19e8360712
commit f2160a7768
6 changed files with 61 additions and 88 deletions
+1 -1
View File
@@ -7,7 +7,7 @@
- Авторизация OAuth (токен в Keychain)
- Просмотр Диска: все файлы и папки, «Недавние», «Корзина»
- **Избранное**: правый клик → «В избранное» (метка хранится локально, звезда в списке)
- **Доступные мне**: реестр публичных ссылок, полученных от других пользователей (кнопка «+» на вкладке; открытие в браузере, копирование, удаление)
- **Доступные мне**: папки, к которым доступ дали другие пользователи (обнаружение по полю `share.is_owned == false` в листинге API; права rw/r в контекстном меню)
- Чтение / скачивание, загрузка (кнопка и drag-and-drop)
- Копирование, перемещение, переименование, дублирование
- Удаление в Корзину, восстановление, очистка Корзины
+6 -54
View File
@@ -75,7 +75,6 @@ final class AppStore {
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 }
@@ -250,59 +249,22 @@ final class AppStore {
}
}
// 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)"
}
// MARK: - Shared with me (field `share`, is_owned == false)
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))
let shared = try await api.sharedWithMe()
publicItems = shared.sorted { a, b in
a.displayName.localizedCaseInsensitiveCompare(b.displayName) == .orderedAscending
}
errorMessage = nil
} catch {
handleError(error)
}
}
publicItems = loaded
}
}
func loadDiskInfo() async {
guard let api else { return }
@@ -583,16 +545,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 {
static func string(info: Int64) -> String {
let formatter = Foundation.ByteCountFormatter()
+22
View File
@@ -371,4 +371,26 @@ actor DiskAPIClient {
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
}
}
+18 -1
View File
@@ -5,6 +5,22 @@ enum ResourceType: String, Codable, Sendable {
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 {
let name: String?
let created: String?
@@ -18,10 +34,11 @@ struct DiskResource: Codable, Identifiable, Hashable, Sendable {
let publicKey: String?
let publicUrl: String?
let originPath: String?
let share: ShareInfo?
let embedded: ResourceList?
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 publicKey = "public_key"
case publicUrl = "public_url"
+9 -27
View File
@@ -6,8 +6,6 @@ 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 = ""
@@ -58,14 +56,6 @@ struct MainSplitView: View {
}
.toolbar {
ToolbarItemGroup {
if store.sidebarSelection == .files {
Button {
showAddLinkAlert = true
} label: {
Label("Добавить публичную ссылку", systemImage: "plus")
}
}
Button {
showNewFolderAlert = true
} label: {
@@ -126,17 +116,6 @@ 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 } }
@@ -222,7 +201,7 @@ struct MainSplitView: View {
switch store.sidebarSelection {
case .trash: return "Корзина пуста"
case .favorites: return "Нет избранного — правый клик по файлу → «В избранное»"
case .files: return "Нет общих ссылок — нажмите «+», чтобы добавить публичную ссылку"
case .files: return "Нет папок с общим доступом"
default: return "Папка пуста"
}
}
@@ -308,15 +287,18 @@ struct MainSplitView: View {
Task { await store.restoreFromTrash(item) }
}
} else if store.sidebarSelection == .files {
Button("Открыть в браузере") { store.open(item) }
Button("Скопировать ссылку") {
Button("Открыть") { store.open(item) }
if let url = item.publicUrl, !url.isEmpty {
Button("Скопировать публичную ссылку") {
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(item.path, forType: .string)
NSPasteboard.general.setString(url, forType: .string)
}
}
Divider()
Button("Убрать из «Доступные мне»", role: .destructive) {
store.removePublicLink(item)
Menu("Права доступа") {
Text(item.share?.rights == "rw" ? "Редактирование (rw)" : "Только просмотр (r)")
}
.disabled(true)
} else {
Button(store.isFavorite(item) ? "Убрать из избранного" : "В избранное") {
store.toggleFavorite(item)
+2 -2
View File
@@ -12,9 +12,9 @@
| 4 | Режим синхронизации по умолчанию — «Только в облако» (`SyncPolicyStore.globalMode = .cloudOnly`) | `SyncEngine.swift` |
### По пункту 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`) в контекстном меню.
---