375 lines
12 KiB
Swift
375 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: "Create Note from Clipboard", 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.createNoteFromClipboard()
|
|
}
|
|
}
|
|
|
|
@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
|
|
}
|
|
}
|