320 lines
10 KiB
Swift
320 lines
10 KiB
Swift
import SwiftUI
|
|
import Combine
|
|
|
|
public struct ClipboardNotice: Identifiable, Equatable {
|
|
public let id = UUID()
|
|
public let filename: String
|
|
}
|
|
|
|
@MainActor
|
|
public class MenuBarController: NSObject, ObservableObject, NSWindowDelegate {
|
|
@Published public var config: SyncConfig
|
|
@Published public var isSyncing = false
|
|
@Published public var lastSyncTime: Date?
|
|
@Published public var syncStatus: String = "Ready"
|
|
@Published public var pendingChanges = 0
|
|
@Published public var uploadProgress: Double? = nil
|
|
@Published public private(set) var recentUploads: [AttachmentRecord] = []
|
|
@Published public private(set) var isLoadingRecentUploads = false
|
|
@Published public private(set) var recentUploadsError: String?
|
|
@Published public private(set) var clipboardNotice: ClipboardNotice?
|
|
|
|
public var cancellables = Set<AnyCancellable>()
|
|
|
|
private let configStore = ConfigStore.shared
|
|
private var apiClient: APIClient
|
|
private var syncEngine: SyncEngine?
|
|
private var uploadManager: UploadManager
|
|
private var folderWatcher: FolderWatcher?
|
|
private var syncTimer: Timer?
|
|
|
|
private var settingsWindow: NSWindow?
|
|
private var uploadsWindow: NSWindow?
|
|
private var dropZoneWindow: NSWindow?
|
|
|
|
public override init() {
|
|
let config = ConfigStore.shared.load()
|
|
self.config = config
|
|
self.apiClient = APIClient(baseURL: config.serverBaseURL ?? URL(string: "http://localhost:8080")!, apiToken: config.apiToken)
|
|
self.uploadManager = UploadManager(client: apiClient, config: config)
|
|
self.syncEngine = SyncEngine(client: apiClient, config: config)
|
|
super.init()
|
|
|
|
Task {
|
|
await setupSync()
|
|
}
|
|
Task {
|
|
await refreshRecentUploads()
|
|
}
|
|
}
|
|
|
|
public func updateConfig(_ newConfig: SyncConfig) {
|
|
config = newConfig
|
|
|
|
Task {
|
|
await configStore.save(newConfig)
|
|
|
|
if let url = newConfig.serverBaseURL {
|
|
await apiClient.setAPIToken(newConfig.apiToken)
|
|
apiClient = APIClient(baseURL: url, apiToken: newConfig.apiToken)
|
|
uploadManager = UploadManager(client: apiClient, config: newConfig)
|
|
syncEngine = SyncEngine(client: apiClient, config: newConfig)
|
|
await refreshRecentUploads()
|
|
await setupSync()
|
|
}
|
|
}
|
|
}
|
|
|
|
private func setupSync() async {
|
|
// Stop existing watcher
|
|
folderWatcher?.stop()
|
|
syncTimer?.invalidate()
|
|
|
|
guard config.autoSync, let syncFolder = config.syncFolder else {
|
|
syncStatus = "Auto-sync disabled"
|
|
return
|
|
}
|
|
|
|
// Setup folder watcher
|
|
let watcher = FolderWatcher(folderPath: syncFolder)
|
|
watcher.delegate = self
|
|
watcher.start()
|
|
self.folderWatcher = watcher
|
|
|
|
// Setup periodic sync
|
|
syncTimer = Timer.scheduledTimer(withTimeInterval: TimeInterval(config.syncInterval), repeats: true) { [weak self] _ in
|
|
Task { [weak self] in
|
|
await self?.performSync()
|
|
}
|
|
}
|
|
|
|
// Initial sync
|
|
await performSync()
|
|
}
|
|
|
|
public func performSync() async {
|
|
guard !isSyncing else { return }
|
|
guard config.syncFolder != nil else {
|
|
syncStatus = "No sync folder configured"
|
|
return
|
|
}
|
|
|
|
isSyncing = true
|
|
syncStatus = "Syncing..."
|
|
|
|
do {
|
|
let engine = syncEngine ?? SyncEngine(client: apiClient, config: config)
|
|
syncEngine = engine
|
|
try await engine.performSync()
|
|
|
|
lastSyncTime = Date()
|
|
syncStatus = "Synced"
|
|
} catch {
|
|
syncStatus = "Sync failed: \(error.localizedDescription)"
|
|
}
|
|
|
|
isSyncing = false
|
|
}
|
|
|
|
// MARK: - Window Management
|
|
|
|
public func showSettings() {
|
|
if let window = settingsWindow {
|
|
present(window)
|
|
return
|
|
}
|
|
|
|
let window = NSWindow(
|
|
contentRect: NSRect(x: 0, y: 0, width: 520, height: 640),
|
|
styleMask: [.titled, .closable],
|
|
backing: .buffered,
|
|
defer: false
|
|
)
|
|
window.title = "Cairnquire Settings"
|
|
window.titlebarAppearsTransparent = true
|
|
window.isReleasedWhenClosed = false
|
|
window.delegate = self
|
|
window.contentView = NSHostingView(rootView: SettingsView(config: config, onSave: { [weak self, weak window] newConfig in
|
|
self?.updateConfig(newConfig)
|
|
window?.close()
|
|
}))
|
|
window.center()
|
|
|
|
settingsWindow = window
|
|
present(window)
|
|
}
|
|
|
|
public func showUploads() {
|
|
if let window = uploadsWindow {
|
|
present(window)
|
|
return
|
|
}
|
|
|
|
let window = NSWindow(
|
|
contentRect: NSRect(x: 0, y: 0, width: 400, height: 500),
|
|
styleMask: [.titled, .closable, .miniaturizable, .resizable],
|
|
backing: .buffered,
|
|
defer: false
|
|
)
|
|
window.title = "Uploaded Files"
|
|
window.isReleasedWhenClosed = false
|
|
window.delegate = self
|
|
window.contentView = NSHostingView(rootView: UploadsListView(client: apiClient))
|
|
window.center()
|
|
|
|
uploadsWindow = window
|
|
present(window)
|
|
}
|
|
|
|
public func showDropZone() {
|
|
if let window = dropZoneWindow {
|
|
present(window)
|
|
return
|
|
}
|
|
|
|
let window = NSWindow(
|
|
contentRect: NSRect(x: 0, y: 0, width: 300, height: 250),
|
|
styleMask: [.titled, .closable],
|
|
backing: .buffered,
|
|
defer: false
|
|
)
|
|
window.title = "Upload File"
|
|
window.isReleasedWhenClosed = false
|
|
window.delegate = self
|
|
window.contentView = NSHostingView(rootView: DropZoneView(uploadManager: uploadManager))
|
|
window.center()
|
|
|
|
dropZoneWindow = window
|
|
present(window)
|
|
}
|
|
|
|
public func windowWillClose(_ notification: Notification) {
|
|
guard let window = notification.object as? NSWindow else { return }
|
|
|
|
if window === settingsWindow {
|
|
settingsWindow = nil
|
|
} else if window === uploadsWindow {
|
|
uploadsWindow = nil
|
|
} else if window === dropZoneWindow {
|
|
dropZoneWindow = nil
|
|
}
|
|
}
|
|
|
|
private func present(_ window: NSWindow) {
|
|
NSApp.activate(ignoringOtherApps: true)
|
|
window.makeKeyAndOrderFront(nil)
|
|
}
|
|
|
|
public func quickUpload() {
|
|
let panel = NSOpenPanel()
|
|
panel.canChooseFiles = true
|
|
panel.canChooseDirectories = false
|
|
panel.allowsMultipleSelection = true
|
|
|
|
guard panel.runModal() == .OK else { return }
|
|
|
|
Task {
|
|
await uploadURLList(panel.urls)
|
|
}
|
|
}
|
|
|
|
public func uploadFiles(_ urls: [URL]) {
|
|
Task {
|
|
await uploadURLList(urls)
|
|
}
|
|
}
|
|
|
|
private func uploadURLList(_ urls: [URL]) async {
|
|
guard !urls.isEmpty else { return }
|
|
|
|
let total = Double(urls.count)
|
|
for (index, url) in urls.enumerated() {
|
|
let baseProgress = Double(index) / total
|
|
let fileWeight = 1 / total
|
|
uploadProgress = baseProgress
|
|
syncStatus = "Uploading \(url.lastPathComponent)"
|
|
|
|
let onProgress: UploadProgressHandler = { [weak self] fileProgress in
|
|
let combinedProgress = baseProgress + min(max(fileProgress, 0), 1) * fileWeight
|
|
Task { @MainActor in
|
|
self?.uploadProgress = combinedProgress
|
|
}
|
|
}
|
|
|
|
do {
|
|
let result = try await uploadFileResolvingDuplicates(url, onProgress: onProgress)
|
|
uploadProgress = Double(index + 1) / total
|
|
syncStatus = "Uploaded \(result.filename)"
|
|
clipboardNotice = ClipboardNotice(filename: result.filename)
|
|
await refreshRecentUploads()
|
|
} catch {
|
|
syncStatus = "Upload failed: \(error.localizedDescription)"
|
|
}
|
|
}
|
|
uploadProgress = nil
|
|
}
|
|
|
|
private func uploadFileResolvingDuplicates(
|
|
_ url: URL,
|
|
onProgress: UploadProgressHandler?
|
|
) async throws -> UploadResult {
|
|
var duplicateAction: UploadDuplicateAction?
|
|
|
|
while true {
|
|
do {
|
|
return try await uploadManager.uploadFile(
|
|
url,
|
|
duplicateAction: duplicateAction,
|
|
onProgress: onProgress
|
|
)
|
|
} catch let error as APIError where error.isUploadConflict {
|
|
guard let action = promptForUploadDuplicate(error, filename: url.lastPathComponent) else {
|
|
throw UploadCancelledError()
|
|
}
|
|
duplicateAction = action
|
|
}
|
|
}
|
|
}
|
|
|
|
public func refreshRecentUploads() async {
|
|
guard !isLoadingRecentUploads else { return }
|
|
|
|
isLoadingRecentUploads = true
|
|
defer { isLoadingRecentUploads = false }
|
|
|
|
do {
|
|
recentUploads = Array(try await apiClient.listAttachments().prefix(3))
|
|
recentUploadsError = nil
|
|
} catch {
|
|
recentUploadsError = error.localizedDescription
|
|
}
|
|
}
|
|
|
|
public func createNoteFromClipboard() {
|
|
guard let text = NSPasteboard.general.string(forType: .string) else {
|
|
syncStatus = "Clipboard is empty"
|
|
return
|
|
}
|
|
|
|
Task {
|
|
do {
|
|
let result = try await uploadManager.createNote(from: text)
|
|
syncStatus = "Created note: \(result.title)"
|
|
} catch {
|
|
syncStatus = "Failed to create note: \(error.localizedDescription)"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
extension MenuBarController: FolderWatcherDelegate {
|
|
public nonisolated func folderWatcher(_ watcher: FolderWatcher, didDetectChanges paths: [String]) {
|
|
Task { @MainActor in
|
|
pendingChanges += paths.count
|
|
syncStatus = "\(pendingChanges) pending changes"
|
|
await performSync()
|
|
pendingChanges = 0
|
|
}
|
|
}
|
|
}
|