Files
cairnquire/apps/macos-sync/Sources/CairnquireSync/UI/MenuBarController.swift
2026-06-01 10:53:28 -04:00

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
}
}
}