Files
YanDisk/Sources/YanDisk/Core/AppStore.swift
T
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

555 lines
17 KiB
Swift

import AppKit
import Foundation
import Observation
@MainActor
@Observable
final class AppStore {
enum SidebarItem: String, CaseIterable, Identifiable {
case disk
case files
case recents
case favorites
case screenshots
case trash
case tasks
var id: String { rawValue }
var title: String {
switch self {
case .disk: return "YanDisk"
case .files: return "Доступные мне"
case .recents: return "Недавние"
case .favorites: return "Избранное"
case .screenshots: return "Скриншоты"
case .trash: return "Корзина"
case .tasks: return "Задачи"
}
}
var systemImage: String {
switch self {
case .disk: return "cloud"
case .files: return "person.2"
case .recents: return "clock"
case .favorites: return "star"
case .screenshots: return "camera.viewfinder"
case .trash: return "trash"
case .tasks: return "arrow.triangle.2.circlepath"
}
}
/// Пункты, отображаемые в сайдбаре (Скриншоты скрыты до реализации).
static var displayCases: [SidebarItem] {
allCases.filter { $0 != .screenshots }
}
}
var token: String?
var isAuthorized: Bool { token != nil }
var diskInfo: DiskInfo?
var sidebarSelection: SidebarItem = .disk
var currentPath: String = "/"
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?
var searchText = ""
var showLoginSheet = false
var pasteToken = ""
var showPreferences = false
var showShareSheet = false
var shareTarget: DiskResource?
var shareURL: String?
var shareSettings = ShareAccessSettings()
var selection = Set<String>()
var pathStack: [String] = ["/"]
let syncEngine = SyncEngine()
private var api: DiskAPIClient?
private static let favoritesKey = "FavoritePaths"
var usedPercent: Double {
guard let info = diskInfo, let total = info.totalSpace, total > 0 else { return 0 }
return Double(info.usedSpace ?? 0) / Double(total)
}
var formattedUsed: String {
ByteFormatter.string(info: diskInfo?.usedSpace ?? 0)
}
var formattedTotal: String {
ByteFormatter.string(info: diskInfo?.totalSpace ?? 0)
}
init() {
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
func bootstrap() {
guard !didBootstrap else {
if !isAuthorized { showLoginSheet = true }
return
}
didBootstrap = true
if let token {
let client = DiskAPIClient(token: token)
api = client
syncEngine.attach(api: client)
Task {
await refreshAll()
}
} else {
showLoginSheet = true
}
}
func login(with rawToken: String) {
guard let token = OAuthManager.parseToken(from: rawToken) ?? Optional(rawToken.trimmingCharacters(in: .whitespacesAndNewlines)), !token.isEmpty else {
errorMessage = "Не удалось распознать токен"
return
}
KeychainStore.saveToken(token)
self.token = token
pasteToken = ""
showLoginSheet = false
let client = DiskAPIClient(token: token)
api = client
syncEngine.attach(api: client)
Task { await refreshAll() }
}
func logout() {
KeychainStore.deleteToken()
token = nil
api = nil
syncEngine.detach()
items = []
trashItems = []
recentItems = []
favoriteItems = []
publicItems = []
diskInfo = nil
showLoginSheet = true
}
func openAuthorization() {
OAuthManager.openAuthorization()
}
// MARK: - Navigation
func navigate(to path: String) {
currentPath = path
if pathStack.last != path {
pathStack.append(path)
}
Task { await loadDirectory(path: path) }
}
func goBack() {
guard pathStack.count > 1 else { return }
pathStack.removeLast()
currentPath = pathStack.last ?? "/"
Task { await loadDirectory(path: currentPath) }
}
func selectSidebar(_ item: SidebarItem) {
sidebarSelection = item
switch item {
case .disk:
navigate(to: "/")
case .favorites:
Task { await loadFavorites() }
case .files:
Task { await loadPublicItems() }
case .recents:
Task { await loadRecents() }
case .screenshots:
break
case .trash:
Task { await loadTrash() }
case .tasks:
break
}
}
// MARK: - Data
func refreshAll() async {
await loadDiskInfo()
switch sidebarSelection {
case .trash: await loadTrash()
case .recents: await loadRecents()
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 (field `share`, is_owned == false)
func loadPublicItems() async {
guard let api else { return }
isLoading = true
defer { isLoading = false }
do {
let shared = try await api.sharedWithMe()
publicItems = shared.sorted { a, b in
a.displayName.localizedCaseInsensitiveCompare(b.displayName) == .orderedAscending
}
errorMessage = nil
} catch {
handleError(error)
}
}
func loadDiskInfo() async {
guard let api else { return }
do {
diskInfo = try await api.diskInfo()
} catch {
handleError(error)
}
}
func loadDirectory(path: String) async {
guard let api else { return }
isLoading = true
defer { isLoading = false }
do {
let list = try await api.listDirectory(path: path)
items = list.sorted { a, b in
if a.isDirectory != b.isDirectory { return a.isDirectory }
return a.displayName.localizedCaseInsensitiveCompare(b.displayName) == .orderedAscending
}
errorMessage = nil
} catch {
handleError(error)
}
}
func loadTrash() async {
guard let api else { return }
isLoading = true
defer { isLoading = false }
do {
trashItems = try await api.listTrash()
} catch {
handleError(error)
}
}
func loadRecents() async {
guard let api else { return }
isLoading = true
defer { isLoading = false }
do {
let last = try await api.lastUploaded(limit: 20)
if last.isEmpty {
recentItems = try await api.recentlyModified(limit: 20)
} else {
recentItems = last
}
} catch {
handleError(error)
}
}
var filteredItems: [DiskResource] {
guard !searchText.isEmpty else { return items }
return items.filter {
$0.displayName.localizedCaseInsensitiveContains(searchText)
}
}
// 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 {
NSWorkspace.shared.open(URL(string: "https://disk.yandex.ru/client/disk\(item.path)")!)
}
}
func revealInFinder(_ item: DiskResource) {
let local = syncEngine.localURL(for: item.path)
if FileManager.default.fileExists(atPath: local.path) {
NSWorkspace.shared.activateFileViewerSelecting([local])
} else {
NSWorkspace.shared.open(URL(string: "https://disk.yandex.ru/client/disk\(item.path)")!)
}
}
func createFolder(named name: String) async {
guard let api else { return }
let path = currentPath == "/" ? "/\(name)" : "\(currentPath)/\(name)"
do {
try await api.createDirectory(path: path)
await loadDirectory(path: currentPath)
statusMessage = "Папка «\(name)» создана"
} catch {
handleError(error)
}
}
func rename(_ item: DiskResource, to newName: String) async {
guard let api, !newName.isEmpty, newName != item.displayName else { return }
let parent = (item.path as NSString).deletingLastPathComponent
let newPath = parent == "/" ? "/\(newName)" : "\(parent)/\(newName)"
do {
try await api.move(from: item.path, to: newPath, overwrite: false)
await loadDirectory(path: currentPath)
statusMessage = "Переименовано в «\(newName)»"
} catch {
handleError(error)
}
}
func copy(_ item: DiskResource, to destinationPath: String) async {
guard let api else { return }
let dest = destinationPath == "/" ? "/\(item.displayName)" : "\(destinationPath)/\(item.displayName)"
do {
try await api.copy(from: item.path, to: dest, overwrite: false)
await loadDirectory(path: currentPath)
statusMessage = "Скопировано в \(destinationPath)"
} catch {
handleError(error)
}
}
func duplicate(_ item: DiskResource) async {
guard let api else { return }
let parent = (item.path as NSString).deletingLastPathComponent
let name = (item.displayName as NSString).deletingPathExtension
let ext = (item.displayName as NSString).pathExtension
let newName = ext.isEmpty ? " \(name) — копия" : "\(name) — копия.\(ext)"
let dest = parent == "/" ? "/\(newName)" : "\(parent)/\(newName)"
do {
try await api.copy(from: item.path, to: dest, overwrite: false)
await loadDirectory(path: currentPath)
} catch {
handleError(error)
}
}
func move(_ item: DiskResource, to destinationPath: String) async {
guard let api else { return }
let dest = destinationPath == "/" ? "/\(item.displayName)" : "\(destinationPath)/\(item.displayName)"
do {
try await api.move(from: item.path, to: dest, overwrite: false)
await loadDirectory(path: currentPath)
statusMessage = "Перемещено в \(destinationPath)"
} catch {
handleError(error)
}
}
func moveToTrash(_ item: DiskResource) async {
guard let api else { return }
do {
try await api.delete(path: item.path, permanently: false)
await loadDirectory(path: currentPath)
statusMessage = "«\(item.displayName)» перемещён в Корзину"
} catch {
handleError(error)
}
}
func deletePermanently(_ item: DiskResource) async {
guard let api else { return }
do {
try await api.delete(path: item.path, permanently: true)
await loadDirectory(path: currentPath)
} catch {
handleError(error)
}
}
func restoreFromTrash(_ item: DiskResource) async {
guard let api else { return }
do {
try await api.restoreFromTrash(path: item.path, name: nil)
await loadTrash()
statusMessage = "«\(item.displayName)» восстановлен"
} catch {
handleError(error)
}
}
func emptyTrash() async {
guard let api else { return }
do {
try await api.emptyTrash()
await loadTrash()
statusMessage = "Корзина очищена"
} catch {
handleError(error)
}
}
func downloadToComputer(_ item: DiskResource) async {
guard let api else { return }
let panel = NSSavePanel()
panel.nameFieldStringValue = item.displayName
panel.canCreateDirectories = true
guard panel.runModal() == .OK, let url = panel.url else { return }
do {
try await api.downloadFile(path: item.path, to: url)
statusMessage = "Файл сохранён: \(url.lastPathComponent)"
NSWorkspace.shared.activateFileViewerSelecting([url])
} catch {
handleError(error)
}
}
func uploadFiles(from urls: [URL]) async {
guard let api else { return }
for url in urls {
let name = url.lastPathComponent
let remote = currentPath == "/" ? "/\(name)" : "\(currentPath)/\(name)"
do {
try await api.uploadFile(localURL: url, to: remote, overwrite: true)
} catch {
handleError(error)
}
}
await loadDirectory(path: currentPath)
statusMessage = "Загружено объектов: \(urls.count)"
}
// MARK: - Share
func prepareShare(_ item: DiskResource) {
shareTarget = item
shareSettings = ShareAccessSettings()
shareURL = item.publicUrl
showShareSheet = true
}
func publishShare() async {
guard let api, let item = shareTarget else { return }
do {
let url = try await api.publish(path: item.path, settings: shareSettings)
shareURL = url
await loadDirectory(path: currentPath)
statusMessage = "Ссылка скопирована"
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(url, forType: .string)
} catch {
handleError(error)
}
}
func unpublishShare(_ item: DiskResource) async {
guard let api else { return }
do {
try await api.unpublish(path: item.path)
await loadDirectory(path: currentPath)
statusMessage = "Публичный доступ закрыт"
} catch {
handleError(error)
}
}
func copyPublicLink(_ item: DiskResource) async {
if let url = item.publicUrl, !url.isEmpty {
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(url, forType: .string)
statusMessage = "Ссылка скопирована"
return
}
shareTarget = item
shareSettings = ShareAccessSettings()
await publishShare()
}
func applySyncMode(_ mode: SyncMode, to item: DiskResource) async {
await syncEngine.applyModeChange(mode, path: item.path)
statusMessage = "Режим синхронизации: \(mode.title)"
}
private func handleError(_ error: Error) {
if let apiError = error as? DiskAPIError {
errorMessage = apiError.localizedDescription
} else {
errorMessage = error.localizedDescription
}
}
}
enum ByteFormatter {
static func string(info: Int64) -> String {
let formatter = Foundation.ByteCountFormatter()
formatter.countStyle = .file
return formatter.string(fromByteCount: info)
}
}