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:
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user