- Models: ShareInfo (rights, is_owned, is_root) + поле share в DiskResource - DiskAPIClient.sharedWithMe(): обход дерева, сбор ресурсов с is_owned == false - Вкладка «Доступные мне» показывает общие папки, в контекстном меню права rw/r - Реестр публичных ссылок из v1.0.2 убран (был workaround)
555 lines
17 KiB
Swift
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)
|
|
}
|
|
}
|