3 Commits
Author SHA1 Message Date
akhmed 73afb23842 v1.0.4: вкладка 'Общий доступ' — быстрый скан корня + глубокий в фоне 2026-09-15 22:34:27 +03:00
akhmed f2160a7768 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)
2026-09-15 22:22:52 +03:00
akhmed 19e8360712 v1.0.2: Избранное (метка + вкладка), Доступные мне (реестр публичных ссылок)
- Избранное: правый клик → «В избранное», звезда в строке, метки в UserDefaults, вкладка подгружает ресурсы по путям
- Доступные мне: кнопка «+» добавляет публичную ссылку; метаданные через официальный GET /v1/disk/public/resources; открытие/копирование/удаление
- Ограничение платформы задокументировано (ПРАВКИ.md): REST API не отдаёт список shared-with-me
- smoke_test: фикс пути бандла
2026-09-15 22:12:49 +03:00
7 changed files with 199 additions and 20 deletions
+3 -1
View File
@@ -5,7 +5,9 @@
## Возможности ## Возможности
- Авторизация OAuth (токен в Keychain) - Авторизация OAuth (токен в Keychain)
- Просмотр Диска: все файлы и папки, «Недавние», «Корзина», «Доступные мне» (в разработке) - Просмотр Диска: все файлы и папки, «Недавние», «Корзина»
- **Избранное**: правый клик → «В избранное» (метка хранится локально, звезда в списке)
- **Доступные мне**: папки, к которым доступ дали другие пользователи (обнаружение по полю `share.is_owned == false` в листинге API; права rw/r в контекстном меню)
- Чтение / скачивание, загрузка (кнопка и drag-and-drop) - Чтение / скачивание, загрузка (кнопка и drag-and-drop)
- Копирование, перемещение, переименование, дублирование - Копирование, перемещение, переименование, дублирование
- Удаление в Корзину, восстановление, очистка Корзины - Удаление в Корзину, восстановление, очистка Корзины
+97 -7
View File
@@ -19,7 +19,7 @@ final class AppStore {
var title: String { var title: String {
switch self { switch self {
case .disk: return "YanDisk" case .disk: return "YanDisk"
case .files: return "Доступные мне" case .files: return "Общий доступ"
case .recents: return "Недавние" case .recents: return "Недавние"
case .favorites: return "Избранное" case .favorites: return "Избранное"
case .screenshots: return "Скриншоты" case .screenshots: return "Скриншоты"
@@ -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?
@@ -71,6 +74,8 @@ final class AppStore {
let syncEngine = SyncEngine() let syncEngine = SyncEngine()
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 +93,9 @@ 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)
}
} }
private var didBootstrap = false private var didBootstrap = false
@@ -133,6 +141,8 @@ final class AppStore {
items = [] items = []
trashItems = [] trashItems = []
recentItems = [] recentItems = []
favoriteItems = []
publicItems = []
diskInfo = nil diskInfo = nil
showLoginSheet = true showLoginSheet = true
} }
@@ -161,13 +171,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 +195,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 +340,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 {
+32
View File
@@ -361,4 +361,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
}
} }
+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"
+44 -4
View File
@@ -171,7 +171,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 +182,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,10 +214,10 @@ 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)
@@ -214,6 +233,11 @@ struct MainSplitView: View {
.frame(width: 18) .frame(width: 18)
Text(item.displayName) Text(item.displayName)
.lineLimit(1) .lineLimit(1)
if store.sidebarSelection != .files && store.isFavorite(item) {
Image(systemName: "star.fill")
.font(.caption2)
.foregroundStyle(.yellow)
}
if item.publicUrl != nil { if item.publicUrl != nil {
Image(systemName: "link") Image(systemName: "link")
.font(.caption2) .font(.caption2)
@@ -262,7 +286,23 @@ struct MainSplitView: View {
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("Создать копию на компьютере") {
+2 -2
View File
@@ -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"
+3 -5
View File
@@ -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. Вкладка «Приглашения» через веб-интерфейс.
Решение о выборе — отдельно.
--- ---