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) - Авторизация OAuth (токен в Keychain)
- Просмотр Диска: все файлы и папки, «Недавние», «Корзина» - Просмотр Диска: все файлы и папки, «Недавние», «Корзина»
- **Избранное**: правый клик → «В избранное» (метка хранится локально, звезда в списке) - **Избранное**: правый клик → «В избранное» (метка хранится локально, звезда в списке)
- **Доступные мне**: реестр публичных ссылок, полученных от других пользователей (кнопка «+» на вкладке; открытие в браузере, копирование, удаление) - **Доступные мне**: папки, к которым доступ дали другие пользователи (обнаружение по полю `share.is_owned == false` в листинге API; права rw/r в контекстном меню)
- Чтение / скачивание, загрузка (кнопка и drag-and-drop) - Чтение / скачивание, загрузка (кнопка и drag-and-drop)
- Копирование, перемещение, переименование, дублирование - Копирование, перемещение, переименование, дублирование
- Удаление в Корзину, восстановление, очистка Корзины - Удаление в Корзину, восстановление, очистка Корзины
+8 -56
View File
@@ -75,7 +75,6 @@ final class AppStore {
private var api: DiskAPIClient? private var api: DiskAPIClient?
private static let favoritesKey = "FavoritePaths" private static let favoritesKey = "FavoritePaths"
private static let publicLinksKey = "PublicLinks"
var usedPercent: Double { var usedPercent: Double {
guard let info = diskInfo, let total = info.totalSpace, total > 0 else { return 0 } guard let info = diskInfo, let total = info.totalSpace, total > 0 else { return 0 }
@@ -250,57 +249,20 @@ final class AppStore {
} }
} }
// MARK: - Shared with me (public links) // MARK: - Shared with me (field `share`, is_owned == false)
func addPublicLink(_ rawLink: String) async {
let link = rawLink.trimmingCharacters(in: .whitespacesAndNewlines)
let links = UserDefaults.standard.stringArray(forKey: Self.publicLinksKey) ?? []
let isDiskLink = link.contains("disk.yandex.") || link.contains("yadi.sk")
guard isDiskLink, !publicItems.contains(where: { $0.path == link }),
!links.contains(link) else {
errorMessage = "Некорректная публичная ссылка"
return
}
guard let api else { return }
isLoading = true
defer { isLoading = false }
do {
let resource = try await api.publicResource(publicKey: link)
publicItems.append(.presentedByLink(resource, link: link))
var links = UserDefaults.standard.stringArray(forKey: Self.publicLinksKey) ?? []
links.append(link)
UserDefaults.standard.set(links, forKey: Self.publicLinksKey)
statusMessage = "Добавлено: \(resource.displayName)"
errorMessage = nil
} catch {
handleError(error)
}
}
func removePublicLink(_ item: DiskResource) {
publicItems.removeAll { $0.path == item.path }
var links = UserDefaults.standard.stringArray(forKey: Self.publicLinksKey) ?? []
links.removeAll { $0 == item.path }
UserDefaults.standard.set(links, forKey: Self.publicLinksKey)
statusMessage = "Убрано: \(item.displayName)"
}
func loadPublicItems() async { func loadPublicItems() async {
guard let api else { return } guard let api else { return }
isLoading = true isLoading = true
defer { isLoading = false } defer { isLoading = false }
let links = UserDefaults.standard.stringArray(forKey: Self.publicLinksKey) ?? [] do {
if links.count != publicItems.count { let shared = try await api.sharedWithMe()
var loaded: [DiskResource] = [] publicItems = shared.sorted { a, b in
for link in links { a.displayName.localizedCaseInsensitiveCompare(b.displayName) == .orderedAscending
do {
let r = try await api.publicResource(publicKey: link)
loaded.append(.presentedByLink(r, link: link))
} catch {
handleError(error)
}
} }
publicItems = loaded errorMessage = nil
} catch {
handleError(error)
} }
} }
@@ -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 { enum ByteFormatter {
static func string(info: Int64) -> String { static func string(info: Int64) -> String {
let formatter = Foundation.ByteCountFormatter() let formatter = Foundation.ByteCountFormatter()
+22
View File
@@ -371,4 +371,26 @@ actor DiskAPIClient {
let (data, _) = try await send(request) let (data, _) = try await send(request)
return try decode(DiskResource.self, from: data) return try decode(DiskResource.self, from: data)
} }
/// Ресурсы, к которым доступ дали другие пользователи.
/// Ищем по дереву поле `share` с `is_owned == false` (недокументированное, но в каждом ответе API).
func sharedWithMe(from root: String = "/", maxDepth: Int = 3) async throws -> [DiskResource] {
var result: [DiskResource] = []
func walk(_ path: String, depth: Int) async throws {
guard depth <= maxDepth else { return }
for item in try await listDirectory(path: path) {
if item.share?.isSharedWithMe == true {
result.append(item)
continue // внутрь чужих общих папок не заходим — это отдельные корни
}
if item.isDirectory {
try await walk(item.path, depth: depth + 1)
}
}
}
try await walk(root, depth: 0)
return result
}
} }
+18 -1
View File
@@ -5,6 +5,22 @@ enum ResourceType: String, Codable, Sendable {
case dir case dir
} }
/// Информация об общем доступе к ресурсу (недокументированное поле `share`).
struct ShareInfo: Codable, Hashable, Sendable {
let rights: String?
let isOwned: Bool?
let isRoot: Bool?
enum CodingKeys: String, CodingKey {
case rights
case isOwned = "is_owned"
case isRoot = "is_root"
}
/// Ресурс, к которому доступ дали другие пользователи.
var isSharedWithMe: Bool { isOwned == false }
}
struct DiskResource: Codable, Identifiable, Hashable, Sendable { struct DiskResource: Codable, Identifiable, Hashable, Sendable {
let name: String? let name: String?
let created: String? let created: String?
@@ -18,10 +34,11 @@ struct DiskResource: Codable, Identifiable, Hashable, Sendable {
let publicKey: String? let publicKey: String?
let publicUrl: String? let publicUrl: String?
let originPath: String? let originPath: String?
let share: ShareInfo?
let embedded: ResourceList? let embedded: ResourceList?
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case name, created, modified, path, type, md5, size, preview case name, created, modified, path, type, md5, size, preview, share
case mimeType = "mime_type" case mimeType = "mime_type"
case publicKey = "public_key" case publicKey = "public_key"
case publicUrl = "public_url" case publicUrl = "public_url"
+10 -28
View File
@@ -6,8 +6,6 @@ struct MainSplitView: View {
@Environment(\.openWindow) private var openWindow @Environment(\.openWindow) private var openWindow
@State private var showNewFolderAlert = false @State private var showNewFolderAlert = false
@State private var newFolderName = "" @State private var newFolderName = ""
@State private var showAddLinkAlert = false
@State private var newLinkText = ""
@State private var renameItem: DiskResource? @State private var renameItem: DiskResource?
@State private var renameText = "" @State private var renameText = ""
@@ -58,14 +56,6 @@ struct MainSplitView: View {
} }
.toolbar { .toolbar {
ToolbarItemGroup { ToolbarItemGroup {
if store.sidebarSelection == .files {
Button {
showAddLinkAlert = true
} label: {
Label("Добавить публичную ссылку", systemImage: "plus")
}
}
Button { Button {
showNewFolderAlert = true showNewFolderAlert = true
} label: { } label: {
@@ -126,17 +116,6 @@ struct MainSplitView: View {
} }
Button("Отмена", role: .cancel) {} Button("Отмена", role: .cancel) {}
} }
.alert("Публичная ссылка", isPresented: $showAddLinkAlert) {
TextField("https://disk.yandex.ru/d/…", text: $newLinkText)
Button("Добавить") {
let link = newLinkText
newLinkText = ""
Task { await store.addPublicLink(link) }
}
Button("Отмена", role: .cancel) {}
} message: {
Text("Вставьте ссылку, которой поделился другой пользователь (папка «Общий доступ» на disk.yandex.ru → «Поделиться ссылкой»).")
}
.alert("Переименовать", isPresented: Binding( .alert("Переименовать", isPresented: Binding(
get: { renameItem != nil }, get: { renameItem != nil },
set: { if !$0 { renameItem = nil } } set: { if !$0 { renameItem = nil } }
@@ -222,7 +201,7 @@ struct MainSplitView: View {
switch store.sidebarSelection { switch store.sidebarSelection {
case .trash: return "Корзина пуста" case .trash: return "Корзина пуста"
case .favorites: return "Нет избранного — правый клик по файлу → «В избранное»" case .favorites: return "Нет избранного — правый клик по файлу → «В избранное»"
case .files: return "Нет общих ссылок — нажмите «+», чтобы добавить публичную ссылку" case .files: return "Нет папок с общим доступом"
default: return "Папка пуста" default: return "Папка пуста"
} }
} }
@@ -308,15 +287,18 @@ struct MainSplitView: View {
Task { await store.restoreFromTrash(item) } Task { await store.restoreFromTrash(item) }
} }
} else if store.sidebarSelection == .files { } else if store.sidebarSelection == .files {
Button("Открыть в браузере") { store.open(item) } Button("Открыть") { store.open(item) }
Button("Скопировать ссылку") { if let url = item.publicUrl, !url.isEmpty {
NSPasteboard.general.clearContents() Button("Скопировать публичную ссылку") {
NSPasteboard.general.setString(item.path, forType: .string) NSPasteboard.general.clearContents()
NSPasteboard.general.setString(url, forType: .string)
}
} }
Divider() Divider()
Button("Убрать из «Доступные мне»", role: .destructive) { Menu("Права доступа") {
store.removePublicLink(item) Text(item.share?.rights == "rw" ? "Редактирование (rw)" : "Только просмотр (r)")
} }
.disabled(true)
} else { } else {
Button(store.isFavorite(item) ? "Убрать из избранного" : "В избранное") { Button(store.isFavorite(item) ? "Убрать из избранного" : "В избранное") {
store.toggleFavorite(item) store.toggleFavorite(item)
+2 -2
View File
@@ -12,9 +12,9 @@
| 4 | Режим синхронизации по умолчанию — «Только в облако» (`SyncPolicyStore.globalMode = .cloudOnly`) | `SyncEngine.swift` | | 4 | Режим синхронизации по умолчанию — «Только в облако» (`SyncPolicyStore.globalMode = .cloudOnly`) | `SyncEngine.swift` |
### По пункту 3 — «Доступные мне» ### По пункту 3 — «Доступные мне»
В открытом REST API Яндекс.Диска (`cloud-api.yandex.net`) нет эндпоинта «файлы, которыми поделились со мной». Проверено эмпирически (15.09.2026): web-API `disk.yandex.ru/api/v2` за сессией+капчей (OAuth-токен не принимается, 302 → captcha/passport), в WebDAV и в дереве Диска общие папки не отображаются, в `system_folders` соответствующего пути нет. В документации REST API нет эндпоинта «файлы, которыми поделились со мной», и web-API `disk.yandex.ru/api/v2` требует браузерную сессию (302 → капча).
**Реализовано (v1.0.2):** вкладка «Доступные мне» = реестр публичных ссылок, добавленных пользователем (кнопка «+»); метаданные тянутся официальным `GET /v1/disk/public/resources?public_key=…`, открытие в браузере, копирование и удаление ссылки из реестра. Хранилище — UserDefaults (`PublicLinks`). **Найдено и реализовано (v1.0.3):** в ответе `GET /v1/disk/resources` (листинг любого каталога) каждый ресурс несёт недокументированное поле `share: {rights, is_owned, is_root}`. Папки, к которым доступ дали другие пользователи, лежат прямо в дереве Диска с `is_owned: false`. `DiskAPIClient.sharedWithMe()` обходит дерево (до глубины 3, внутрь чужих общих корней не заходит) и собирает все ресурсы с `is_owned == false`. Вкладка «Доступные мне» показывает их с правами (`rw`/`r`) в контекстном меню.
--- ---