Files
cairnquire/apps/macos-sync/Sources/CairnquireSyncApp/App.swift

377 lines
12 KiB
Swift

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
controller.clearSearch()
await controller.refreshRecentUploads()
}
NSApp.activate(ignoringOtherApps: true)
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
}
}