commit 9098340f2a4c6e261e01dcb002968ccbe2e7a41f Author: akhmed Date: Tue Sep 15 21:16:22 2026 +0300 YanDisk v1.0.1: тёмная иконка по теме системы, вкладки (Скриншоты скрыт, Файлы→Доступные мне), синк по умолчанию 'В облаке', ПРАВКИ.md - Иконка: AppIconDark.icns + переключение Dock-иконки по effectiveAppearance (KVO) - Сайдбар: убран 'Скриншоты', 'Файлы' → 'Доступные мне' (заглушка), первая вкладка — все файлы и папки - SyncPolicyStore.globalMode по умолчанию .cloudOnly ('В облаке') - build.sh/smoke_test.sh: SDKROOT из Xcode (фикс SDK-конфликта тулчейна 6.3.3/SDK 6.4) - ПРАВКИ.md: полный аудит и план доработок diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ff141d8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.build/ +.DS_Store +*.dSYM diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..5761d74 --- /dev/null +++ b/Package.swift @@ -0,0 +1,13 @@ +// swift-tools-version: 5.9 +import PackageDescription + +let package = Package( + name: "YanDisk", + platforms: [.macOS(.v14)], + targets: [ + .executableTarget( + name: "YanDisk", + path: "Sources/YanDisk" + ) + ] +) diff --git a/README.md b/README.md new file mode 100644 index 0000000..c297914 --- /dev/null +++ b/README.md @@ -0,0 +1,67 @@ +# YanDisk + +Нативный SwiftUI-клиент Яндекс.Диска для macOS (Apple Silicon). + +## Возможности + +- Авторизация OAuth +- Просмотр Диска: папки, файлы, «Недавние», «Корзина» +- Чтение / скачивание, загрузка (кнопка и drag-and-drop) +- Копирование, перемещение, переименование, дублирование +- Удаление в Корзину, восстановление, очистка Корзины +- Синхронизация `~/Yandex.Disk`: **Везде** / **Только на компьютере** / **Только в облаке** +- Общий доступ: ссылка, права, пароль, срок действия +- Иконка в меню-баре, светлая и тёмная темы + +## Сборка + +```bash +cd /Users/statix/Projects/YanDisk +./build.sh +open .build/YanDisk.app +``` + +Установка в `/Applications`: + +```bash +rm -rf /Applications/YanDisk.app +cp -R .build/YanDisk.app /Applications/ +codesign --force --deep --sign - /Applications/YanDisk.app +``` + +## Иконка + +Исходники: `icons/Icon-iOS-*.png` (1024×1024). +Собранные ресурсы: + +- `Resources/AppIcon.icns` — Dock/Finder +- `Resources/Assets.xcassets/AppIcon.appiconset` — варианты light/dark (HIG) + +Пересборка иконки: + +```bash +./scripts/generate_icons.sh +``` + +## Темы + +Настройки → Основные → **Тема**: Как в системе / Светлая / Тёмная. +Значение хранится в `AppStorage("Appearance")`. + +## Структура + +``` +Sources/YanDisk/ + App/ точка входа, AppDelegate, окна + Core/ API, OAuth, Keychain, модели, AppStore + Sync/ движок синхронизации + UI/ меню-бар, браузер, шаринг, настройки, вход +icons/ исходные PNG иконки +Resources/ Info.plist, AppIcon.icns, Assets.xcassets +``` + +## Авторизация + +1. Иконка облака → **Добавить аккаунт** +2. **Войти через Яндекс** +3. Вставить `access_token` diff --git a/Resources/AppIcon.icns b/Resources/AppIcon.icns new file mode 100644 index 0000000..e08c79c Binary files /dev/null and b/Resources/AppIcon.icns differ diff --git a/Resources/AppIconDark.icns b/Resources/AppIconDark.icns new file mode 100644 index 0000000..91f9281 Binary files /dev/null and b/Resources/AppIconDark.icns differ diff --git a/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json b/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..5462829 --- /dev/null +++ b/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,188 @@ +{ + "images": [ + { + "filename": "icon_16x16.png", + "idiom": "mac", + "scale": "1x", + "size": "16x16" + }, + { + "filename": "icon_16x16_dark.png", + "idiom": "mac", + "scale": "1x", + "size": "16x16", + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ] + }, + { + "filename": "icon_16x16@2x.png", + "idiom": "mac", + "scale": "2x", + "size": "16x16" + }, + { + "filename": "icon_16x16@2x_dark.png", + "idiom": "mac", + "scale": "2x", + "size": "16x16", + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ] + }, + { + "filename": "icon_32x32.png", + "idiom": "mac", + "scale": "1x", + "size": "32x32" + }, + { + "filename": "icon_32x32_dark.png", + "idiom": "mac", + "scale": "1x", + "size": "32x32", + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ] + }, + { + "filename": "icon_32x32@2x.png", + "idiom": "mac", + "scale": "2x", + "size": "32x32" + }, + { + "filename": "icon_32x32@2x_dark.png", + "idiom": "mac", + "scale": "2x", + "size": "32x32", + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ] + }, + { + "filename": "icon_128x128.png", + "idiom": "mac", + "scale": "1x", + "size": "128x128" + }, + { + "filename": "icon_128x128_dark.png", + "idiom": "mac", + "scale": "1x", + "size": "128x128", + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ] + }, + { + "filename": "icon_128x128@2x.png", + "idiom": "mac", + "scale": "2x", + "size": "128x128" + }, + { + "filename": "icon_128x128@2x_dark.png", + "idiom": "mac", + "scale": "2x", + "size": "128x128", + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ] + }, + { + "filename": "icon_256x256.png", + "idiom": "mac", + "scale": "1x", + "size": "256x256" + }, + { + "filename": "icon_256x256_dark.png", + "idiom": "mac", + "scale": "1x", + "size": "256x256", + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ] + }, + { + "filename": "icon_256x256@2x.png", + "idiom": "mac", + "scale": "2x", + "size": "256x256" + }, + { + "filename": "icon_256x256@2x_dark.png", + "idiom": "mac", + "scale": "2x", + "size": "256x256", + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ] + }, + { + "filename": "icon_512x512.png", + "idiom": "mac", + "scale": "1x", + "size": "512x512" + }, + { + "filename": "icon_512x512_dark.png", + "idiom": "mac", + "scale": "1x", + "size": "512x512", + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ] + }, + { + "filename": "icon_512x512@2x.png", + "idiom": "mac", + "scale": "2x", + "size": "512x512" + }, + { + "filename": "icon_512x512@2x_dark.png", + "idiom": "mac", + "scale": "2x", + "size": "512x512", + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ] + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/Resources/Assets.xcassets/AppIcon.appiconset/icon_128x128.png b/Resources/Assets.xcassets/AppIcon.appiconset/icon_128x128.png new file mode 100644 index 0000000..3ab3cc2 Binary files /dev/null and b/Resources/Assets.xcassets/AppIcon.appiconset/icon_128x128.png differ diff --git a/Resources/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png b/Resources/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png new file mode 100644 index 0000000..58c1728 Binary files /dev/null and b/Resources/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png differ diff --git a/Resources/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x_dark.png b/Resources/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x_dark.png new file mode 100644 index 0000000..7a10c89 Binary files /dev/null and b/Resources/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x_dark.png differ diff --git a/Resources/Assets.xcassets/AppIcon.appiconset/icon_128x128_dark.png b/Resources/Assets.xcassets/AppIcon.appiconset/icon_128x128_dark.png new file mode 100644 index 0000000..753fbe7 Binary files /dev/null and b/Resources/Assets.xcassets/AppIcon.appiconset/icon_128x128_dark.png differ diff --git a/Resources/Assets.xcassets/AppIcon.appiconset/icon_16x16.png b/Resources/Assets.xcassets/AppIcon.appiconset/icon_16x16.png new file mode 100644 index 0000000..6720c12 Binary files /dev/null and b/Resources/Assets.xcassets/AppIcon.appiconset/icon_16x16.png differ diff --git a/Resources/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png b/Resources/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png new file mode 100644 index 0000000..28d1288 Binary files /dev/null and b/Resources/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png differ diff --git a/Resources/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x_dark.png b/Resources/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x_dark.png new file mode 100644 index 0000000..2c88796 Binary files /dev/null and b/Resources/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x_dark.png differ diff --git a/Resources/Assets.xcassets/AppIcon.appiconset/icon_16x16_dark.png b/Resources/Assets.xcassets/AppIcon.appiconset/icon_16x16_dark.png new file mode 100644 index 0000000..7f1511b Binary files /dev/null and b/Resources/Assets.xcassets/AppIcon.appiconset/icon_16x16_dark.png differ diff --git a/Resources/Assets.xcassets/AppIcon.appiconset/icon_256x256.png b/Resources/Assets.xcassets/AppIcon.appiconset/icon_256x256.png new file mode 100644 index 0000000..58c1728 Binary files /dev/null and b/Resources/Assets.xcassets/AppIcon.appiconset/icon_256x256.png differ diff --git a/Resources/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png b/Resources/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png new file mode 100644 index 0000000..847ed34 Binary files /dev/null and b/Resources/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png differ diff --git a/Resources/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x_dark.png b/Resources/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x_dark.png new file mode 100644 index 0000000..3a36e1f Binary files /dev/null and b/Resources/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x_dark.png differ diff --git a/Resources/Assets.xcassets/AppIcon.appiconset/icon_256x256_dark.png b/Resources/Assets.xcassets/AppIcon.appiconset/icon_256x256_dark.png new file mode 100644 index 0000000..7a10c89 Binary files /dev/null and b/Resources/Assets.xcassets/AppIcon.appiconset/icon_256x256_dark.png differ diff --git a/Resources/Assets.xcassets/AppIcon.appiconset/icon_32x32.png b/Resources/Assets.xcassets/AppIcon.appiconset/icon_32x32.png new file mode 100644 index 0000000..28d1288 Binary files /dev/null and b/Resources/Assets.xcassets/AppIcon.appiconset/icon_32x32.png differ diff --git a/Resources/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png b/Resources/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png new file mode 100644 index 0000000..f9c3840 Binary files /dev/null and b/Resources/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png differ diff --git a/Resources/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x_dark.png b/Resources/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x_dark.png new file mode 100644 index 0000000..e509dd7 Binary files /dev/null and b/Resources/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x_dark.png differ diff --git a/Resources/Assets.xcassets/AppIcon.appiconset/icon_32x32_dark.png b/Resources/Assets.xcassets/AppIcon.appiconset/icon_32x32_dark.png new file mode 100644 index 0000000..2c88796 Binary files /dev/null and b/Resources/Assets.xcassets/AppIcon.appiconset/icon_32x32_dark.png differ diff --git a/Resources/Assets.xcassets/AppIcon.appiconset/icon_512x512.png b/Resources/Assets.xcassets/AppIcon.appiconset/icon_512x512.png new file mode 100644 index 0000000..847ed34 Binary files /dev/null and b/Resources/Assets.xcassets/AppIcon.appiconset/icon_512x512.png differ diff --git a/Resources/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png b/Resources/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png new file mode 100644 index 0000000..b8a8d35 Binary files /dev/null and b/Resources/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png differ diff --git a/Resources/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x_dark.png b/Resources/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x_dark.png new file mode 100644 index 0000000..977d215 Binary files /dev/null and b/Resources/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x_dark.png differ diff --git a/Resources/Assets.xcassets/AppIcon.appiconset/icon_512x512_dark.png b/Resources/Assets.xcassets/AppIcon.appiconset/icon_512x512_dark.png new file mode 100644 index 0000000..3a36e1f Binary files /dev/null and b/Resources/Assets.xcassets/AppIcon.appiconset/icon_512x512_dark.png differ diff --git a/Resources/Assets.xcassets/Contents.json b/Resources/Assets.xcassets/Contents.json new file mode 100644 index 0000000..f223229 --- /dev/null +++ b/Resources/Assets.xcassets/Contents.json @@ -0,0 +1 @@ +{"info":{"author":"xcode","version":1}} diff --git a/Resources/Info.plist b/Resources/Info.plist new file mode 100644 index 0000000..980fb8d --- /dev/null +++ b/Resources/Info.plist @@ -0,0 +1,34 @@ + + + + + CFBundleDevelopmentRegion + ru + CFBundleExecutable + YanDisk + CFBundleIdentifier + local.yandisk.app + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + YanDisk + CFBundleDisplayName + YanDisk + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0.0 + CFBundleVersion + 1 + LSMinimumSystemVersion + 14.0 + NSPrincipalClass + NSApplication + NSHumanReadableCopyright + YanDisk — client for Yandex.Disk REST API + CFBundleIconFile + AppIcon + CFBundleIconName + AppIcon + + diff --git a/Sources/YanDisk/App/YanDiskApp.swift b/Sources/YanDisk/App/YanDiskApp.swift new file mode 100644 index 0000000..ddea9cb --- /dev/null +++ b/Sources/YanDisk/App/YanDiskApp.swift @@ -0,0 +1,173 @@ +import SwiftUI +import AppKit + +final class AppDelegate: NSObject, NSApplicationDelegate { + private var mainWindowController: NSWindowController? + private var store: AppStore? + private var appearanceObserver: NSKeyValueObservation? + + private func updateAppIcon() { + let isDark = NSApp.effectiveAppearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua + let name = isDark ? "AppIconDark" : "AppIcon" + if let icon = Bundle.main.image(forResource: NSImage.Name(name)) { + NSApp.applicationIconImage = icon + } + } + + func attach(store: AppStore) { + self.store = store + } + + func applicationDidFinishLaunching(_ notification: Notification) { + NSLog("YD didFinishLaunching") + NSApp.setActivationPolicy(.regular) + NSApp.activate(ignoringOtherApps: true) + updateAppIcon() + appearanceObserver = NSApp.observe(\.effectiveAppearance, options: [.initial, .new]) { [weak self] _, _ in + DispatchQueue.main.async { + MainActor.assumeIsolated { + self?.updateAppIcon() + } + } + } + DispatchQueue.main.async { [weak self] in + MainActor.assumeIsolated { + self?.showMainWindow() + } + } + } + + func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { + false + } + + func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { + true + } + + @MainActor @objc func showMainWindow() { + NSLog("YD showMainWindow store=\(self.store != nil) windows=\(NSApp.windows.count)") + if let window = mainWindowController?.window { + window.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + return + } + + if let existing = NSApp.windows.first(where: { + $0.canBecomeMain && ($0.title.contains("YanDisk") || $0.title.isEmpty) + }) { + NSLog("YD reuse existing window") + existing.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + return + } + + let store: AppStore + if let s = self.store { + store = s + } else { + NSLog("YD creating AppStore") + store = AppStore() + self.store = store + } + + NSLog("YD building hosting controller") + let root = RootView().environment(store) + let hosting = NSHostingController(rootView: root) + let window = NSWindow(contentViewController: hosting) + window.title = "YanDisk" + window.setContentSize(NSSize(width: 980, height: 620)) + window.minSize = NSSize(width: 900, height: 560) + window.styleMask = [.titled, .closable, .miniaturizable, .resizable] + window.isReleasedWhenClosed = false + window.center() + window.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + mainWindowController = NSWindowController(window: window) + NSLog("YD window shown title=\(window.title) visible=\(window.isVisible)") + } + + @MainActor @objc func showPreferencesWindow(_ sender: Any?) { + NSApp.activate(ignoringOtherApps: true) + if let window = NSApp.windows.first(where: { $0.title == "Настройки" }) { + window.makeKeyAndOrderFront(nil) + return + } + let store = self.store ?? AppStore() + self.store = store + let hosting = NSHostingController( + rootView: PreferencesView().environment(store).frame(width: 520, height: 420) + ) + let window = NSWindow(contentViewController: hosting) + window.title = "Настройки" + window.styleMask = [.titled, .closable] + window.setContentSize(NSSize(width: 520, height: 420)) + window.isReleasedWhenClosed = false + window.center() + window.makeKeyAndOrderFront(nil) + } +} + +@main +struct YanDiskApp: App { + @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate + @State private var store = AppStore() + @AppStorage("Appearance") private var appearance = "system" + + var body: some Scene { + MenuBarExtra { + MenuBarView() + .environment(store) + .onAppear { + appDelegate.attach(store: store) + } + } label: { + Image(systemName: "cloud.fill") + } + .menuBarExtraStyle(.window) + + Settings { + PreferencesView() + .environment(store) + .frame(width: 520, height: 420) + .preferredColorScheme(colorScheme) + } + .commands { + CommandGroup(replacing: .appInfo) { + Button("О YanDisk") { + NSApp.orderFrontStandardAboutPanel(nil) + } + } + } + } + + private var colorScheme: ColorScheme? { + switch appearance { + case "light": return .light + case "dark": return .dark + default: return nil + } + } +} + +struct RootView: View { + @Environment(AppStore.self) private var store + @AppStorage("Appearance") private var appearance = "system" + + var body: some View { + MainSplitView() + .environment(store) + .preferredColorScheme(colorScheme) + .task { + store.bootstrap() + } + } + + private var colorScheme: ColorScheme? { + switch appearance { + case "light": return .light + case "dark": return .dark + default: return nil + } + } +} diff --git a/Sources/YanDisk/Core/AppStore.swift b/Sources/YanDisk/Core/AppStore.swift new file mode 100644 index 0000000..8ac1f01 --- /dev/null +++ b/Sources/YanDisk/Core/AppStore.swift @@ -0,0 +1,476 @@ +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 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() + var pathStack: [String] = ["/"] + + let syncEngine = SyncEngine() + private var api: DiskAPIClient? + + 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 + } + } + + 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 = [] + 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, .favorites: + navigate(to: "/") + case .files: + // «Доступные мне» — пока заглушка, API shared-with-me в плане (см. ПРАВКИ.md) + items = [] + currentPath = "/" + pathStack = ["/"] + 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 .files: items = [] // «Доступные мне» — заглушка + default: await loadDirectory(path: currentPath) + } + await syncEngine.syncNow() + } + + 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.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) + } +} diff --git a/Sources/YanDisk/Core/DiskAPIClient.swift b/Sources/YanDisk/Core/DiskAPIClient.swift new file mode 100644 index 0000000..cc7d799 --- /dev/null +++ b/Sources/YanDisk/Core/DiskAPIClient.swift @@ -0,0 +1,364 @@ +import Foundation + +actor DiskAPIClient { + static let baseURL = URL(string: "https://cloud-api.yandex.net/v1/disk")! + static let clientId = "43f1f51e71484737aa1df6d3422d7823" + + private let session: URLSession + private var token: String + + init(token: String) { + let config = URLSessionConfiguration.ephemeral + config.timeoutIntervalForRequest = 60 + config.timeoutIntervalForResource = 600 + config.httpAdditionalHeaders = [ + "Accept": "application/json", + "User-Agent": "YanDisk/1.0 (macOS)" + ] + self.session = URLSession(configuration: config) + self.token = token + } + + func updateToken(_ token: String) { + self.token = token + } + + // MARK: - Private helpers + + private func makeRequest( + path: String, + query: [URLQueryItem] = [], + method: String = "GET", + body: Data? = nil, + authorized: Bool = true + ) throws -> URLRequest { + guard var components = URLComponents(url: Self.baseURL.appendingPathComponent(path), resolvingAgainstBaseURL: false) else { + throw DiskAPIError.transport("Некорректный URL") + } + if !query.isEmpty { + components.queryItems = query + } + guard let url = components.url else { + throw DiskAPIError.transport("Некорректный URL") + } + var request = URLRequest(url: url) + request.httpMethod = method + if authorized { + request.setValue("OAuth \(token)", forHTTPHeaderField: "Authorization") + } + if let body { + request.httpBody = body + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + } + return request + } + + private func send(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) { + let (data, response): (Data, URLResponse) + do { + (data, response) = try await session.data(for: request) + } catch { + throw DiskAPIError.transport(error.localizedDescription) + } + guard let http = response as? HTTPURLResponse else { + throw DiskAPIError.transport("Некорректный ответ") + } + switch http.statusCode { + case 200, 201, 202, 204: + return (data, http) + case 401: + throw DiskAPIError.unauthorized + case 404: + throw DiskAPIError.notFound + case 409: + throw DiskAPIError.conflict + case 429: + throw DiskAPIError.tooManyRequests + case 507: + throw DiskAPIError.insufficientSpace + default: + let message = (try? JSONSerialization.jsonObject(with: data) as? [String: Any])?["description"] as? String + ?? (String(data: data, encoding: .utf8) ?? "HTTP \(http.statusCode)") + throw DiskAPIError.server(message) + } + } + + private func decode(_ type: T.Type, from data: Data) throws -> T { + let decoder = JSONDecoder() + do { + return try decoder.decode(T.self, from: data) + } catch { + throw DiskAPIError.decoding + } + } + + private static func diskPath(_ path: String) -> String { + if path.hasPrefix("disk:") || path.hasPrefix("trash:") { + return path + } + let normalized = path.hasPrefix("/") ? path : "/" + path + return "disk:\(normalized)" + } + + private func linkItem(path: String, query: [URLQueryItem], method: String, body: Data?) async throws -> LinkResponse { + let request = try makeRequest(path: path, query: query, method: method, body: body) + let (data, _) = try await send(request) + return try decode(LinkResponse.self, from: data) + } + + // MARK: - Disk info + + func diskInfo() async throws -> DiskInfo { + let request = try makeRequest(path: "") + let (data, _) = try await send(request) + return try decode(DiskInfo.self, from: data) + } + + // MARK: - Resources + + func resource(path: String, limit: Int = 500, offset: Int = 0) async throws -> DiskResource { + let query = [ + URLQueryItem(name: "path", value: Self.diskPath(path)), + URLQueryItem(name: "limit", value: String(limit)), + URLQueryItem(name: "offset", value: String(offset)) + ] + let request = try makeRequest(path: "resources", query: query) + let (data, _) = try await send(request) + return try decode(DiskResource.self, from: data) + } + + func listDirectory(path: String) async throws -> [DiskResource] { + var items: [DiskResource] = [] + var offset = 0 + let limit = 500 + while true { + let page = try await resource(path: path, limit: limit, offset: offset) + let batch = page.embedded?.items ?? [] + items.append(contentsOf: batch) + let total = page.embedded?.total ?? items.count + offset += batch.count + if batch.isEmpty || offset >= total { + break + } + } + return items + } + + func createDirectory(path: String) async throws { + let query = [URLQueryItem(name: "path", value: Self.diskPath(path))] + let request = try makeRequest(path: "resources", query: query, method: "PUT") + _ = try await send(request) + } + + // MARK: - Copy / Move / Delete + + func copy(from: String, to: String, overwrite: Bool = false) async throws { + let query = [ + URLQueryItem(name: "from", value: Self.diskPath(from)), + URLQueryItem(name: "path", value: Self.diskPath(to)), + URLQueryItem(name: "overwrite", value: overwrite ? "true" : "false") + ] + let request = try makeRequest(path: "resources/copy", query: query, method: "POST") + let (data, http) = try await send(request) + if http.statusCode == 202, let link = try? decode(LinkResponse.self, from: data) { + let id = link.href.components(separatedBy: "id=").last ?? "" + if !id.isEmpty { + try await waitOperation(operationId: id) + } + } + } + + func move(from: String, to: String, overwrite: Bool = false) async throws { + let query = [ + URLQueryItem(name: "from", value: Self.diskPath(from)), + URLQueryItem(name: "path", value: Self.diskPath(to)), + URLQueryItem(name: "overwrite", value: overwrite ? "true" : "false") + ] + let request = try makeRequest(path: "resources/move", query: query, method: "POST") + let (data, http) = try await send(request) + if http.statusCode == 202, let link = try? decode(LinkResponse.self, from: data) { + let id = link.href.components(separatedBy: "id=").last ?? "" + if !id.isEmpty { + try await waitOperation(operationId: id) + } + } + } + + func delete(path: String, permanently: Bool = false) async throws { + let query = [ + URLQueryItem(name: "path", value: Self.diskPath(path)), + URLQueryItem(name: "permanently", value: permanently ? "true" : "false") + ] + let request = try makeRequest(path: "resources", query: query, method: "DELETE") + _ = try await send(request) + } + + // MARK: - Trash + + func listTrash() async throws -> [DiskResource] { + let query = [URLQueryItem(name: "limit", value: "500")] + let request = try makeRequest(path: "trash/resources", query: query) + let (data, _) = try await send(request) + let resource = try decode(DiskResource.self, from: data) + return resource.embedded?.items ?? [] + } + + func restoreFromTrash(path: String, name: String?) async throws { + var query = [URLQueryItem(name: "path", value: path)] + if let name { + query.append(URLQueryItem(name: "name", value: name)) + } + let request = try makeRequest(path: "trash/resources/restore", query: query, method: "PUT") + _ = try await send(request) + } + + func emptyTrash() async throws { + let request = try makeRequest(path: "trash/resources", method: "DELETE") + _ = try await send(request) + } + + // MARK: - Download / Upload + + func downloadURL(path: String) async throws -> URL { + let query = [URLQueryItem(name: "path", value: Self.diskPath(path))] + let request = try makeRequest(path: "resources/download", query: query) + let (data, _) = try await send(request) + let link = try decode(LinkResponse.self, from: data) + guard let url = URL(string: link.href) else { + throw DiskAPIError.transport("Некорректная ссылка скачивания") + } + return url + } + + func downloadFile(path: String, to localURL: URL, progress: (@Sendable (Double) -> Void)? = nil) async throws { + let remote = try await downloadURL(path: path) + var request = URLRequest(url: remote) + request.setValue("OAuth \(token)", forHTTPHeaderField: "Authorization") + let (tempURL, response) = try await session.download(for: request) + guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else { + throw DiskAPIError.transport("Ошибка скачивания") + } + let fm = FileManager.default + let dir = localURL.deletingLastPathComponent() + try fm.createDirectory(at: dir, withIntermediateDirectories: true) + if fm.fileExists(atPath: localURL.path) { + try fm.removeItem(at: localURL) + } + try fm.moveItem(at: tempURL, to: localURL) + progress?(1) + } + + func uploadURL(path: String, overwrite: Bool = true) async throws -> URL { + let query = [ + URLQueryItem(name: "path", value: Self.diskPath(path)), + URLQueryItem(name: "overwrite", value: overwrite ? "true" : "false") + ] + let request = try makeRequest(path: "resources/upload", query: query) + let (data, _) = try await send(request) + let link = try decode(LinkResponse.self, from: data) + guard let url = URL(string: link.href) else { + throw DiskAPIError.transport("Некорректная ссылка загрузки") + } + return url + } + + func uploadFile(localURL: URL, to path: String, overwrite: Bool = true) async throws { + let remote = try await uploadURL(path: path, overwrite: overwrite) + var request = URLRequest(url: remote) + request.httpMethod = "PUT" + request.setValue("application/octet-stream", forHTTPHeaderField: "Content-Type") + let (_, response) = try await session.upload(for: request, fromFile: localURL) + guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else { + throw DiskAPIError.transport("Ошибка загрузки") + } + } + + // MARK: - Publish / Share + + func publish(path: String, settings: ShareAccessSettings) async throws -> String { + var body: [String: Any] = [:] + var rights = [settings.rights.rawValue] + if let until = settings.availableUntilSeconds { + body["available_until"] = until + } + if !settings.password.isEmpty { + if settings.rights == .read { + rights = [ShareRight.readWithPassword.rawValue] + } + body["password"] = settings.password + } + if settings.everyone { + body["accesses"] = [ + ["macros": ["all"], "rights": rights] + ] + } + let payload = ["public_settings": body] + let data = try JSONSerialization.data(withJSONObject: payload) + let query = [ + URLQueryItem(name: "path", value: Self.diskPath(path)), + URLQueryItem(name: "allow_address_access", value: "true") + ] + let request = try makeRequest(path: "resources/publish", query: query, method: "PUT", body: data) + _ = try await send(request) + + let meta = try await resource(path: path) + if let url = meta.publicUrl, !url.isEmpty { + return url + } + if let key = meta.publicKey { + return "https://disk.yandex.ru/d/\(key)" + } + throw DiskAPIError.transport("Ссылка не создана") + } + + func unpublish(path: String) async throws { + let query = [URLQueryItem(name: "path", value: Self.diskPath(path))] + let request = try makeRequest(path: "resources/unpublish", query: query, method: "PUT") + _ = try await send(request) + } + + // MARK: - Operations poll + + func waitOperation(operationId: String, timeout: TimeInterval = 600) async throws { + let start = Date() + while Date().timeIntervalSince(start) < timeout { + let request = try makeRequest(path: "operations/\(operationId)") + let (data, _) = try await send(request) + let op = try decode(OperationStatus.self, from: data) + switch op.status { + case "success": + return + case "failure": + throw DiskAPIError.server(op.error ?? "Операция завершилась с ошибкой") + default: + try await Task.sleep(nanoseconds: 800_000_000) + } + } + throw DiskAPIError.server("Операция не завершилась вовремя") + } + + // MARK: - Last uploaded / files + + func lastUploaded(limit: Int = 30) async throws -> [DiskResource] { + let query = [URLQueryItem(name: "limit", value: String(limit))] + let request = try makeRequest(path: "resources/last-uploaded", query: query) + let (data, _) = try await send(request) + let list = try decode(ResourceList.self, from: data) + return list.items ?? [] + } + + func recentlyModified(limit: Int = 30) async throws -> [DiskResource] { + let request = try makeRequest(path: "resources", query: [ + URLQueryItem(name: "path", value: "disk:/"), + URLQueryItem(name: "limit", value: String(limit)), + URLQueryItem(name: "sort", value: "modified") + ]) + let (data, _) = try await send(request) + let resource = try decode(DiskResource.self, from: data) + return resource.embedded?.items ?? [] + } + + func filesList(limit: Int = 200) async throws -> [DiskResource] { + try await listDirectory(path: "/").filter { !$0.isDirectory } + } +} diff --git a/Sources/YanDisk/Core/KeychainStore.swift b/Sources/YanDisk/Core/KeychainStore.swift new file mode 100644 index 0000000..862cd36 --- /dev/null +++ b/Sources/YanDisk/Core/KeychainStore.swift @@ -0,0 +1,43 @@ +import Foundation +import Security + +enum KeychainStore { + private static let service = "local.yandisk.app" + private static let account = "oauth-token" + + static func saveToken(_ token: String) { + let data = Data(token.utf8) + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account + ] + SecItemDelete(query as CFDictionary) + var add = query + add[kSecValueData as String] = data + SecItemAdd(add as CFDictionary, nil) + } + + static func loadToken() -> String? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne + ] + var item: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &item) + guard status == errSecSuccess, let data = item as? Data else { return nil } + return String(data: data, encoding: .utf8) + } + + static func deleteToken() { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account + ] + SecItemDelete(query as CFDictionary) + } +} diff --git a/Sources/YanDisk/Core/Models.swift b/Sources/YanDisk/Core/Models.swift new file mode 100644 index 0000000..76c15ff --- /dev/null +++ b/Sources/YanDisk/Core/Models.swift @@ -0,0 +1,190 @@ +import Foundation + +enum ResourceType: String, Codable, Sendable { + case file + case dir +} + +struct DiskResource: Codable, Identifiable, Hashable, Sendable { + let name: String? + let created: String? + let modified: String? + let path: String + let type: ResourceType? + let md5: String? + let size: Int64? + let mimeType: String? + let preview: String? + let publicKey: String? + let publicUrl: String? + let originPath: String? + let embedded: ResourceList? + + enum CodingKeys: String, CodingKey { + case name, created, modified, path, type, md5, size, preview + case mimeType = "mime_type" + case publicKey = "public_key" + case publicUrl = "public_url" + case originPath = "origin_path" + case embedded = "_embedded" + } + + var id: String { path } + var isDirectory: Bool { type == .dir } + var displayName: String { + if let name, !name.isEmpty { return name } + return (path as NSString).lastPathComponent + } + var displaySize: Int64 { size ?? 0 } +} + +struct ResourceList: Codable, Hashable, Sendable { + let items: [DiskResource]? + let limit: Int? + let offset: Int? + let path: String? + let total: Int? +} + +struct DiskInfo: Codable, Sendable { + let totalSpace: Int64? + let trashSize: Int64? + let usedSpace: Int64? + let systemFolders: [String: String]? + let unlimitedAutoUploadEnabled: Bool? + let isPaid: Bool? + + enum CodingKeys: String, CodingKey { + case totalSpace = "total_space" + case trashSize = "trash_size" + case usedSpace = "used_space" + case systemFolders = "system_folders" + case unlimitedAutoUploadEnabled = "unlimited_autoupload_enabled" + case isPaid = "is_paid" + } +} + +struct LinkResponse: Codable, Sendable { + let href: String + let method: String? + let templated: Bool? + let operationId: String? + + enum CodingKeys: String, CodingKey { + case href, method, templated + case operationId = "operation_id" + } +} + +struct OperationStatus: Codable, Sendable { + let status: String? + let progress: Int? + let error: String? +} + +enum ShareRight: String, CaseIterable, Identifiable, Sendable { + case read + case write + case readOnly = "read_without_download" + case readWithPassword = "read_with_password" + case readWithPasswordNoDownload = "read_with_password_without_download" + + var id: String { rawValue } + + var title: String { + switch self { + case .read: return "Просмотр" + case .write: return "Редактирование" + case .readOnly: return "Просмотр без скачивания" + case .readWithPassword: return "По паролю" + case .readWithPasswordNoDownload: return "По паролю, без скачивания" + } + } +} + +struct ShareAccessSettings: Sendable { + var rights: ShareRight = .read + var password: String = "" + var availableUntilSeconds: Int? = nil + var everyone: Bool = true +} + +enum SyncMode: String, CaseIterable, Identifiable, Codable, Sendable { + case everywhere + case computerOnly + case cloudOnly + + var id: String { rawValue } + + var title: String { + switch self { + case .everywhere: return "Везде" + case .computerOnly: return "Только на компьютере" + case .cloudOnly: return "Только в облаке" + } + } + + var shortTitle: String { + switch self { + case .everywhere: return "Везде" + case .computerOnly: return "На компьютере" + case .cloudOnly: return "В облаке" + } + } +} + +enum SyncState: Equatable, Sendable { + case idle + case syncing + case success + case offline + case error(String) + + var label: String { + switch self { + case .idle: return "Готово" + case .syncing: return "Идёт синхронизация" + case .success: return "Синхронизирован" + case .offline: return "Нет соединения" + case .error(let m): return m + } + } +} + +struct TransferTask: Identifiable, Equatable, Sendable { + enum Kind: String, Sendable { + case upload, download + var title: String { self == .upload ? "Загрузка" : "Скачивание" } + } + + let id = UUID() + let kind: Kind + let path: String + var progress: Double + var isFinished: Bool + var isError: Bool +} + +enum DiskAPIError: LocalizedError { + case unauthorized + case notFound + case insufficientSpace + case conflict + case tooManyRequests + case server(String) + case decoding + case transport(String) + + var errorDescription: String? { + switch self { + case .unauthorized: return "Не авторизован" + case .notFound: return "Не удалось найти запрошенный ресурс" + case .insufficientSpace: return "Недостаточно свободного места" + case .conflict: return "Ресурс уже существует" + case .tooManyRequests: return "Слишком много запросов" + case .server(let m): return m + case .decoding: return "Ошибка разбора ответа" + case .transport(let m): return m + } + } +} diff --git a/Sources/YanDisk/Core/OAuthManager.swift b/Sources/YanDisk/Core/OAuthManager.swift new file mode 100644 index 0000000..4e0ea40 --- /dev/null +++ b/Sources/YanDisk/Core/OAuthManager.swift @@ -0,0 +1,62 @@ +import AppKit +import Foundation + +enum OAuthManager { + static let clientId = DiskAPIClient.clientId + + /// Implicit token flow — страница Яндекса покажет access_token. + /// Redirect URL из настроек приложения (verification_code) подходит для веб-сервисов; + /// настольный клиент открывает браузер, пользователь вставляет токен. + static var authorizeURL: URL { + var components = URLComponents(string: "https://oauth.yandex.ru/authorize")! + components.queryItems = [ + URLQueryItem(name: "response_type", value: "token"), + URLQueryItem(name: "client_id", value: clientId), + URLQueryItem(name: "force_confirm", value: "yes") + ] + return components.url! + } + + static func openAuthorization() { + NSWorkspace.shared.open(authorizeURL) + } + + /// Разбирает token из вставленной строки (URL с #access_token=... или голый токен). + static func parseToken(from input: String) -> String? { + let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { return nil } + + if let url = URL(string: trimmed), + let items = URLComponents(url: url, resolvingAgainstBaseURL: false)?.fragmentItems { + if let token = items.first(where: { $0.name == "access_token" })?.value, !token.isEmpty { + return token + } + } + + if trimmed.contains("access_token=") { + let parts = trimmed + .replacingOccurrences(of: "#", with: "&") + .replacingOccurrences(of: "?", with: "&") + .split(separator: "&") + for part in parts { + let kv = part.split(separator: "=", maxSplits: 1) + if kv.count == 2, kv[0] == "access_token" { + return String(kv[1]) + } + } + } + + // Голый токен: обычно 32+ hex/symbols + if trimmed.range(of: "^[A-Za-z0-9_\\-]{16,}$", options: .regularExpression) != nil { + return trimmed + } + return nil + } +} + +private extension URLComponents { + var fragmentItems: [URLQueryItem]? { + guard let fragment else { return nil } + return URLComponents(string: "?\(fragment)")?.queryItems + } +} diff --git a/Sources/YanDisk/Sync/SyncEngine.swift b/Sources/YanDisk/Sync/SyncEngine.swift new file mode 100644 index 0000000..9ec36e6 --- /dev/null +++ b/Sources/YanDisk/Sync/SyncEngine.swift @@ -0,0 +1,294 @@ +import Foundation + +/// Локальная политика синхронизации: per-path override + глобальный режим. +struct SyncPolicyStore: Codable { + var globalMode: SyncMode = .cloudOnly + var pathModes: [String: SyncMode] = [:] + + func mode(for path: String) -> SyncMode { + if let exact = pathModes[path] { return exact } + // наследуем от ближайшего предка + var current = (path as NSString).deletingLastPathComponent + while current != "/" && !current.isEmpty { + if let mode = pathModes[current] { return mode } + current = (current as NSString).deletingLastPathComponent + } + return globalMode + } + + mutating func setMode(_ mode: SyncMode, for path: String) { + pathModes[path] = mode + } + + mutating func clearMode(for path: String) { + pathModes.removeValue(forKey: path) + } +} + +@MainActor +final class SyncEngine: ObservableObject { + @Published var state: SyncState = .idle + @Published var lastSyncDate: Date? + @Published var policy = SyncPolicyStore() + @Published var isSyncEnabled = true + @Published var syncFolderURL: URL + @Published var tasks: [TransferTask] = [] + + private var api: DiskAPIClient? + private var timer: Timer? + private var isRunning = false + private let prefsKey = "SyncPolicyStore" + + init() { + let home = FileManager.default.homeDirectoryForCurrentUser + self.syncFolderURL = home.appendingPathComponent("Yandex.Disk", isDirectory: true) + loadPolicy() + if let saved = UserDefaults.standard.string(forKey: "SyncFolderPath"), !saved.isEmpty { + self.syncFolderURL = URL(fileURLWithPath: saved, isDirectory: true) + } + self.isSyncEnabled = UserDefaults.standard.object(forKey: "SyncEnabled") as? Bool ?? true + } + + func attach(api: DiskAPIClient) { + self.api = api + startTimer() + } + + func detach() { + timer?.invalidate() + timer = nil + api = nil + state = .idle + } + + private func startTimer() { + timer?.invalidate() + timer = Timer.scheduledTimer(withTimeInterval: 25, repeats: true) { [weak self] _ in + Task { @MainActor in + await self?.syncNow() + } + } + Task { @MainActor in await syncNow() } + } + + func setSyncFolder(_ url: URL) { + syncFolderURL = url + UserDefaults.standard.set(url.path, forKey: "SyncFolderPath") + try? FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + } + + func setGlobalMode(_ mode: SyncMode) { + policy.globalMode = mode + savePolicy() + Task { await syncNow() } + } + + func setMode(_ mode: SyncMode, for path: String) { + policy.setMode(mode, for: path) + savePolicy() + Task { await syncNow() } + } + + func setSyncEnabled(_ enabled: Bool) { + isSyncEnabled = enabled + UserDefaults.standard.set(enabled, forKey: "SyncEnabled") + if enabled { + Task { await syncNow() } + } else { + state = .idle + } + } + + private func loadPolicy() { + guard let data = UserDefaults.standard.data(forKey: prefsKey), + let decoded = try? JSONDecoder().decode(SyncPolicyStore.self, from: data) else { + return + } + policy = decoded + } + + private func savePolicy() { + if let data = try? JSONEncoder().encode(policy) { + UserDefaults.standard.set(data, forKey: prefsKey) + } + } + + func localURL(for cloudPath: String) -> URL { + let relative = cloudPath.hasPrefix("/") ? String(cloudPath.dropFirst()) : cloudPath + return syncFolderURL.appendingPathComponent(relative) + } + + func cloudPath(for localURL: URL) -> String? { + let base = syncFolderURL.standardizedFileURL.path + let full = localURL.standardizedFileURL.path + guard full == base || full.hasPrefix(base + "/") else { return nil } + if full == base { return "/" } + let rel = String(full.dropFirst(base.count)) + return rel.hasPrefix("/") ? rel : "/" + rel + } + + func syncNow() async { + guard isSyncEnabled, let api, !isRunning else { return } + isRunning = true + state = .syncing + defer { + isRunning = false + lastSyncDate = Date() + } + + do { + try FileManager.default.createDirectory(at: syncFolderURL, withIntermediateDirectories: true) + try await syncDirectory(cloudPath: "/", api: api) + // Загрузка локальных новых файлов в корне, если режим не cloudOnly на уровне + try await uploadLocalChanges(cloudPath: "/", api: api) + state = .success + } catch let error as DiskAPIError { + if case .unauthorized = error { + state = .error("Не авторизован") + } else { + state = .error(error.localizedDescription) + } + } catch { + state = .error(error.localizedDescription) + } + } + + private func syncDirectory(cloudPath: String, api: DiskAPIClient, depth: Int = 0) async throws { + guard depth < 6 else { return } + let mode = policy.mode(for: cloudPath) + + switch mode { + case .cloudOnly: + // Удаляем локальную копию, если есть + let local = localURL(for: cloudPath) + if cloudPath != "/" { + try? FileManager.default.removeItem(at: local) + } + return + case .computerOnly, .everywhere: + break + } + + let remoteItems = try await api.listDirectory(path: cloudPath) + let localDir = localURL(for: cloudPath) + try FileManager.default.createDirectory(at: localDir, withIntermediateDirectories: true) + + let fm = FileManager.default + let localNames = Set((try? fm.contentsOfDirectory(atPath: localDir.path)) ?? []) + + for item in remoteItems { + let itemMode = policy.mode(for: item.path) + if itemMode == .cloudOnly { + let local = localURL(for: item.path) + try? fm.removeItem(at: local) + continue + } + + let localURL = localURL(for: item.path) + if item.isDirectory { + try await syncDirectory(cloudPath: item.path, api: api, depth: depth + 1) + } else { + let exists = fm.fileExists(atPath: localURL.path) + if !exists { + if itemMode == .everywhere || itemMode == .computerOnly { + appendTask(kind: .download, path: item.path) + try await api.downloadFile(path: item.path, to: localURL) + finishLastTask(success: true) + } + } else if itemMode == .everywhere { + let attrs = try? fm.attributesOfItem(atPath: localURL.path) + let localSize = (attrs?[.size] as? Int64) ?? -1 + let remoteSize = item.size ?? -1 + if remoteSize >= 0, localSize != remoteSize, localSize < remoteSize { + appendTask(kind: .download, path: item.path) + try await api.downloadFile(path: item.path, to: localURL) + finishLastTask(success: true) + } + } + } + } + + // Локальные элементы, которых нет в облаке + if mode != .computerOnly { + for name in localNames { + if name.hasPrefix(".") { continue } + let childLocal = localDir.appendingPathComponent(name) + let childCloud = cloudPath == "/" ? "/\(name)" : "\(cloudPath)/\(name)" + let known = remoteItems.contains { $0.displayName == name } + if !known { + var isDir: ObjCBool = false + guard fm.fileExists(atPath: childLocal.path, isDirectory: &isDir) else { continue } + if isDir.boolValue { + try await api.createDirectory(path: childCloud) + try await uploadLocalChanges(cloudPath: childCloud, api: api) + } else { + appendTask(kind: .upload, path: childCloud) + try await api.uploadFile(localURL: childLocal, to: childCloud, overwrite: true) + finishLastTask(success: true) + } + } + } + } + } + + private func uploadLocalChanges(cloudPath: String, api: DiskAPIClient) async throws { + let mode = policy.mode(for: cloudPath) + guard mode != .cloudOnly else { return } + + let dir = localURL(for: cloudPath) + let fm = FileManager.default + guard let names = try? fm.contentsOfDirectory(atPath: dir.path) else { return } + + for name in names { + if name.hasPrefix(".") { continue } + let childLocal = dir.appendingPathComponent(name) + let childCloud = cloudPath == "/" ? "/\(name)" : "\(cloudPath)/\(name)" + var isDir: ObjCBool = false + guard fm.fileExists(atPath: childLocal.path, isDirectory: &isDir) else { continue } + + if isDir.boolValue { + if policy.mode(for: childCloud) != .cloudOnly { + try await uploadLocalChanges(cloudPath: childCloud, api: api) + } + } else { + // Проверяем, есть ли в облаке; если нет — загружаем + let existsRemotely = (try? await api.resource(path: childCloud)) != nil + if !existsRemotely { + appendTask(kind: .upload, path: childCloud) + try await api.uploadFile(localURL: childLocal, to: childCloud, overwrite: true) + finishLastTask(success: true) + } + } + } + } + + private func appendTask(kind: TransferTask.Kind, path: String) { + let task = TransferTask(kind: kind, path: path, progress: 0, isFinished: false, isError: false) + tasks.insert(task, at: 0) + if tasks.count > 30 { tasks.removeLast(tasks.count - 30) } + } + + private func finishLastTask(success: Bool) { + guard !tasks.isEmpty else { return } + tasks[0].progress = 1 + tasks[0].isFinished = true + tasks[0].isError = !success + } + + /// Применить режим к папке: cloudOnly удаляет локальную копию. + func applyModeChange(_ mode: SyncMode, path: String) async { + setMode(mode, for: path) + if mode == .cloudOnly, path != "/" { + try? FileManager.default.removeItem(at: localURL(for: path)) + } + if mode == .computerOnly || mode == .everywhere, let api { + if path == "/" { + try? FileManager.default.createDirectory(at: syncFolderURL, withIntermediateDirectories: true) + } else { + try? FileManager.default.createDirectory(at: localURL(for: path), withIntermediateDirectories: true) + } + _ = try? await api.resource(path: path) + } + await syncNow() + } +} diff --git a/Sources/YanDisk/UI/LoginView.swift b/Sources/YanDisk/UI/LoginView.swift new file mode 100644 index 0000000..bbcc7c3 --- /dev/null +++ b/Sources/YanDisk/UI/LoginView.swift @@ -0,0 +1,59 @@ +import SwiftUI + +struct LoginView: View { + @Environment(AppStore.self) private var store + + var body: some View { + @Bindable var store = store + VStack(spacing: 20) { + Image(systemName: "cloud.circle.fill") + .font(.system(size: 56)) + .foregroundStyle(Color(red: 0.93, green: 0.0, blue: 0.21)) + + Text("YanDisk") + .font(.largeTitle.bold()) + + Text("Войдите, чтобы синхронизировать файлы") + .foregroundStyle(.secondary) + + Button { + store.openAuthorization() + } label: { + Label("Войти через Яндекс", systemImage: "person.crop.circle") + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + .tint(Color(red: 0.93, green: 0.0, blue: 0.21)) + + VStack(alignment: .leading, spacing: 8) { + Text("Вставьте OAuth-токен") + .font(.caption) + .foregroundStyle(.secondary) + TextField("access_token или ссылка с токеном", text: $store.pasteToken) + .textFieldStyle(.roundedBorder) + Text("После входа скопируйте токен со страницы Яндекса и вставьте сюда.") + .font(.caption2) + .foregroundStyle(.secondary) + } + + Button("Продолжить") { + store.login(with: store.pasteToken) + } + .keyboardShortcut(.defaultAction) + .disabled(store.pasteToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + + if let error = store.errorMessage { + Text(error) + .font(.caption) + .foregroundStyle(.red) + } + + Text("Client ID: \(OAuthManager.clientId)") + .font(.caption2) + .foregroundStyle(.tertiary) + } + .padding(28) + .frame(width: 420) + } +} diff --git a/Sources/YanDisk/UI/MainSplitView.swift b/Sources/YanDisk/UI/MainSplitView.swift new file mode 100644 index 0000000..80a47ff --- /dev/null +++ b/Sources/YanDisk/UI/MainSplitView.swift @@ -0,0 +1,369 @@ +import SwiftUI +import AppKit + +struct MainSplitView: View { + @Environment(AppStore.self) private var store + @Environment(\.openWindow) private var openWindow + @State private var showNewFolderAlert = false + @State private var newFolderName = "" + @State private var renameItem: DiskResource? + @State private var renameText = "" + + var body: some View { + @Bindable var store = store + NavigationSplitView { + List(AppStore.SidebarItem.displayCases, selection: $store.sidebarSelection) { item in + Label(item.title, systemImage: item.systemImage) + .tag(item) + } + .listStyle(.sidebar) + .safeAreaInset(edge: .bottom) { + if store.diskInfo != nil { + VStack(alignment: .leading, spacing: 4) { + Text("Место на Диске") + .font(.caption) + .foregroundStyle(.secondary) + ProgressView(value: store.usedPercent) + Text("\(store.formattedUsed) / \(store.formattedTotal)") + .font(.caption2) + .foregroundStyle(.secondary) + } + .padding(10) + } + } + } detail: { + VStack(spacing: 0) { + if store.sidebarSelection == .tasks { + TasksView() + } else { + pathBar(store: store) + Divider() + fileBrowser(store: store) + } + if let status = store.statusMessage { + Divider() + HStack { + Image(systemName: "checkmark.circle") + Text(status) + Spacer() + } + .font(.caption) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(.thinMaterial) + } + } + } + .toolbar { + ToolbarItemGroup { + Button { + showNewFolderAlert = true + } label: { + Label("Новая папка", systemImage: "folder.badge.plus") + } + + Button { + let panel = NSOpenPanel() + panel.allowsMultipleSelection = true + panel.canChooseDirectories = true + if panel.runModal() == .OK { + Task { await store.uploadFiles(from: panel.urls) } + } + } label: { + Label("Загрузить", systemImage: "square.and.arrow.up") + } + + if store.sidebarSelection == .trash { + Button { + Task { await store.emptyTrash() } + } label: { + Label("Очистить Корзину", systemImage: "trash") + } + } + + Picker("Режим", selection: Binding( + get: { store.syncEngine.policy.globalMode }, + set: { store.syncEngine.setGlobalMode($0) } + )) { + ForEach(SyncMode.allCases) { mode in + Text(mode.title).tag(mode) + } + } + .pickerStyle(.menu) + + Button { + NSApp.activate(ignoringOtherApps: true) + NSApp.sendAction(#selector(AppDelegate.showPreferencesWindow(_:)), to: nil, from: nil) + } label: { + Label("Настройки", systemImage: "gearshape") + } + } + } + .sheet(isPresented: $store.showShareSheet) { + ShareSheetView() + .environment(store) + } + .sheet(isPresented: $store.showLoginSheet) { + LoginView() + .environment(store) + } + .alert("Новая папка", isPresented: $showNewFolderAlert) { + TextField("Имя папки", text: $newFolderName) + Button("Создать") { + let name = newFolderName + newFolderName = "" + Task { await store.createFolder(named: name) } + } + Button("Отмена", role: .cancel) {} + } + .alert("Переименовать", isPresented: Binding( + get: { renameItem != nil }, + set: { if !$0 { renameItem = nil } } + )) { + TextField("Новое имя", text: $renameText) + Button("OK") { + if let item = renameItem { + let name = renameText + renameItem = nil + Task { await store.rename(item, to: name) } + } + } + Button("Отмена", role: .cancel) { renameItem = nil } + } + .onAppear { store.bootstrap() } + .onChange(of: store.sidebarSelection) { _, newValue in + store.selectSidebar(newValue) + } + } + + private func pathBar(store: AppStore) -> some View { + HStack(spacing: 8) { + Button { + store.goBack() + } label: { + Image(systemName: "chevron.left") + } + .disabled(store.pathStack.count <= 1 || store.sidebarSelection == .trash) + + Button { + Task { await store.refreshAll() } + } label: { + Image(systemName: "arrow.clockwise") + } + + Text(breadcrumb(store: store)) + .font(.subheadline) + .lineLimit(1) + Spacer() + TextField("Поиск", text: Binding( + get: { store.searchText }, + set: { store.searchText = $0 } + )) + .textFieldStyle(.roundedBorder) + .frame(width: 180) + } + .padding(10) + } + + private func breadcrumb(store: AppStore) -> String { + switch store.sidebarSelection { + case .trash: return "Корзина" + case .recents: return "Недавние" + case .favorites: return "Избранное" + case .screenshots: return "Скриншоты" + case .files: return "Доступные мне" + default: + if store.currentPath == "/" { return "YanDisk" } + return store.currentPath + } + } + + private func visibleItems(store: AppStore) -> [DiskResource] { + switch store.sidebarSelection { + case .trash: return store.trashItems + case .recents: return store.recentItems + case .files: return [] // «Доступные мне» — заглушка до реализации API + default: return store.filteredItems + } + } + + @ViewBuilder + private func fileBrowser(store: AppStore) -> some View { + let items = visibleItems(store: store) + if store.isLoading && items.isEmpty { + ProgressView("Загрузка…") + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if items.isEmpty { + VStack(spacing: 12) { + Image(systemName: store.sidebarSelection == .trash ? "trash" : "folder") + .font(.system(size: 40)) + .foregroundStyle(.tertiary) + Text(store.sidebarSelection == .trash ? "Корзина пуста" : "Папка пуста") + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + Table(items, selection: Binding( + get: { store.selection }, + set: { store.selection = $0 } + )) { + TableColumn("Имя") { item in + HStack(spacing: 8) { + Image(systemName: icon(for: item)) + .foregroundStyle(item.isDirectory ? Color.accentColor : .secondary) + .frame(width: 18) + Text(item.displayName) + .lineLimit(1) + if item.publicUrl != nil { + Image(systemName: "link") + .font(.caption2) + .foregroundStyle(.secondary) + } + } + } + TableColumn("Размер") { item in + Text(item.isDirectory ? "—" : ByteFormatter.string(info: item.displaySize)) + .foregroundStyle(.secondary) + } + .width(100) + TableColumn("Изменён") { item in + Text(formattedDate(item.modified)) + .foregroundStyle(.secondary) + } + .width(140) + TableColumn("Синхр.") { item in + Text(store.syncEngine.policy.mode(for: item.path).shortTitle) + .foregroundStyle(.secondary) + .font(.caption) + } + .width(100) + } + .contextMenu(forSelectionType: String.self) { ids in + if let path = ids.first, let item = items.first(where: { $0.path == path }) { + contextMenu(store: store, item: item) + } + } primaryAction: { ids in + if let path = ids.first, let item = items.first(where: { $0.path == path }) { + store.open(item) + } + } + .dropDestination(for: URL.self) { urls, _ in + let files = urls.filter { $0.isFileURL } + guard !files.isEmpty else { return false } + Task { await store.uploadFiles(from: files) } + return true + } + } + } + + @ViewBuilder + private func contextMenu(store: AppStore, item: DiskResource) -> some View { + if store.sidebarSelection == .trash { + Button("Восстановить") { + Task { await store.restoreFromTrash(item) } + } + } else { + Button("Открыть") { store.open(item) } + Button("Показать в Finder") { store.revealInFinder(item) } + Button("Создать копию на компьютере") { + Task { await store.downloadToComputer(item) } + } + Divider() + Button("Скопировать ссылку") { + Task { await store.copyPublicLink(item) } + } + Button("Настроить общий доступ") { + store.prepareShare(item) + } + Divider() + Menu("Синхронизация") { + ForEach(SyncMode.allCases) { mode in + Button(mode.title) { + Task { await store.applySyncMode(mode, to: item) } + } + } + } + Divider() + Button("Скопировать «\(item.displayName)»") { + Task { await store.duplicate(item) } + } + Button("Переместить в Корзину") { + Task { await store.moveToTrash(item) } + } + Divider() + Button("Переименовать") { + renameItem = item + renameText = item.displayName + } + } + } + + private func icon(for item: DiskResource) -> String { + if item.isDirectory { return "folder.fill" } + let ext = (item.displayName as NSString).pathExtension.lowercased() + switch ext { + case "png", "jpg", "jpeg", "gif", "heic", "webp": return "photo" + case "mp4", "mov", "m4v": return "film" + case "mp3", "m4a", "wav": return "music.note" + case "pdf": return "doc.richtext" + case "zip", "rar", "7z": return "doc.zipper" + default: return "doc" + } + } + + private func formattedDate(_ raw: String?) -> String { + guard let raw else { return "—" } + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + var date = formatter.date(from: raw) + if date == nil { + formatter.formatOptions = [.withInternetDateTime] + date = formatter.date(from: raw) + } + guard let date else { return raw } + let out = DateFormatter() + out.locale = Locale(identifier: "ru_RU") + out.dateFormat = "dd.MM.yyyy HH:mm" + return out.string(from: date) + } +} + +struct TasksView: View { + @Environment(AppStore.self) private var store + + var body: some View { + if store.syncEngine.tasks.isEmpty { + VStack(spacing: 10) { + Image(systemName: "arrow.triangle.2.circlepath") + .font(.system(size: 36)) + .foregroundStyle(.tertiary) + Text("Нет активных задач") + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + List(store.syncEngine.tasks) { task in + HStack { + Image(systemName: task.kind == .upload ? "arrow.up.circle" : "arrow.down.circle") + .foregroundStyle(task.isError ? Color.red : Color.accentColor) + VStack(alignment: .leading, spacing: 2) { + Text((task.path as NSString).lastPathComponent) + .lineLimit(1) + Text(task.kind.title) + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + if task.isFinished { + Image(systemName: task.isError ? "xmark.circle.fill" : "checkmark.circle.fill") + .foregroundStyle(task.isError ? .red : .green) + } else { + ProgressView() + .controlSize(.small) + } + } + .padding(.vertical, 2) + } + } + } +} diff --git a/Sources/YanDisk/UI/MenuBarView.swift b/Sources/YanDisk/UI/MenuBarView.swift new file mode 100644 index 0000000..e0e9e03 --- /dev/null +++ b/Sources/YanDisk/UI/MenuBarView.swift @@ -0,0 +1,224 @@ +import SwiftUI +import AppKit + +struct MenuBarView: View { + @Environment(AppStore.self) private var store + @Environment(\.openWindow) private var openWindow + @State private var showLogin = false + @AppStorage("Appearance") private var appearance = "system" + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + header + Divider() + statusSection + Divider() + actionsSection + Divider() + folderSection + Divider() + footerSection + } + .frame(width: 320) + .preferredColorScheme(colorScheme) + .onAppear { + store.bootstrap() + if !store.isAuthorized { + showLogin = true + } + } + .onChange(of: store.showLoginSheet) { _, value in + showLogin = value + } + .sheet(isPresented: $showLogin) { + LoginView() + .environment(store) + } + } + + private var colorScheme: ColorScheme? { + switch appearance { + case "light": return .light + case "dark": return .dark + default: return nil + } + } + + private var header: some View { + HStack(spacing: 10) { + Image(systemName: "cloud.fill") + .font(.system(size: 22, weight: .medium)) + .foregroundStyle(Color(red: 0.93, green: 0.0, blue: 0.21)) + VStack(alignment: .leading, spacing: 2) { + Text("YanDisk") + .font(.headline) + Text(store.isAuthorized ? "Подключён" : "Не авторизован") + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + } + .padding(12) + } + + private var statusSection: some View { + VStack(alignment: .leading, spacing: 6) { + HStack { + Circle() + .fill(statusColor) + .frame(width: 8, height: 8) + Text(store.syncEngine.state.label) + .font(.subheadline) + Spacer() + } + if store.diskInfo != nil { + Text("\(store.formattedUsed) из \(store.formattedTotal)") + .font(.caption) + .foregroundStyle(.secondary) + ProgressView(value: store.usedPercent) + .progressViewStyle(.linear) + } + HStack { + Text("Режим:") + Picker("", selection: Binding( + get: { store.syncEngine.policy.globalMode }, + set: { store.syncEngine.setGlobalMode($0) } + )) { + ForEach(SyncMode.allCases) { mode in + Text(mode.title).tag(mode) + } + } + .labelsHidden() + .fixedSize() + } + .font(.caption) + } + .padding(.horizontal, 12) + .padding(.vertical, 10) + } + + private var statusColor: Color { + switch store.syncEngine.state { + case .success: return .green + case .syncing: return .orange + case .offline: return .gray + case .error: return .red + case .idle: return .secondary + } + } + + private var actionsSection: some View { + VStack(spacing: 0) { + MenuBarButton(icon: "folder", title: "Открыть YanDisk") { + NSApp.activate(ignoringOtherApps: true) + NSApp.sendAction(#selector(AppDelegate.showMainWindow), to: nil, from: nil) + } + MenuBarButton(icon: "arrow.triangle.2.circlepath", title: "Синхронизировать сейчас") { + Task { await store.syncEngine.syncNow() } + } + MenuBarButton(icon: "rectangle.and.pencil.and.ellipsis", title: "Загрузить файлы…") { + let panel = NSOpenPanel() + panel.allowsMultipleSelection = true + panel.canChooseDirectories = true + panel.canChooseFiles = true + if panel.runModal() == .OK { + Task { await store.uploadFiles(from: panel.urls) } + } + } + MenuBarButton(icon: "folder.badge.gearshape", title: "Показать папку синхронизации") { + try? FileManager.default.createDirectory( + at: store.syncEngine.syncFolderURL, + withIntermediateDirectories: true + ) + NSWorkspace.shared.open(store.syncEngine.syncFolderURL) + } + MenuBarButton(icon: "gearshape", title: "Настройки") { + NSApp.activate(ignoringOtherApps: true) + NSApp.sendAction(#selector(AppDelegate.showPreferencesWindow(_:)), to: nil, from: nil) + } + } + .padding(.vertical, 4) + } + + private var folderSection: some View { + VStack(alignment: .leading, spacing: 6) { + Text("СИНХРОНИЗАЦИЯ") + .font(.caption2) + .foregroundStyle(.secondary) + .padding(.horizontal, 12) + HStack { + Text("Весь Диск") + .font(.subheadline) + Spacer() + Picker("", selection: Binding( + get: { store.syncEngine.policy.mode(for: "/") }, + set: { newValue in + Task { await store.syncEngine.applyModeChange(newValue, path: "/") } + } + )) { + ForEach(SyncMode.allCases) { m in + Text(m.shortTitle).tag(m) + } + } + .labelsHidden() + .fixedSize() + } + .padding(.horizontal, 12) + Toggle("Включить синхронизацию", isOn: Binding( + get: { store.syncEngine.isSyncEnabled }, + set: { store.syncEngine.setSyncEnabled($0) } + )) + .toggleStyle(.switch) + .controlSize(.small) + .padding(.horizontal, 12) + .padding(.bottom, 6) + } + .padding(.top, 4) + } + + private var footerSection: some View { + HStack { + if store.isAuthorized { + Button("Выйти") { + store.logout() + } + } else { + Button("Добавить аккаунт") { + store.openAuthorization() + showLogin = true + } + } + Spacer() + Button("Закрыть") { + NSApp.terminate(nil) + } + } + .buttonStyle(.plain) + .font(.caption) + .foregroundStyle(.secondary) + .padding(10) + } +} + +struct MenuBarButton: View { + let icon: String + let title: String + let action: () -> Void + + var body: some View { + Button(action: action) { + HStack(spacing: 10) { + Image(systemName: icon) + .frame(width: 16) + .foregroundStyle(.secondary) + Text(title) + .foregroundStyle(.primary) + Spacer() + } + .contentShape(Rectangle()) + .padding(.horizontal, 12) + .padding(.vertical, 6) + } + .buttonStyle(.plain) + } +} diff --git a/Sources/YanDisk/UI/PreferencesView.swift b/Sources/YanDisk/UI/PreferencesView.swift new file mode 100644 index 0000000..15967c2 --- /dev/null +++ b/Sources/YanDisk/UI/PreferencesView.swift @@ -0,0 +1,178 @@ +import SwiftUI +import AppKit +import UniformTypeIdentifiers + +struct PreferencesView: View { + @Environment(AppStore.self) private var store + @AppStorage("Appearance") private var appearance = "system" + + var body: some View { + TabView { + generalTab + .tabItem { Label("Основные", systemImage: "gearshape") } + syncTab + .tabItem { Label("Синхронизация", systemImage: "arrow.triangle.2.circlepath") } + accountTab + .tabItem { Label("Аккаунт", systemImage: "person.crop.circle") } + aboutTab + .tabItem { Label("О программе", systemImage: "info.circle") } + } + .padding(8) + .preferredColorScheme(colorScheme) + } + + private var colorScheme: ColorScheme? { + switch appearance { + case "light": return .light + case "dark": return .dark + default: return nil + } + } + + private var generalTab: some View { + Form { + Picker("Тема", selection: $appearance) { + Text("Как в системе").tag("system") + Text("Светлая").tag("light") + Text("Тёмная").tag("dark") + } + + Toggle("Включить синхронизацию", isOn: Binding( + get: { store.syncEngine.isSyncEnabled }, + set: { store.syncEngine.setSyncEnabled($0) } + )) + + Picker("Режим по умолчанию", selection: Binding( + get: { store.syncEngine.policy.globalMode }, + set: { store.syncEngine.setGlobalMode($0) } + )) { + ForEach(SyncMode.allCases) { mode in + Text(mode.title).tag(mode) + } + } + + LabeledContent("Папка синхронизации") { + HStack { + Text(store.syncEngine.syncFolderURL.path) + .lineLimit(1) + .truncationMode(.middle) + .foregroundStyle(.secondary) + Button("Изменить…") { + let panel = NSOpenPanel() + panel.canChooseFiles = false + panel.canChooseDirectories = true + panel.canCreateDirectories = true + panel.allowsMultipleSelection = false + panel.prompt = "Выбрать" + if panel.runModal() == .OK, let url = panel.url { + store.syncEngine.setSyncFolder(url) + } + } + } + } + + Button("Показать в Finder") { + NSWorkspace.shared.open(store.syncEngine.syncFolderURL) + } + } + .formStyle(.grouped) + } + + private var syncTab: some View { + Form { + Section("Режимы синхронизации") { + ForEach(SyncMode.allCases) { mode in + VStack(alignment: .leading, spacing: 4) { + Text(mode.title).font(.headline) + Text(modeHelp(mode)) + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(.vertical, 2) + } + } + + Section("Переопределения по папкам") { + if store.syncEngine.policy.pathModes.isEmpty { + Text("Нет переопределений") + .foregroundStyle(.secondary) + } else { + ForEach(store.syncEngine.policy.pathModes.sorted(by: { $0.key < $1.key }), id: \.key) { path, mode in + HStack { + Text(path) + Spacer() + Text(mode.title) + .foregroundStyle(.secondary) + Button { + store.syncEngine.policy.clearMode(for: path) + } label: { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + } + } + } + } + + Button("Синхронизировать сейчас") { + Task { await store.syncEngine.syncNow() } + } + } + .formStyle(.grouped) + } + + private var accountTab: some View { + Form { + if store.isAuthorized { + LabeledContent("Статус") { + Text("Авторизован") + .foregroundStyle(.green) + } + LabeledContent("Client ID") { + Text(OAuthManager.clientId) + .font(.caption) + .foregroundStyle(.secondary) + } + Button("Выйти из аккаунта", role: .destructive) { + store.logout() + } + } else { + Text("Аккаунт не подключён") + Button("Добавить аккаунт") { + store.openAuthorization() + store.showLoginSheet = true + } + .buttonStyle(.borderedProminent) + } + } + .formStyle(.grouped) + } + + private var aboutTab: some View { + Form { + LabeledContent("Приложение") { + Text("YanDisk") + } + LabeledContent("Версия") { + Text("1.0.0") + } + LabeledContent("API") { + Text("REST cloud-api.yandex.net") + .font(.caption) + } + Text("Клиент для macOS на Apple Silicon. Чтение, запись, копирование, перемещение, корзина, синхронизация и общий доступ.") + .font(.caption) + .foregroundStyle(.secondary) + } + .formStyle(.grouped) + } + + private func modeHelp(_ mode: SyncMode) -> String { + switch mode { + case .everywhere: return "Файлы есть и на компьютере, и в облаке. Двусторонняя синхронизация." + case .computerOnly: return "Сохранять на компьютере; новые файлы из облака также скачиваются." + case .cloudOnly: return "Оставить только в облаке: локальная копия удаляется." + } + } +} diff --git a/Sources/YanDisk/UI/ShareSheetView.swift b/Sources/YanDisk/UI/ShareSheetView.swift new file mode 100644 index 0000000..7547459 --- /dev/null +++ b/Sources/YanDisk/UI/ShareSheetView.swift @@ -0,0 +1,86 @@ +import SwiftUI + +struct ShareSheetView: View { + @Environment(AppStore.self) private var store + @Environment(\.dismiss) private var dismiss + + var body: some View { + @Bindable var store = store + VStack(alignment: .leading, spacing: 16) { + HStack { + Image(systemName: "person.2.circle.fill") + .font(.system(size: 28)) + .foregroundStyle(Color(red: 0.93, green: 0.0, blue: 0.21)) + VStack(alignment: .leading) { + Text("Общий доступ") + .font(.title3.bold()) + if let item = store.shareTarget { + Text(item.displayName) + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + Spacer() + } + + Divider() + + Picker("Права", selection: $store.shareSettings.rights) { + ForEach([ShareRight.read, .write, .readOnly, .readWithPassword, .readWithPasswordNoDownload]) { right in + Text(right.title).tag(right) + } + } + + Toggle("Доступен всем, у кого есть ссылка", isOn: $store.shareSettings.everyone) + + SecureField("Пароль (необязательно)", text: $store.shareSettings.password) + + Picker("Срок действия", selection: Binding( + get: { store.shareSettings.availableUntilSeconds ?? 0 }, + set: { store.shareSettings.availableUntilSeconds = $0 == 0 ? nil : $0 } + )) { + Text("Без ограничений").tag(0) + Text("1 час").tag(3600) + Text("1 день").tag(86400) + Text("7 дней").tag(604800) + Text("30 дней").tag(2_592_000) + } + + if let url = store.shareURL, !url.isEmpty { + HStack { + TextField("Ссылка", text: .constant(url)) + .textFieldStyle(.roundedBorder) + .disabled(true) + Button("Копировать") { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(url, forType: .string) + } + } + } + + HStack { + if let item = store.shareTarget, item.publicUrl != nil { + Button("Закрыть доступ", role: .destructive) { + Task { + await store.unpublishShare(item) + dismiss() + } + } + } + Spacer() + Button("Отмена") { dismiss() } + .keyboardShortcut(.cancelAction) + Button("Поделиться") { + Task { + await store.publishShare() + } + } + .keyboardShortcut(.defaultAction) + .buttonStyle(.borderedProminent) + .tint(Color(red: 0.93, green: 0.0, blue: 0.21)) + } + } + .padding(20) + .frame(width: 460) + } +} diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..2b3dbac --- /dev/null +++ b/build.sh @@ -0,0 +1,43 @@ +#!/bin/bash +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")" && pwd)" +cd "$ROOT" + +APP_NAME="YanDisk" +BUILD_DIR="$ROOT/.build" + +# SDK из CommandLineTools может не совпадать с тулчейном — берём SDK из Xcode. +export SDKROOT="$(xcrun --sdk macosx --show-sdk-path 2>/dev/null || echo "${SDKROOT:-}")" +APP_DIR="$BUILD_DIR/${APP_NAME}.app" +CONTENTS="$APP_DIR/Contents" +MACOS_DIR="$CONTENTS/MacOS" +RESOURCES_DIR="$CONTENTS/Resources" + +echo "==> swift build" +swift build -c release + +BIN="$(swift build -c release --show-bin-path)/YanDisk" +echo "==> binary: $BIN" + +echo "==> assembling .app" +rm -rf "$APP_DIR" +mkdir -p "$MACOS_DIR" "$RESOURCES_DIR" +cp "$BIN" "$MACOS_DIR/YanDisk" +cp "$ROOT/Resources/Info.plist" "$CONTENTS/Info.plist" + +# Icon: classic .icns (Finder/Dock) + asset catalog (light/dark) +if [[ -f "$ROOT/Resources/AppIcon.icns" ]]; then + cp "$ROOT/Resources/AppIcon.icns" "$RESOURCES_DIR/AppIcon.icns" +fi +if [[ -f "$ROOT/Resources/AppIconDark.icns" ]]; then + cp "$ROOT/Resources/AppIconDark.icns" "$RESOURCES_DIR/AppIconDark.icns" +fi +if [[ -d "$ROOT/Resources/Assets.xcassets" ]]; then + cp -R "$ROOT/Resources/Assets.xcassets" "$RESOURCES_DIR/Assets.xcassets" +fi + +codesign --force --deep --sign - "$APP_DIR" + +echo "==> done: $APP_DIR" +echo "Run: open \"$APP_DIR\"" diff --git a/icons/Icon-iOS-ClearDark-1024x1024@1x.png b/icons/Icon-iOS-ClearDark-1024x1024@1x.png new file mode 100644 index 0000000..5d04383 Binary files /dev/null and b/icons/Icon-iOS-ClearDark-1024x1024@1x.png differ diff --git a/icons/Icon-iOS-ClearLight-1024x1024@1x.png b/icons/Icon-iOS-ClearLight-1024x1024@1x.png new file mode 100644 index 0000000..8e02eb5 Binary files /dev/null and b/icons/Icon-iOS-ClearLight-1024x1024@1x.png differ diff --git a/icons/Icon-iOS-Dark-1024x1024@1x.png b/icons/Icon-iOS-Dark-1024x1024@1x.png new file mode 100644 index 0000000..26b6c7e Binary files /dev/null and b/icons/Icon-iOS-Dark-1024x1024@1x.png differ diff --git a/icons/Icon-iOS-Default-1024x1024@1x.png b/icons/Icon-iOS-Default-1024x1024@1x.png new file mode 100644 index 0000000..ce2fdd7 Binary files /dev/null and b/icons/Icon-iOS-Default-1024x1024@1x.png differ diff --git a/icons/Icon-iOS-TintedDark-1024x1024@1x.png b/icons/Icon-iOS-TintedDark-1024x1024@1x.png new file mode 100644 index 0000000..c1b15ea Binary files /dev/null and b/icons/Icon-iOS-TintedDark-1024x1024@1x.png differ diff --git a/icons/Icon-iOS-TintedLight-1024x1024@1x.png b/icons/Icon-iOS-TintedLight-1024x1024@1x.png new file mode 100644 index 0000000..3d081a9 Binary files /dev/null and b/icons/Icon-iOS-TintedLight-1024x1024@1x.png differ diff --git a/scripts/generate_icons.sh b/scripts/generate_icons.sh new file mode 100755 index 0000000..fe0d2b8 --- /dev/null +++ b/scripts/generate_icons.sh @@ -0,0 +1,67 @@ +#!/bin/bash +# Генерация macOS AppIcon.icns + Assets.xcassets (light/dark) из icons/*.png +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +"$MIMO_PYTHON" - </dev/null + +echo "2) OAuth parse" +swift -e ' +import Foundation +func parse(_ input: String) -> String? { + let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { return nil } + if trimmed.contains("access_token=") { + let parts = trimmed.replacingOccurrences(of: "#", with: "&").replacingOccurrences(of: "?", with: "&").split(separator: "&") + for part in parts { + let kv = part.split(separator: "=", maxSplits: 1) + if kv.count == 2, kv[0] == "access_token" { return String(kv[1]) } + } + } + if trimmed.range(of: "^[A-Za-z0-9_\\-]{16,}$", options: .regularExpression) != nil { return trimmed } + return nil +} +assert(parse("0c4181a7c2cf4521964a72ff57a34a07") == "0c4181a7c2cf4521964a72ff57a34a07") +assert(parse("https://oauth.yandex.ru/verification_code#access_token=abc123&token_type=bearer") == "abc123") +print("ok") +' + +echo "3) API path encoding" +swift -e ' +import Foundation +func diskPath(_ path: String) -> String { + if path.hasPrefix("disk:") || path.hasPrefix("trash:") { return path } + let normalized = path.hasPrefix("/") ? path : "/" + path + return "disk:\(normalized)" +} +assert(diskPath("/Фото") == "disk:/Фото") +assert(diskPath("Фото") == "disk:/Фото") +assert(diskPath("trash:/x") == "trash:/x") +print("ok") +' + +echo "4) app bundle" +APP="$ROOT/.build/YandexDisk.app" +test -x "$APP/Contents/MacOS/YandexDiskClient" +plutil -lint "$APP/Contents/Info.plist" +codesign -dv "$APP" 2>&1 | head -5 +echo "ALL SMOKE TESTS PASSED" diff --git a/ПРАВКИ.md b/ПРАВКИ.md new file mode 100644 index 0000000..b24e382 --- /dev/null +++ b/ПРАВКИ.md @@ -0,0 +1,113 @@ +# YanDisk — анализ кода и план правок + +Дата: 15.09.2026. Анализ всех исходников `Sources/YanDisk/` (~3 500 строк Swift). + +## Реализовано в этой итерации + +| № | Правка | Где | +|---|--------|-----| +| 1 | Иконка зависит от темы системы: тёмный `AppIconDark.icns` + переключение Dock-иконки в рантайме по `effectiveAppearance` (KVO-наблюдатель) | `YanDiskApp.swift`, `build.sh`, `scripts/generate_icons.sh` | +| 2 | Вкладка «Скриншоты» скрыта из сайдбара (`SidebarItem.displayCases`); отдельно обсудим реализацию | `AppStore.swift`, `MainSplitView.swift` | +| 3 | Вкладка «Файлы» → «Доступные мне» (файлы, к которым дали доступ другие пользователи). Пока заглушка: публичный REST API Яндекса не отдаёт список «мне поделились» — нужен отдельный план (см. ниже) | `AppStore.swift`, `MainSplitView.swift` | +| 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. Вкладка «Приглашения» через веб-интерфейс. +Решение о выборе — отдельно. + +--- + +## Найденные неоптимальные решения и план правок + +### A. Синхронизация (SyncEngine) — приоритет 1 + +1. **`uploadLocalChanges` — N+1 запросов.** Для каждого локального файла делается отдельный `GET /resources` (`existsRemotely`). При 100 файлах = 100 запросов за цикл синхронизации. + → Правка: сравнивать с уже полученным списком `listDirectory` (он уже есть в `syncDirectory`), а не спрашивать API по каждому файлу. + +2. **Полное перестроение дерева каждые 25 секунд.** Таймер дергает `syncNow()`, который рекурсивно обходит всё облако (до глубины 6) независимо от изменений. + → Правка: долгий poll через `GET /disk/` + `revision`/ETag; синк дерева только при изменении revision. Интервал с backoff (25 c → 60 c при отсутствии активности). + +3. **Нет chunked-загрузки и прогресса.** `uploadFile` грузит файл целиком одним PUT; `progress` в `downloadFile` объявлен, но нигде не используется; `TransferTask.progress` всегда 0 до завершения. + → Правка: резюмабельные загрузки через Range, прогресс через `Progress`/`session.download` delegate; большие файлы (>1 ГБ) — через `upload/create` + PUT по частям. + +4. **`finishLastTask` помечает `tasks[0]` — гонка.** При параллельных операциях помечается не та задача; задачи upload/download из разных потоков перемешиваются. + → Правка: `appendTask` возвращает `UUID`, операция закрывает задачу по своему id. + +5. **Сравнение файлов только по размеру** (`localSize != remoteSize, localSize < remoteSize`) — переименование контента без смены размера не ловится; md5 не используется, хотя приходит в API. + → Правка: сравнение по `md5` для файлов < N МБ, по размеру+mtime для остальных. + +6. **Удаления из облака не отражаются локально** (кроме cloudOnly-ветки): если файл исчез с Диска, локальная копия остаётся. И наоборот: `computerOnly` в `mode(for:)` не блокирует скачивание в ветке `case .computerOnly, .everywhere: break`. + → Правка: явная семантика режимов: `cloudOnly` = удалить локальное, `everywhere` = полное зеркало (в т.ч. удаления), `computerOnly` = скачать и не следить за удалением в облаке. + +7. **`depth < 6` молча обрывает** глубокие деревья. + → Правка: убрать ограничение, ограничивать только количеством API-запросов в минуту. + +### B. Клиент API (DiskAPIClient) — приоритет 2 + +8. **`waitOperation`: фиксированный sleep 800 мс до 600 с.** Нет экспоненциального backoff и отмены. + → Правка: backoff 0.5→8 с; прокидывать `Task.isCancelled`. + +9. **`filesList(limit:)` сканирует только корень** (`listDirectory("/")`), т.е. «все файлы» на самом деле только корневые — функция фактически сломана. + → Правка: рекурсивный обход либо убрать вызов до реальной необходимости. + +10. **`logout()` не отзывает токен.** Токен остаётся живым в аккаунте Яндекса. + → Правка: `POST /revoke_token` (или подсказка пользователю). + +11. **Дублирование кода copy/move** — идентичные блоки построения query + 202-обработки. + → Правка: общий приватный `transfer(path:from:to:overwrite:)`. + +12. **`makeRequest` не URL-кодирует path** внутри query-значений корректно для спецсимволов имён? — `URLQueryItem` кодирует значения, но пути с `+`/`%` стоит прогнать через `addingPercentEncoding` и проверить тестами на именах с кавычками/эмодзи. + +### C. UI / состояние — приоритет 3 + +13. **`formattedDate` создаёт 2 новых форматтера на каждую строку таблицы.** При 500 элементах — 1000 аллокаций на рендер. + → Правка: статические кэшированные `ISO8601DateFormatter`/`DateFormatter` (static let, одинаковый locale). + +14. **`ByteFormatter.string` создаёт новый `ByteCountFormatter` на каждый вызов.** + → Правка: static let. + +15. **Триплет `colorScheme`-вычислений скопирован 3 раза** (YanDiskApp, RootView, MenuBarView) + `appearance`-AppStorage в 4 местах. + → Правка: единый `AppearanceManager` (@Observable) или расширение `EnvironmentValues`. + +16. **Брендовый цвет `Color(red: 0.93, green: 0.0, blue: 0.21)` захардкожен 4 раза.** + → Правка: `extension Color { static let yandexRed }` или Asset ColorSet (он же решает и иконку внутри UI). + +17. **`selection: Set`** в Table по path — при обновлении списка выделение молча теряется/остаётся на удалённых путях. + → Правка: чистить selection после `loadDirectory`. + +18. **Дублирование UI-состояния логина:** `showLoginSheet` в store + `showLogin` @State в MenuBarView синхронизируются onChange — два источника правды. + → Правка: один источник (store), MenuBarView читает напрямую. + +19. **`statusMessage` никогда не сбрасывается** — плашка висит вечно. + → Правка: авто-скрытие через Task.sleep(3 c) + анимация. + +20. **`NsLog`-дебаг останется в релизе** (`YD didFinishLaunching` и др.). + → Правка: `os.Logger` с категориями, убрать из release. + +21. **Вкладки «Избранное» и «Доступные мне» — заглушки.** Избранное сейчас просто показывает корень «/» — дубликат первой вкладки. + → Правка: «Избранное» = локальный список избранных путей (UserDefaults, звездочка в контекстном меню), «Доступные мне» — см. блок выше. + +22. **`NSOpenPanel.runModal()` блокирует main thread** прямо из тапа тулбара. + → Правка: `beginSheetModal(for: window)` или `begin { }`. + +23. **`store.bootstrap()` вызывается и из `RootView.task`, и из `MainSplitView.onAppear`, и из `MenuBarView.onAppear`** — спасает только `didBootstrap`-флаг. + → Правка: единая точка инициализации. + +### D. Инфраструктура — приоритет 3 + +24. **Нет CI.** → GitHub Actions-подобный workflow в Gitea (swift build + smoke_test.sh). +25. **`.build/` попадёт в репозиторий**, если не исключить. → `.gitignore`: `.build/`, `.DS_Store`, `*.dSYM`. +26. **Client ID захардкожен** в `DiskAPIClient.swift`. Для опенсорса — вынести в конфиг/plist. +27. **`smoke_test.sh`** — проверить, что он реально гоняется после каждого билда; включить в CI. + +## Порядок работ (предложение) + +1. B8, A1, A4, A5 (корректность синка) +2. A2 (экономия API), A6 (семантика режимов) +3. C13, C14, C15, C16 (перф/чистка — дешёвые) +4. C21 «Избранное» локально, C19, C22 +5. «Доступные мне» — после выбора подхода +6. D24–D27