Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
73afb23842 | ||
|
|
f2160a7768 | ||
|
|
19e8360712 |
@@ -5,7 +5,9 @@
|
|||||||
## Возможности
|
## Возможности
|
||||||
|
|
||||||
- Авторизация OAuth (токен в Keychain)
|
- Авторизация OAuth (токен в Keychain)
|
||||||
- Просмотр Диска: все файлы и папки, «Недавние», «Корзина», «Доступные мне» (в разработке)
|
- Просмотр Диска: все файлы и папки, «Недавние», «Корзина»
|
||||||
|
- **Избранное**: правый клик → «В избранное» (метка хранится локально, звезда в списке)
|
||||||
|
- **Доступные мне**: папки, к которым доступ дали другие пользователи (обнаружение по полю `share.is_owned == false` в листинге API; права rw/r в контекстном меню)
|
||||||
- Чтение / скачивание, загрузка (кнопка и drag-and-drop)
|
- Чтение / скачивание, загрузка (кнопка и drag-and-drop)
|
||||||
- Копирование, перемещение, переименование, дублирование
|
- Копирование, перемещение, переименование, дублирование
|
||||||
- Удаление в Корзину, восстановление, очистка Корзины
|
- Удаление в Корзину, восстановление, очистка Корзины
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
@@ -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