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() 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 } } } private func uploadDataResolvingDuplicates(_ data: Data, filename: String) async throws -> UploadResult { var duplicateAction: UploadDuplicateAction? while true { do { return try await uploadManager.uploadData( data, filename: filename, duplicateAction: duplicateAction ) } catch let error as APIError where error.isUploadConflict { guard let action = promptForUploadDuplicate(error, filename: filename) 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 createNote() { switch config.effectiveNewNoteAction { case .clipboard: createNoteFromClipboard() case .editor: createNoteInEditor() } } 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)" } } } private func createNoteInEditor() { let draftURL = FileManager.default.temporaryDirectory .appendingPathComponent("cairnquire-\(UUID().uuidString)") .appendingPathExtension("md") let completionURL = draftURL.appendingPathExtension("done") do { try Data().write(to: draftURL, options: .atomic) try launchTerminalEditor(draftURL: draftURL, completionURL: completionURL) syncStatus = "Waiting for $EDITOR to exit" } catch { try? FileManager.default.removeItem(at: draftURL) syncStatus = "Failed to open $EDITOR: \(error.localizedDescription)" return } Task { [weak self] in await self?.uploadEditorDraft(draftURL: draftURL, completionURL: completionURL) } } private func uploadEditorDraft(draftURL: URL, completionURL: URL) async { defer { try? FileManager.default.removeItem(at: draftURL) try? FileManager.default.removeItem(at: completionURL) } while !FileManager.default.fileExists(atPath: completionURL.path) { try? await Task.sleep(for: .milliseconds(500)) } do { let data = try Data(contentsOf: draftURL) guard !data.isEmpty else { syncStatus = "Editor note was empty; nothing uploaded" return } let filename = Self.editorNoteFilename() let result = try await uploadDataResolvingDuplicates(data, filename: filename) syncStatus = "Uploaded \(result.filename)" clipboardNotice = ClipboardNotice(filename: result.filename) await refreshRecentUploads() } catch { syncStatus = "Failed to upload editor note: \(error.localizedDescription)" } } private func launchTerminalEditor(draftURL: URL, completionURL: URL) throws { let draftPath = Self.shellQuote(draftURL.path) let completionPath = Self.shellQuote(completionURL.path) let shellCommand = "trap '/usr/bin/touch \(completionPath)' EXIT; editor=\"${EDITOR:-${VISUAL:-vi}}\"; eval \"$editor \(draftPath)\"" let script = "tell application \"Terminal\" to do script \"\(Self.appleScriptEscape(shellCommand))\"" let process = Process() let errorPipe = Pipe() process.executableURL = URL(fileURLWithPath: "/usr/bin/osascript") process.arguments = ["-e", "tell application \"Terminal\" to activate", "-e", script] process.standardError = errorPipe try process.run() process.waitUntilExit() guard process.terminationStatus == 0 else { let data = errorPipe.fileHandleForReading.readDataToEndOfFile() let message = String(data: data, encoding: .utf8)? .trimmingCharacters(in: .whitespacesAndNewlines) throw NSError( domain: "CairnquireSync", code: Int(process.terminationStatus), userInfo: [NSLocalizedDescriptionKey: message ?? "Terminal could not be opened"] ) } } private static func shellQuote(_ value: String) -> String { "'" + value.replacingOccurrences(of: "'", with: "'\\''") + "'" } private static func appleScriptEscape(_ value: String) -> String { value .replacingOccurrences(of: "\\", with: "\\\\") .replacingOccurrences(of: "\"", with: "\\\"") } private static func editorNoteFilename() -> String { let formatter = DateFormatter() formatter.locale = Locale(identifier: "en_US_POSIX") formatter.dateFormat = "yyyy-MM-dd HH-mm-ss" return "Note \(formatter.string(from: Date())).md" } } 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 } } }