import SwiftUI import Combine import CairnquireSync @main struct CairnquireSyncApp: App { @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate var body: some Scene { Settings { EmptyView() } } } @MainActor class AppDelegate: NSObject, NSApplicationDelegate { var statusItem: NSStatusItem! var controller: MenuBarController! var popover: NSPopover! var contextMenu: NSMenu! var dropButton: DropStatusButton! private var clipboardHUDPanel: NSPanel? private var clipboardHUDDismissWorkItem: DispatchWorkItem? func applicationDidFinishLaunching(_ notification: Notification) { controller = MenuBarController() setupStatusItem() setupPopover() setupContextMenu() // Observe config changes to update icon controller.$config .sink { [weak self] config in self?.updateIcon(config: config) } .store(in: &controller.cancellables) // Observe upload progress to animate icon controller.$uploadProgress .sink { [weak self] progress in self?.updateIconForUpload(progress: progress) } .store(in: &controller.cancellables) controller.$clipboardNotice .compactMap { $0 } .sink { [weak self] notice in self?.showClipboardHUD(notice) } .store(in: &controller.cancellables) // Hide dock icon NSApp.setActivationPolicy(.accessory) } private func setupStatusItem() { statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength) // Create custom drop-aware button let button = DropStatusButton() button.imagePosition = .imageOnly button.target = self button.action = #selector(statusItemButtonClicked(_:)) button.sendAction(on: [.leftMouseUp, .rightMouseUp]) button.appDelegate = self // Replace the default button with our custom one if let oldButton = statusItem.button { oldButton.superview?.addSubview(button) button.frame = oldButton.frame button.autoresizingMask = [.width, .height] oldButton.isHidden = true } dropButton = button updateIcon(config: controller.config) } func updateIcon(config: SyncConfig) { guard controller.uploadProgress == nil else { return } let iconName = config.iconName let image = NSImage(systemSymbolName: iconName, accessibilityDescription: "Cairnquire") ?? NSImage(systemSymbolName: "tray.circle.fill", accessibilityDescription: "Cairnquire") dropButton.image = image } private func updateIconForUpload(progress: Double?) { if progress != nil { // During upload: show tray.circle (outline) let image = NSImage(systemSymbolName: "tray.circle", accessibilityDescription: "Uploading") dropButton.image = image } else { // Upload complete: restore normal icon updateIcon(config: controller.config) } } func bounceIcon() { guard let button = dropButton else { return } let originalOrigin = button.frame.origin NSAnimationContext.runAnimationGroup { context in context.duration = 0.12 context.timingFunction = CAMediaTimingFunction(name: .easeOut) button.animator().setFrameOrigin(NSPoint( x: originalOrigin.x, y: originalOrigin.y - 6 )) } completionHandler: { NSAnimationContext.runAnimationGroup { context in context.duration = 0.15 context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) button.animator().setFrameOrigin(originalOrigin) } } } private func showClipboardHUD(_ notice: ClipboardNotice) { clipboardHUDDismissWorkItem?.cancel() let panel = clipboardHUDPanel ?? makeClipboardHUDPanel() panel.contentView = NSHostingView(rootView: ClipboardHUDView(filename: notice.filename)) positionClipboardHUD(panel) panel.alphaValue = 0 panel.orderFrontRegardless() NSAnimationContext.runAnimationGroup { context in context.duration = 0.15 panel.animator().alphaValue = 1 } let dismissWorkItem = DispatchWorkItem { [weak self, weak panel] in guard let self, let panel else { return } NSAnimationContext.runAnimationGroup { context in context.duration = 0.2 panel.animator().alphaValue = 0 } completionHandler: { panel.orderOut(nil) } self.clipboardHUDDismissWorkItem = nil } clipboardHUDDismissWorkItem = dismissWorkItem DispatchQueue.main.asyncAfter(deadline: .now() + 2, execute: dismissWorkItem) } private func makeClipboardHUDPanel() -> NSPanel { let panel = NSPanel( contentRect: NSRect(x: 0, y: 0, width: 280, height: 72), styleMask: [.borderless, .nonactivatingPanel], backing: .buffered, defer: false ) panel.level = .floating panel.isOpaque = false panel.backgroundColor = .clear panel.hasShadow = true panel.hidesOnDeactivate = false panel.collectionBehavior = [.canJoinAllSpaces, .transient, .ignoresCycle] clipboardHUDPanel = panel return panel } private func positionClipboardHUD(_ panel: NSPanel) { guard let buttonWindow = dropButton.window else { return } let buttonFrame = buttonWindow.convertToScreen(dropButton.convert(dropButton.bounds, to: nil)) let panelSize = panel.frame.size let visibleFrame = buttonWindow.screen?.visibleFrame ?? NSScreen.main?.visibleFrame ?? .zero let centeredX = buttonFrame.midX - panelSize.width / 2 let origin = NSPoint( x: min(max(centeredX, visibleFrame.minX + 8), visibleFrame.maxX - panelSize.width - 8), y: buttonFrame.minY - panelSize.height - 8 ) panel.setFrameOrigin(origin) } private func setupPopover() { popover = NSPopover() popover.behavior = .transient popover.contentViewController = NSHostingController( rootView: StatusPopoverView(controller: controller) ) } private func setupContextMenu() { contextMenu = NSMenu() contextMenu.addItem(NSMenuItem(title: "Sync Now", action: #selector(syncNow), keyEquivalent: "s")) contextMenu.addItem(NSMenuItem(title: "Upload File...", action: #selector(uploadFile), keyEquivalent: "u")) contextMenu.addItem(NSMenuItem(title: "New Note", action: #selector(createNote), keyEquivalent: "n")) contextMenu.addItem(NSMenuItem(title: "Drop Zone", action: #selector(showDropZone), keyEquivalent: "d")) contextMenu.addItem(NSMenuItem(title: "View Uploads", action: #selector(showUploads), keyEquivalent: "v")) contextMenu.addItem(NSMenuItem.separator()) contextMenu.addItem(NSMenuItem(title: "Settings...", action: #selector(showSettings), keyEquivalent: ",")) contextMenu.addItem(NSMenuItem(title: "Quit", action: #selector(quit), keyEquivalent: "q")) } @objc private func statusItemButtonClicked(_ sender: AnyObject?) { guard let event = NSApp.currentEvent else { return } switch event.type { case .rightMouseUp: statusItem.menu = contextMenu statusItem.button?.performClick(nil) statusItem.menu = nil default: togglePopover() } } private func togglePopover() { if popover.isShown { closePopover() } else { showPopover() } } private func showPopover() { guard let button = dropButton else { return } Task { @MainActor in await controller.refreshRecentUploads() } popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY) NSEvent.addGlobalMonitorForEvents(matching: [.leftMouseDown, .rightMouseDown]) { [weak self] event in guard let self = self, self.popover.isShown else { return } self.closePopover() } } private func closePopover() { popover.performClose(nil) } // MARK: - Actions @objc private func syncNow() { Task { @MainActor in await controller.performSync() } } @objc private func uploadFile() { Task { @MainActor in controller.quickUpload() } } @objc private func createNote() { Task { @MainActor in controller.createNote() } } @objc private func showDropZone() { Task { @MainActor in controller.showDropZone() } } @objc private func showUploads() { Task { @MainActor in controller.showUploads() } } @objc private func showSettings() { Task { @MainActor in controller.showSettings() } } @objc private func quit() { NSApp.terminate(nil) } } private struct ClipboardHUDView: View { let filename: String var body: some View { HStack(spacing: 12) { Image(systemName: "checkmark.circle.fill") .font(.system(size: 26)) .foregroundColor(.green) VStack(alignment: .leading, spacing: 3) { Text("URL Copied to Clipboard") .font(.system(size: 13, weight: .semibold)) Text(filename) .font(.caption) .foregroundColor(.secondary) .lineLimit(1) .truncationMode(.middle) } Spacer() } .padding(.horizontal, 16) .frame(width: 280, height: 72) .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12)) } } // MARK: - Drop Status Button class DropStatusButton: NSButton { weak var appDelegate: AppDelegate? override init(frame frameRect: NSRect) { super.init(frame: frameRect) setup() } required init?(coder: NSCoder) { super.init(coder: coder) setup() } private func setup() { registerForDraggedTypes([.fileURL]) } override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation { // Tint icon slightly to indicate drop zone is active if let image = self.image { self.image = image.tinted(with: .systemBlue) } return .copy } override func draggingExited(_ sender: NSDraggingInfo?) { // Restore original icon if let appDelegate = appDelegate { appDelegate.updateIcon(config: appDelegate.controller.config) } } override func performDragOperation(_ sender: NSDraggingInfo) -> Bool { let pasteboard = sender.draggingPasteboard guard let urls = pasteboard.readObjects(forClasses: [NSURL.self], options: nil) as? [URL], !urls.isEmpty else { return false } appDelegate?.bounceIcon() Task { @MainActor in appDelegate?.controller.uploadFiles(urls) } return true } } extension NSImage { func tinted(with color: NSColor) -> NSImage { let image = self.copy() as! NSImage image.isTemplate = false let rect = NSRect(origin: .zero, size: image.size) image.lockFocus() color.set() rect.fill(using: .sourceAtop) image.unlockFocus() return image } }