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: полный аудит и план доработок
This commit is contained in:
2026-09-15 21:16:22 +03:00
commit 9098340f2a
50 changed files with 3100 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
.build/
.DS_Store
*.dSYM
+13
View File
@@ -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"
)
]
)
+67
View File
@@ -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`
Binary file not shown.
Binary file not shown.
@@ -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
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 255 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 257 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

+1
View File
@@ -0,0 +1 @@
{"info":{"author":"xcode","version":1}}
+34
View File
@@ -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>
+173
View File
@@ -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
}
}
}
+476
View File
@@ -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)
}
}
+364
View File
@@ -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 }
}
}
+43
View File
@@ -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)
}
}
+190
View File
@@ -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
}
}
}
+62
View File
@@ -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
}
}
+294
View File
@@ -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()
}
}
+59
View File
@@ -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)
}
}
+369
View File
@@ -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)
}
}
}
}
+224
View File
@@ -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)
}
}
+178
View File
@@ -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 "Оставить только в облаке: локальная копия удаляется."
}
}
}
+86
View File
@@ -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)
}
}
Executable
+43
View File
@@ -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\""
Binary file not shown.

After

Width:  |  Height:  |  Size: 456 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 444 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 915 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 444 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 458 KiB

+67
View File
@@ -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
Executable
+53
View File
@@ -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"
+113
View File
@@ -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