sync app filled in
This commit is contained in:
374
apps/macos-sync/Sources/CairnquireSyncApp/App.swift
Normal file
374
apps/macos-sync/Sources/CairnquireSyncApp/App.swift
Normal file
@@ -0,0 +1,374 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user