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: полный аудит и план доработок
@@ -0,0 +1,3 @@
|
||||
.build/
|
||||
.DS_Store
|
||||
*.dSYM
|
||||
@@ -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"
|
||||
)
|
||||
]
|
||||
)
|
||||
@@ -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`
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 9.4 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 75 KiB |
|
After Width: | Height: | Size: 77 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 75 KiB |
|
After Width: | Height: | Size: 255 KiB |
|
After Width: | Height: | Size: 257 KiB |
|
After Width: | Height: | Size: 77 KiB |
@@ -0,0 +1 @@
|
||||
{"info":{"author":"xcode","version":1}}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>ru</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>YanDisk</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>local.yandisk.app</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>YanDisk</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>YanDisk</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>14.0</string>
|
||||
<key>NSPrincipalClass</key>
|
||||
<string>NSApplication</string>
|
||||
<key>NSHumanReadableCopyright</key>
|
||||
<string>YanDisk — client for Yandex.Disk REST API</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>AppIcon</string>
|
||||
<key>CFBundleIconName</key>
|
||||
<string>AppIcon</string>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<String>()
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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<T: Decodable>(_ 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 }
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 "Оставить только в облаке: локальная копия удаляется."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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\""
|
||||
|
After Width: | Height: | Size: 456 KiB |
|
After Width: | Height: | Size: 444 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 915 KiB |
|
After Width: | Height: | Size: 444 KiB |
|
After Width: | Height: | Size: 458 KiB |
@@ -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" - <<PY
|
||||
from PIL import Image
|
||||
from pathlib import Path
|
||||
import subprocess, shutil, json
|
||||
|
||||
root = Path("$ROOT")
|
||||
icons = root / "icons"
|
||||
res = root / "Resources"
|
||||
res.mkdir(exist_ok=True)
|
||||
|
||||
light_src = Image.open(icons / "Icon-iOS-Default-1024x1024@1x.png").convert("RGBA")
|
||||
dark_src = Image.open(icons / "Icon-iOS-Dark-1024x1024@1x.png").convert("RGBA")
|
||||
sizes = [16, 32, 128, 256, 512]
|
||||
|
||||
def write_iconset(src, out_dir: Path):
|
||||
if out_dir.exists():
|
||||
shutil.rmtree(out_dir)
|
||||
out_dir.mkdir(parents=True)
|
||||
for s in sizes:
|
||||
for scale in (1, 2):
|
||||
px = s * scale
|
||||
img = src.resize((px, px), Image.Resampling.LANCZOS)
|
||||
name = f"icon_{s}x{s}{'@2x' if scale==2 else ''}.png"
|
||||
img.save(out_dir / name)
|
||||
|
||||
light_iconset = res / "AppIcon.iconset"
|
||||
write_iconset(light_src, light_iconset)
|
||||
icns = res / "AppIcon.icns"
|
||||
subprocess.check_call(["iconutil", "-c", "icns", str(light_iconset), "-o", str(icns)])
|
||||
shutil.rmtree(light_iconset)
|
||||
|
||||
dark_iconset = res / "AppIconDark.iconset"
|
||||
write_iconset(dark_src, dark_iconset)
|
||||
dark_icns = res / "AppIconDark.icns"
|
||||
subprocess.check_call(["iconutil", "-c", "icns", str(dark_iconset), "-o", str(dark_icns)])
|
||||
shutil.rmtree(dark_iconset)
|
||||
|
||||
catalog = res / "Assets.xcassets"
|
||||
appicon = catalog / "AppIcon.appiconset"
|
||||
if catalog.exists():
|
||||
shutil.rmtree(catalog)
|
||||
appicon.mkdir(parents=True)
|
||||
(catalog / "Contents.json").write_text('{"info":{"author":"xcode","version":1}}\\n')
|
||||
|
||||
entries = []
|
||||
for s in sizes:
|
||||
for scale in (1, 2):
|
||||
px = s * scale
|
||||
fname = f"icon_{s}x{s}{'@2x' if scale==2 else ''}.png"
|
||||
light_src.resize((px, px), Image.Resampling.LANCZOS).save(appicon / fname)
|
||||
entries.append({"filename": fname, "idiom": "mac", "scale": f"{scale}x", "size": f"{s}x{s}"})
|
||||
fname_d = f"icon_{s}x{s}{'@2x' if scale==2 else ''}_dark.png"
|
||||
dark_src.resize((px, px), Image.Resampling.LANCZOS).save(appicon / fname_d)
|
||||
entries.append({
|
||||
"filename": fname_d, "idiom": "mac", "scale": f"{scale}x", "size": f"{s}x{s}",
|
||||
"appearances": [{"appearance": "luminosity", "value": "dark"}],
|
||||
})
|
||||
|
||||
(appicon / "Contents.json").write_text(json.dumps({
|
||||
"images": entries, "info": {"author": "xcode", "version": 1}
|
||||
}, indent=2) + "\\n")
|
||||
print("icons generated:", icns, dark_icns, "catalog entries", len(entries))
|
||||
PY
|
||||
@@ -0,0 +1,53 @@
|
||||
#!/bin/bash
|
||||
# Smoke-тесты логики без запуска GUI
|
||||
set -euo pipefail
|
||||
ROOT="$(cd "$(dirname "$0")" && pwd)"
|
||||
cd "$ROOT"
|
||||
|
||||
# SDK из CommandLineTools может не совпадать с тулчейном — берём SDK из Xcode.
|
||||
export SDKROOT="$(xcrun --sdk macosx --show-sdk-path 2>/dev/null || echo "${SDKROOT:-}")"
|
||||
|
||||
echo "1) build"
|
||||
swift build -c release >/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"
|
||||
@@ -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<String>`** в 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
|
||||