1 Commits
Author SHA1 Message Date
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
6 changed files with 211 additions and 17 deletions
+3 -1
View File
@@ -5,7 +5,9 @@
## Возможности
- Авторизация OAuth (токен в Keychain)
- Просмотр Диска: все файлы и папки, «Недавние», «Корзина», «Доступные мне» (в разработке)
- Просмотр Диска: все файлы и папки, «Недавние», «Корзина»
- **Избранное**: правый клик → «В избранное» (метка хранится локально, звезда в списке)
- **Доступные мне**: реестр публичных ссылок, полученных от других пользователей (кнопка «+» на вкладке; открытие в браузере, копирование, удаление)
- Чтение / скачивание, загрузка (кнопка и drag-and-drop)
- Копирование, перемещение, переименование, дублирование
- Удаление в Корзину, восстановление, очистка Корзины
+132 -6
View File
@@ -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<String> = []
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()
+10
View File
@@ -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)
}
}
+61 -3
View File
@@ -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("Создать копию на компьютере") {
+2 -2
View File
@@ -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"
+3 -5
View File
@@ -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`).
---