This commit is contained in:
@@ -17,31 +17,45 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
let detail: String?
|
||||
}
|
||||
|
||||
struct CapturePauseDuration: Equatable {
|
||||
let title: String
|
||||
let seconds: TimeInterval
|
||||
let symbolName: String
|
||||
}
|
||||
|
||||
private static let statusMenuTextLimit = 68
|
||||
static let temporaryPauseDurations = [
|
||||
CapturePauseDuration(title: "Pause for 5 Minutes", seconds: 5 * 60, symbolName: "timer"),
|
||||
CapturePauseDuration(title: "Pause for 30 Minutes", seconds: 30 * 60, symbolName: "timer"),
|
||||
CapturePauseDuration(title: "Pause for 1 Hour", seconds: 60 * 60, symbolName: "clock")
|
||||
]
|
||||
|
||||
private var cacheService: ClipboardCacheService!
|
||||
private var cloudSyncService: ClipboardCloudSyncService!
|
||||
private var settings: SettingsModel!
|
||||
private var store: ClipboardStore!
|
||||
private var monitor: ClipboardMonitorService!
|
||||
private var panelController: ClipboardPanelController!
|
||||
private var settingsController: SettingsWindowController!
|
||||
private var onboardingController: OnboardingWindowController?
|
||||
private var shortcutManager: ShortcutManager!
|
||||
private var lifecycleService: AppLifecycleService!
|
||||
private var statusItem: NSStatusItem?
|
||||
private var statusMenu: NSMenu?
|
||||
private var pauseResumeTimer: Timer?
|
||||
private var cloudSyncPushWorkItem: DispatchWorkItem?
|
||||
private var suppressCloudSyncPush = false
|
||||
|
||||
func applicationDidFinishLaunching(_ notification: Notification) {
|
||||
settings = SettingsModel()
|
||||
cacheService = ClipboardCacheService()
|
||||
cloudSyncService = ClipboardCloudSyncService()
|
||||
store = ClipboardStore(settings: settings, cacheService: cacheService)
|
||||
monitor = ClipboardMonitorService(store: store, cacheService: cacheService, settings: settings)
|
||||
panelController = ClipboardPanelController(
|
||||
store: store,
|
||||
settings: settings,
|
||||
cacheService: cacheService,
|
||||
preferredScreen: { [weak self] in
|
||||
self?.statusItem?.button?.window?.screen
|
||||
},
|
||||
pollClipboardNow: { [weak monitor] in
|
||||
monitor?.pollNowAndWait()
|
||||
},
|
||||
@@ -49,7 +63,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
self?.openSettings()
|
||||
}
|
||||
)
|
||||
settingsController = SettingsWindowController(settings: settings, store: store, cacheService: cacheService)
|
||||
monitor.onCapturedItem = { [weak self] item in
|
||||
self?.panelController.addCapturedItemToStack(item)
|
||||
}
|
||||
settingsController = SettingsWindowController(
|
||||
settings: settings,
|
||||
store: store,
|
||||
cacheService: cacheService,
|
||||
cloudSyncService: cloudSyncService
|
||||
)
|
||||
lifecycleService = AppLifecycleService()
|
||||
shortcutManager = ShortcutManager(
|
||||
onOpenClipboardPanel: { [weak self] in
|
||||
@@ -63,6 +85,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
self?.settingsController.show()
|
||||
}
|
||||
},
|
||||
onToggleStackCapture: { [weak self] in
|
||||
DispatchQueue.main.async {
|
||||
self?.panelController.toggleStackCaptureMode()
|
||||
}
|
||||
},
|
||||
onStatusChange: { [weak self] status in
|
||||
DispatchQueue.main.async {
|
||||
self?.settings.setShortcutStatus(message: status.message)
|
||||
@@ -72,8 +99,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
settingsShortcut: settings.settingsShortcut
|
||||
)
|
||||
bindSettings()
|
||||
bindCloudSync()
|
||||
applyPresentation(changedSurface: nil)
|
||||
monitor.setPaused(settings.pauseCapture)
|
||||
applyCapturePauseSetting()
|
||||
monitor.start()
|
||||
shortcutManager.start()
|
||||
|
||||
@@ -81,14 +109,17 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
|
||||
refreshStatusItem()
|
||||
configureMainMenu()
|
||||
requestInitialAccessibilityPermissionIfNeeded()
|
||||
presentInitialSetupIfNeeded()
|
||||
}
|
||||
|
||||
func applicationDidBecomeActive(_ notification: Notification) {
|
||||
refreshAccessibilityPermissionMessage()
|
||||
onboardingController?.refreshPermissionStatus()
|
||||
}
|
||||
|
||||
func applicationWillTerminate(_ notification: Notification) {
|
||||
pauseResumeTimer?.invalidate()
|
||||
cloudSyncPushWorkItem?.cancel()
|
||||
monitor.stop()
|
||||
shortcutManager.stop()
|
||||
cacheService.clearTemporaryPreviews(wait: true)
|
||||
@@ -108,6 +139,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
panelController.toggle()
|
||||
}
|
||||
|
||||
@objc private func createCollection() {
|
||||
panelController.createCollection()
|
||||
}
|
||||
|
||||
@objc private func toggleStackCaptureMode() {
|
||||
panelController.toggleStackCaptureMode()
|
||||
}
|
||||
|
||||
@objc private func statusItemClicked(_ sender: NSStatusBarButton) {
|
||||
let event = NSApp.currentEvent
|
||||
if shouldOpenStatusMenu(for: event) {
|
||||
@@ -115,7 +154,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
return
|
||||
}
|
||||
|
||||
showClipboardPanel()
|
||||
showClipboardPanelFromStatusButton(sender)
|
||||
}
|
||||
|
||||
private func showClipboardPanelFromStatusButton(_ button: NSStatusBarButton) {
|
||||
panelController.toggle(preferredScreen: button.window?.screen)
|
||||
}
|
||||
|
||||
private func shouldOpenStatusMenu(for event: NSEvent?) -> Bool {
|
||||
@@ -143,6 +186,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
presentation: Self.statusMenuPresentation(
|
||||
historyCount: store.items.count,
|
||||
isCapturePaused: settings.pauseCapture,
|
||||
pauseCaptureUntil: settings.pauseCaptureUntil,
|
||||
captureStatus: settings.captureStatusMessage,
|
||||
pasteStatus: settings.pasteStatusMessage,
|
||||
shortcutStatus: settings.shortcutStatusMessage,
|
||||
@@ -163,6 +207,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
static func statusMenuPresentation(
|
||||
historyCount: Int,
|
||||
isCapturePaused: Bool,
|
||||
pauseCaptureUntil: Date? = nil,
|
||||
now: Date = Date(),
|
||||
captureStatus: String,
|
||||
pasteStatus: String,
|
||||
shortcutStatus: String,
|
||||
@@ -172,7 +218,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
let captureState = isCapturePaused ? "Capture Paused" : "Capture Running"
|
||||
let summary = "\(captureState) - \(clipCountText(historyCount))"
|
||||
let status = firstPresentStatus([
|
||||
isCapturePaused ? "Capture is paused." : nil,
|
||||
capturePauseStatusText(isCapturePaused: isCapturePaused, pauseCaptureUntil: pauseCaptureUntil, now: now),
|
||||
captureStatus,
|
||||
pasteStatus,
|
||||
shortcutStatus,
|
||||
@@ -210,6 +256,24 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
symbolName: "rectangle.bottomthird.inset.filled",
|
||||
to: menu
|
||||
)
|
||||
addActionMenuItem(
|
||||
"New Collection",
|
||||
action: #selector(createCollection),
|
||||
target: target,
|
||||
keyEquivalent: "n",
|
||||
keyEquivalentModifierMask: [.command, .shift],
|
||||
symbolName: "folder.badge.plus",
|
||||
to: menu
|
||||
)
|
||||
addActionMenuItem(
|
||||
"Stack Capture",
|
||||
action: #selector(toggleStackCaptureMode),
|
||||
target: target,
|
||||
keyEquivalent: "c",
|
||||
keyEquivalentModifierMask: [.command, .shift],
|
||||
symbolName: "square.stack.3d.up.fill",
|
||||
to: menu
|
||||
)
|
||||
addActionMenuItem(
|
||||
"Settings\u{2026}",
|
||||
action: #selector(openSettings),
|
||||
@@ -225,10 +289,24 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
isCapturePaused ? "Resume Capture" : "Pause Capture",
|
||||
action: #selector(togglePauseCapture),
|
||||
target: target,
|
||||
keyEquivalent: "t",
|
||||
keyEquivalentModifierMask: .command,
|
||||
symbolName: isCapturePaused ? "play.fill" : "pause.fill",
|
||||
to: menu
|
||||
)
|
||||
pause.state = isCapturePaused ? .on : .off
|
||||
if !isCapturePaused {
|
||||
for duration in temporaryPauseDurations {
|
||||
let item = addActionMenuItem(
|
||||
duration.title,
|
||||
action: #selector(pauseCaptureForDuration(_:)),
|
||||
target: target,
|
||||
symbolName: duration.symbolName,
|
||||
to: menu
|
||||
)
|
||||
item.representedObject = duration.seconds
|
||||
}
|
||||
}
|
||||
|
||||
menu.addItem(NSMenuItem.separator())
|
||||
addActionMenuItem(
|
||||
@@ -249,7 +327,20 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
}
|
||||
|
||||
@objc private func togglePauseCapture() {
|
||||
settings.pauseCapture.toggle()
|
||||
if settings.pauseCapture {
|
||||
settings.pauseCapture = false
|
||||
settings.pauseCaptureUntil = nil
|
||||
} else {
|
||||
settings.pauseCaptureUntil = nil
|
||||
settings.pauseCapture = true
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func pauseCaptureForDuration(_ sender: NSMenuItem) {
|
||||
let seconds = sender.representedObject as? TimeInterval ?? 0
|
||||
guard seconds > 0 else { return }
|
||||
settings.pauseCapture = true
|
||||
settings.pauseCaptureUntil = Date().addingTimeInterval(seconds)
|
||||
}
|
||||
|
||||
@objc private func quitApp() {
|
||||
@@ -317,6 +408,35 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
return "\(count) clips"
|
||||
}
|
||||
|
||||
static func shouldResumeExpiredCapturePause(isCapturePaused: Bool, pauseCaptureUntil: Date?, now: Date) -> Bool {
|
||||
guard isCapturePaused, let pauseCaptureUntil else { return false }
|
||||
return pauseCaptureUntil <= now
|
||||
}
|
||||
|
||||
static func capturePauseStatusText(isCapturePaused: Bool, pauseCaptureUntil: Date?, now: Date) -> String? {
|
||||
guard isCapturePaused else { return nil }
|
||||
guard let pauseCaptureUntil, pauseCaptureUntil > now else {
|
||||
return "Capture is paused."
|
||||
}
|
||||
|
||||
let seconds = max(0, pauseCaptureUntil.timeIntervalSince(now))
|
||||
if seconds < 60 {
|
||||
return "Capture is paused for less than a minute."
|
||||
}
|
||||
|
||||
let minutes = Int(ceil(seconds / 60))
|
||||
if minutes < 60 {
|
||||
return "Capture is paused for \(minutes) more \(pluralized("minute", minutes))."
|
||||
}
|
||||
|
||||
let hours = Int(ceil(Double(minutes) / 60))
|
||||
return "Capture is paused for \(hours) more \(pluralized("hour", hours))."
|
||||
}
|
||||
|
||||
private static func pluralized(_ singular: String, _ count: Int) -> String {
|
||||
count == 1 ? singular : "\(singular)s"
|
||||
}
|
||||
|
||||
private static func boundedStatusText(_ value: String) -> String {
|
||||
let collapsed = value
|
||||
.split { $0.isWhitespace || $0.isNewline }
|
||||
@@ -385,6 +505,27 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
appSubMenu.addItem(quit)
|
||||
appMenu.submenu = appSubMenu
|
||||
|
||||
let fileMenu = NSMenuItem()
|
||||
let fileSubMenu = NSMenu(title: "File")
|
||||
let newCollection = NSMenuItem(
|
||||
title: "New Collection",
|
||||
action: #selector(createCollection),
|
||||
keyEquivalent: "n"
|
||||
)
|
||||
newCollection.keyEquivalentModifierMask = [.command, .shift]
|
||||
newCollection.target = self
|
||||
fileSubMenu.addItem(newCollection)
|
||||
fileSubMenu.addItem(NSMenuItem.separator())
|
||||
let pauseCapture = NSMenuItem(
|
||||
title: "Pause/Resume Capture",
|
||||
action: #selector(togglePauseCapture),
|
||||
keyEquivalent: "t"
|
||||
)
|
||||
pauseCapture.keyEquivalentModifierMask = .command
|
||||
pauseCapture.target = self
|
||||
fileSubMenu.addItem(pauseCapture)
|
||||
fileMenu.submenu = fileSubMenu
|
||||
|
||||
let editMenu = NSMenuItem()
|
||||
let editSubMenu = NSMenu(title: "Edit")
|
||||
let openShortcut = self.settings.openShortcut
|
||||
@@ -396,10 +537,19 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
showClipboard.keyEquivalentModifierMask = menuModifierFlags(openShortcut)
|
||||
showClipboard.target = self
|
||||
editSubMenu.addItem(showClipboard)
|
||||
let stackCapture = NSMenuItem(
|
||||
title: "Stack Capture",
|
||||
action: #selector(toggleStackCaptureMode),
|
||||
keyEquivalent: "c"
|
||||
)
|
||||
stackCapture.keyEquivalentModifierMask = [.command, .shift]
|
||||
stackCapture.target = self
|
||||
editSubMenu.addItem(stackCapture)
|
||||
editMenu.submenu = editSubMenu
|
||||
|
||||
let mainMenu = NSMenu()
|
||||
mainMenu.addItem(appMenu)
|
||||
mainMenu.addItem(fileMenu)
|
||||
mainMenu.addItem(editMenu)
|
||||
NSApp.mainMenu = mainMenu
|
||||
}
|
||||
@@ -413,10 +563,31 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
private func bindCloudSync() {
|
||||
var observedInitialItems = false
|
||||
store.observeItems { [weak self] _ in
|
||||
guard let self else { return }
|
||||
if !observedInitialItems {
|
||||
observedInitialItems = true
|
||||
return
|
||||
}
|
||||
guard self.settings.iCloudSyncEnabled, !self.suppressCloudSyncPush else { return }
|
||||
self.scheduleCloudSyncPush()
|
||||
}
|
||||
|
||||
if settings.iCloudSyncEnabled {
|
||||
applyCloudSyncSetting()
|
||||
} else {
|
||||
settings.setCloudSyncStatus(message: "iCloud Sync is off.")
|
||||
}
|
||||
}
|
||||
|
||||
private func handleSettingsChange(_ change: SettingsModel.Change) {
|
||||
switch change {
|
||||
case .maxHistoryItems:
|
||||
store.updateHistoryLimit(settings.maxHistoryItems)
|
||||
case .historyRetention:
|
||||
store.normalizeHistoryLength()
|
||||
case .imageCacheMaxBytes:
|
||||
cacheService.purgeIfNeeded(maxBytes: settings.imageCacheMaxBytes)
|
||||
case .openShortcut, .settingsShortcut:
|
||||
@@ -430,20 +601,129 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
applyPresentation(changedSurface: .menuBar)
|
||||
case .showDockIcon:
|
||||
applyPresentation(changedSurface: .dock)
|
||||
case .compactMode:
|
||||
break
|
||||
case .panelLayout:
|
||||
break
|
||||
case .panelSizing:
|
||||
break
|
||||
case .cloudSync:
|
||||
applyCloudSyncSetting()
|
||||
case .pauseCapture:
|
||||
monitor.setPaused(settings.pauseCapture)
|
||||
if settings.showMenuBarIcon {
|
||||
refreshStatusMenu()
|
||||
}
|
||||
applyCapturePauseSetting()
|
||||
case .pollProfile:
|
||||
monitor.setPaused(settings.pauseCapture)
|
||||
case .status, .collections, .other:
|
||||
case .hideFromScreenCapture:
|
||||
break
|
||||
case .defaultSortMode, .includeImageTextInSearch, .pruneDuplicates, .ignoredItemKinds, .keepFirstImage, .excludeSensitive, .clearHistoryOnQuit:
|
||||
break
|
||||
case .status, .collections, .ignoredApps, .other:
|
||||
break
|
||||
case .captureStatus:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private func applyCloudSyncSetting() {
|
||||
cloudSyncPushWorkItem?.cancel()
|
||||
cloudSyncPushWorkItem = nil
|
||||
|
||||
guard settings.iCloudSyncEnabled else {
|
||||
settings.setCloudSyncStatus(message: "iCloud Sync is off.")
|
||||
return
|
||||
}
|
||||
|
||||
let status = cloudSyncService.status()
|
||||
settings.setCloudSyncStatus(message: status.message)
|
||||
guard status.isAvailable else { return }
|
||||
pullCloudSyncArchiveIfAvailable()
|
||||
}
|
||||
|
||||
private func pullCloudSyncArchiveIfAvailable() {
|
||||
suppressCloudSyncPush = true
|
||||
defer { suppressCloudSyncPush = false }
|
||||
|
||||
do {
|
||||
let summary = try cloudSyncService.pull(store: store)
|
||||
settings.setCloudSyncStatus(message: "Restored \(summary.itemCount) clips from iCloud.")
|
||||
} catch ClipboardCloudSyncError.noRemoteArchive(_) {
|
||||
settings.setCloudSyncStatus(message: "iCloud Sync is ready. No remote archive yet.")
|
||||
scheduleCloudSyncPush(after: 1.0)
|
||||
} catch {
|
||||
settings.setCloudSyncStatus(message: "iCloud Sync failed: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
private func scheduleCloudSyncPush(after delay: TimeInterval = 2.0) {
|
||||
cloudSyncPushWorkItem?.cancel()
|
||||
let workItem = DispatchWorkItem { [weak self] in
|
||||
self?.pushCloudSyncArchive()
|
||||
}
|
||||
cloudSyncPushWorkItem = workItem
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: workItem)
|
||||
}
|
||||
|
||||
private func pushCloudSyncArchive() {
|
||||
guard settings.iCloudSyncEnabled else { return }
|
||||
do {
|
||||
let summary = try cloudSyncService.push(store: store)
|
||||
settings.setCloudSyncStatus(message: "Synced \(summary.itemCount) clips to iCloud.")
|
||||
} catch {
|
||||
settings.setCloudSyncStatus(message: "iCloud Sync failed: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
private func applyCapturePauseSetting(now: Date = Date()) {
|
||||
if Self.shouldResumeExpiredCapturePause(
|
||||
isCapturePaused: settings.pauseCapture,
|
||||
pauseCaptureUntil: settings.pauseCaptureUntil,
|
||||
now: now
|
||||
) {
|
||||
settings.pauseCapture = false
|
||||
settings.pauseCaptureUntil = nil
|
||||
return
|
||||
}
|
||||
|
||||
monitor.setPaused(settings.pauseCapture)
|
||||
scheduleCapturePauseTimer(now: now)
|
||||
if settings.showMenuBarIcon {
|
||||
refreshStatusMenu()
|
||||
}
|
||||
}
|
||||
|
||||
private func scheduleCapturePauseTimer(now: Date = Date()) {
|
||||
pauseResumeTimer?.invalidate()
|
||||
pauseResumeTimer = nil
|
||||
|
||||
guard settings.pauseCapture, let pauseCaptureUntil = settings.pauseCaptureUntil else { return }
|
||||
let interval = pauseCaptureUntil.timeIntervalSince(now)
|
||||
guard interval > 0 else {
|
||||
settings.pauseCapture = false
|
||||
settings.pauseCaptureUntil = nil
|
||||
return
|
||||
}
|
||||
|
||||
pauseResumeTimer = Timer.scheduledTimer(withTimeInterval: interval, repeats: false) { [weak self] _ in
|
||||
DispatchQueue.main.async {
|
||||
self?.resumeExpiredCapturePause()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func resumeExpiredCapturePause() {
|
||||
guard Self.shouldResumeExpiredCapturePause(
|
||||
isCapturePaused: settings.pauseCapture,
|
||||
pauseCaptureUntil: settings.pauseCaptureUntil,
|
||||
now: Date()
|
||||
) else {
|
||||
applyCapturePauseSetting()
|
||||
return
|
||||
}
|
||||
|
||||
settings.pauseCapture = false
|
||||
settings.pauseCaptureUntil = nil
|
||||
}
|
||||
|
||||
static func presentationPlan(
|
||||
showMenuBarIcon: Bool,
|
||||
showDockIcon: Bool,
|
||||
@@ -536,7 +816,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
|
||||
let alert = NSAlert()
|
||||
alert.messageText = "Allow automatic paste?"
|
||||
alert.informativeText = "ClipBored can capture clipboard history without extra permission. Grant Accessibility only if you want selected clips to paste directly into the previous app; otherwise paste actions will copy the clip for you."
|
||||
alert.informativeText = "ClipBored captures history without extra permission. Grant Accessibility only for direct paste; otherwise paste actions copy the clip."
|
||||
alert.addButton(withTitle: "Open Accessibility Settings")
|
||||
alert.addButton(withTitle: "Later")
|
||||
alert.alertStyle = .warning
|
||||
@@ -550,6 +830,39 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
refreshAccessibilityPermissionMessage()
|
||||
}
|
||||
|
||||
private func presentInitialSetupIfNeeded() {
|
||||
guard !settings.onboardingCompleted else {
|
||||
requestInitialAccessibilityPermissionIfNeeded()
|
||||
return
|
||||
}
|
||||
|
||||
let controller = OnboardingWindowController(
|
||||
settings: settings,
|
||||
onOpenAccessibility: { [weak self] in
|
||||
self?.openAccessibilitySettingsFromOnboarding()
|
||||
},
|
||||
onFinish: { [weak self] in
|
||||
self?.completeInitialSetup()
|
||||
}
|
||||
)
|
||||
onboardingController = controller
|
||||
controller.show()
|
||||
}
|
||||
|
||||
private func openAccessibilitySettingsFromOnboarding() {
|
||||
settings.markAccessibilityNoticeShown()
|
||||
_ = AccessibilityPermissionService.requestPromptIfNeeded()
|
||||
if !AccessibilityPermissionService.isTrusted {
|
||||
AccessibilityPermissionService.openSystemSettings()
|
||||
}
|
||||
refreshAccessibilityPermissionMessage()
|
||||
}
|
||||
|
||||
private func completeInitialSetup() {
|
||||
onboardingController = nil
|
||||
requestInitialAccessibilityPermissionIfNeeded()
|
||||
}
|
||||
|
||||
private func menuModifierFlags(_ binding: ShortcutBinding) -> NSEvent.ModifierFlags {
|
||||
NSEvent.ModifierFlags(rawValue: binding.modifierFlags)
|
||||
}
|
||||
|
||||
@@ -7,7 +7,9 @@ enum AppConfiguration {
|
||||
static let defaultHistoryLength = 300
|
||||
static let minHistoryLength = 50
|
||||
static let maxHistoryLength = 2000
|
||||
static let minCacheMaxBytes: Int64 = 2 * 1024 * 1024
|
||||
static let defaultCacheMaxBytes: Int64 = 120 * 1024 * 1024
|
||||
static let maxCacheMaxBytes: Int64 = 512 * 1024 * 1024
|
||||
static let maxPinnedItems = 250
|
||||
static let maxFullImagePixelSize: CGFloat = 1600
|
||||
static let maxRecognizedImageTextLength = 4096
|
||||
|
||||
@@ -25,6 +25,42 @@ extension NSImage {
|
||||
let rep = NSBitmapImageRep(cgImage: cgImage)
|
||||
return rep.representation(using: .png, properties: [:])
|
||||
}
|
||||
|
||||
func rotatedClockwise() -> NSImage? {
|
||||
guard let cgImage = cgImage(forProposedRect: nil, context: nil, hints: nil),
|
||||
cgImage.width > 0,
|
||||
cgImage.height > 0 else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let colorSpace = cgImage.colorSpace ?? CGColorSpaceCreateDeviceRGB()
|
||||
let bitmapInfo = CGImageAlphaInfo.premultipliedLast.rawValue
|
||||
guard let context = CGContext(
|
||||
data: nil,
|
||||
width: cgImage.height,
|
||||
height: cgImage.width,
|
||||
bitsPerComponent: 8,
|
||||
bytesPerRow: 0,
|
||||
space: colorSpace,
|
||||
bitmapInfo: bitmapInfo
|
||||
) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
context.interpolationQuality = .high
|
||||
context.translateBy(x: CGFloat(cgImage.height), y: 0)
|
||||
context.rotate(by: .pi / 2)
|
||||
context.draw(
|
||||
cgImage,
|
||||
in: CGRect(x: 0, y: 0, width: cgImage.width, height: cgImage.height)
|
||||
)
|
||||
|
||||
guard let output = context.makeImage() else { return nil }
|
||||
return NSImage(
|
||||
cgImage: output,
|
||||
size: NSSize(width: cgImage.height, height: cgImage.width)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension NSView {
|
||||
|
||||
@@ -89,6 +89,31 @@ enum ClipboardSortMode: Int {
|
||||
case .videos: return "Videos"
|
||||
}
|
||||
}
|
||||
|
||||
func includes(_ item: ClipboardItem) -> Bool {
|
||||
switch self {
|
||||
case .mostRecent, .mostUsed:
|
||||
return true
|
||||
case .images:
|
||||
return item.kind == .image
|
||||
case .links:
|
||||
return item.kind == .url
|
||||
case .text:
|
||||
return item.kind == .text || item.kind == .richText || item.kind == .code
|
||||
case .pinned:
|
||||
return item.isPinned
|
||||
case .files:
|
||||
return item.kind == .file || item.kind == .pdf
|
||||
case .audio:
|
||||
return item.kind == .audio
|
||||
case .colors:
|
||||
return item.kind == .color
|
||||
case .code:
|
||||
return item.kind == .code
|
||||
case .videos:
|
||||
return item.kind == .video
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum ClipboardCollectionDefaults {
|
||||
@@ -127,6 +152,7 @@ struct ClipboardItem {
|
||||
var ocrText: String?
|
||||
var collectionName: String?
|
||||
var customTitle: String?
|
||||
var sourceDeviceName: String?
|
||||
|
||||
var searchableText: String {
|
||||
var text = kindLabel + " " + displayText.lowercased() + " " + payload.lowercased()
|
||||
@@ -136,18 +162,22 @@ struct ClipboardItem {
|
||||
if let sourceApp {
|
||||
text += " " + sourceApp.lowercased()
|
||||
}
|
||||
if let ocrText {
|
||||
text += " " + ocrText.lowercased()
|
||||
}
|
||||
if let sourceAppBundleId {
|
||||
text += " " + sourceAppBundleId.lowercased()
|
||||
}
|
||||
if let collectionName {
|
||||
text += " " + collectionName.lowercased()
|
||||
}
|
||||
if let sourceDeviceName {
|
||||
text += " " + sourceDeviceName.lowercased()
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
var effectiveSourceDeviceName: String {
|
||||
ClipboardItem.normalizedDeviceName(sourceDeviceName) ?? Self.localDeviceName
|
||||
}
|
||||
|
||||
private var kindLabel: String {
|
||||
switch kind {
|
||||
case .text: return "text"
|
||||
@@ -180,7 +210,8 @@ struct ClipboardItem {
|
||||
sourceAppBundleId: String? = nil,
|
||||
ocrText: String? = nil,
|
||||
collectionName: String? = nil,
|
||||
customTitle: String? = nil
|
||||
customTitle: String? = nil,
|
||||
sourceDeviceName: String? = ClipboardItem.localDeviceName
|
||||
) {
|
||||
self.id = id
|
||||
self.kind = kind
|
||||
@@ -198,6 +229,7 @@ struct ClipboardItem {
|
||||
self.ocrText = ocrText
|
||||
self.collectionName = collectionName
|
||||
self.customTitle = ClipboardItem.normalizedCustomTitle(customTitle)
|
||||
self.sourceDeviceName = ClipboardItem.normalizedDeviceName(sourceDeviceName)
|
||||
}
|
||||
|
||||
static func normalizedCustomTitle(_ value: String?) -> String? {
|
||||
@@ -209,4 +241,21 @@ struct ClipboardItem {
|
||||
guard !title.isEmpty else { return nil }
|
||||
return String(title.prefix(80))
|
||||
}
|
||||
|
||||
static var localDeviceName: String {
|
||||
normalizedDeviceName(Host.current().localizedName)
|
||||
?? normalizedDeviceName(Host.current().name)
|
||||
?? normalizedDeviceName(ProcessInfo.processInfo.hostName)
|
||||
?? "This Mac"
|
||||
}
|
||||
|
||||
static func normalizedDeviceName(_ value: String?) -> String? {
|
||||
guard let value else { return nil }
|
||||
let name = value
|
||||
.split { $0.isWhitespace }
|
||||
.joined(separator: " ")
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !name.isEmpty else { return nil }
|
||||
return String(name.prefix(60))
|
||||
}
|
||||
}
|
||||
|
||||
6
sources/clipbored/models/LinkPreviewRequest.swift
Normal file
6
sources/clipbored/models/LinkPreviewRequest.swift
Normal file
@@ -0,0 +1,6 @@
|
||||
import Foundation
|
||||
|
||||
struct LinkPreviewRequest: Equatable {
|
||||
let url: URL
|
||||
let title: String
|
||||
}
|
||||
@@ -1,15 +1,75 @@
|
||||
import Foundation
|
||||
|
||||
enum HistoryRetention: Int {
|
||||
case forever = 0
|
||||
case oneDay = 1
|
||||
case oneWeek = 7
|
||||
case oneMonth = 30
|
||||
case oneYear = 365
|
||||
|
||||
static let allCases: [HistoryRetention] = [.oneDay, .oneWeek, .oneMonth, .oneYear, .forever]
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .oneDay: return "1 Day"
|
||||
case .oneWeek: return "1 Week"
|
||||
case .oneMonth: return "1 Month"
|
||||
case .oneYear: return "1 Year"
|
||||
case .forever: return "Forever"
|
||||
}
|
||||
}
|
||||
|
||||
func cutoffDate(relativeTo now: Date = Date()) -> Date? {
|
||||
guard self != .forever else { return nil }
|
||||
return now.addingTimeInterval(-Double(rawValue) * 24 * 60 * 60)
|
||||
}
|
||||
}
|
||||
|
||||
enum ClipboardPanelLayout: Int, CaseIterable {
|
||||
case horizontal = 0
|
||||
case vertical = 1
|
||||
|
||||
var title: String {
|
||||
"Side Shelf"
|
||||
}
|
||||
}
|
||||
|
||||
enum ClipboardPanelSide: Int, CaseIterable {
|
||||
case left = 0
|
||||
case right = 1
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .left: return "Left"
|
||||
case .right: return "Right"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final class SettingsModel {
|
||||
enum Change: Equatable {
|
||||
case maxHistoryItems
|
||||
case historyRetention
|
||||
case defaultSortMode
|
||||
case imageCacheMaxBytes
|
||||
case includeImageTextInSearch
|
||||
case pruneDuplicates
|
||||
case openShortcut
|
||||
case settingsShortcut
|
||||
case launchAtLogin
|
||||
case showMenuBarIcon
|
||||
case showDockIcon
|
||||
case compactMode
|
||||
case panelLayout
|
||||
case panelSizing
|
||||
case cloudSync
|
||||
case pauseCapture
|
||||
case ignoredApps
|
||||
case ignoredItemKinds
|
||||
case keepFirstImage
|
||||
case excludeSensitive
|
||||
case hideFromScreenCapture
|
||||
case clearHistoryOnQuit
|
||||
case pollProfile
|
||||
case captureStatus
|
||||
case collections
|
||||
@@ -19,6 +79,7 @@ final class SettingsModel {
|
||||
|
||||
enum Keys {
|
||||
static let maxHistoryItems = "maxHistoryItems"
|
||||
static let historyRetention = "historyRetentionDays"
|
||||
static let defaultSortMode = "defaultSortMode"
|
||||
static let imageCacheMaxBytes = "imageCacheMaxBytes"
|
||||
static let includeImageTextInSearch = "includeImageTextInSearch"
|
||||
@@ -26,6 +87,11 @@ final class SettingsModel {
|
||||
static let launchAtLogin = "launchAtLogin"
|
||||
static let showMenuBarIcon = "showMenuBarIcon"
|
||||
static let showDockIcon = "showDockIcon"
|
||||
static let compactMode = "compactMode"
|
||||
static let panelLayout = "panelLayout"
|
||||
static let panelSide = "panelSide"
|
||||
static let panelShelfHeight = "panelShelfHeight"
|
||||
static let iCloudSyncEnabled = "iCloudSyncEnabled"
|
||||
static let openShortcut = "openShortcut"
|
||||
static let settingsShortcut = "settingsShortcut"
|
||||
static let ignoredApps = "ignoredApps"
|
||||
@@ -34,26 +100,38 @@ final class SettingsModel {
|
||||
static let keepFirstImage = "keepFirstImage"
|
||||
static let excludeSensitive = "excludeSensitive"
|
||||
static let pauseCapture = "pauseCapture"
|
||||
static let pauseCaptureUntil = "pauseCaptureUntil"
|
||||
static let hideFromScreenCapture = "hideFromScreenCapture"
|
||||
static let clearHistoryOnQuit = "clearHistoryOnQuit"
|
||||
static let onboardingCompleted = "onboardingCompleted"
|
||||
static let accessibilityNoticeShown = "accessibilityNoticeShown"
|
||||
static let customCollectionNames = "customCollectionNames"
|
||||
static let collectionColorHexes = "collectionColorHexes"
|
||||
}
|
||||
|
||||
var maxHistoryItems: Int {
|
||||
didSet { if oldValue != maxHistoryItems { storeAndNotify(.maxHistoryItems) } }
|
||||
didSet {
|
||||
maxHistoryItems = Self.clampedMaxHistoryItems(maxHistoryItems)
|
||||
if oldValue != maxHistoryItems { storeAndNotify(.maxHistoryItems) }
|
||||
}
|
||||
}
|
||||
var historyRetention: HistoryRetention {
|
||||
didSet { if oldValue != historyRetention { storeAndNotify(.historyRetention) } }
|
||||
}
|
||||
var defaultSortMode: ClipboardSortMode {
|
||||
didSet { if oldValue != defaultSortMode { storeAndNotify(.other) } }
|
||||
didSet { if oldValue != defaultSortMode { storeAndNotify(.defaultSortMode) } }
|
||||
}
|
||||
var imageCacheMaxBytes: Int64 {
|
||||
didSet { if oldValue != imageCacheMaxBytes { storeAndNotify(.imageCacheMaxBytes) } }
|
||||
didSet {
|
||||
imageCacheMaxBytes = Self.clampedImageCacheMaxBytes(imageCacheMaxBytes)
|
||||
if oldValue != imageCacheMaxBytes { storeAndNotify(.imageCacheMaxBytes) }
|
||||
}
|
||||
}
|
||||
var includeImageTextInSearch: Bool {
|
||||
didSet { if oldValue != includeImageTextInSearch { storeAndNotify(.other) } }
|
||||
didSet { if oldValue != includeImageTextInSearch { storeAndNotify(.includeImageTextInSearch) } }
|
||||
}
|
||||
var pruneDuplicates: Bool {
|
||||
didSet { if oldValue != pruneDuplicates { storeAndNotify(.other) } }
|
||||
didSet { if oldValue != pruneDuplicates { storeAndNotify(.pruneDuplicates) } }
|
||||
}
|
||||
var launchAtLogin: Bool {
|
||||
didSet { if oldValue != launchAtLogin { storeAndNotify(.launchAtLogin) } }
|
||||
@@ -64,6 +142,30 @@ final class SettingsModel {
|
||||
var showDockIcon: Bool {
|
||||
didSet { if oldValue != showDockIcon { storeAndNotify(.showDockIcon) } }
|
||||
}
|
||||
var compactMode: Bool {
|
||||
didSet { if oldValue != compactMode { storeAndNotify(.compactMode) } }
|
||||
}
|
||||
var panelLayout: ClipboardPanelLayout {
|
||||
didSet {
|
||||
if panelLayout != .vertical {
|
||||
panelLayout = .vertical
|
||||
}
|
||||
if oldValue != panelLayout { storeAndNotify(.panelLayout) }
|
||||
}
|
||||
}
|
||||
var panelSide: ClipboardPanelSide {
|
||||
didSet { if oldValue != panelSide { storeAndNotify(.panelLayout) } }
|
||||
}
|
||||
var panelShelfHeight: Double {
|
||||
didSet { if oldValue != panelShelfHeight { storeAndNotify(.panelSizing) } }
|
||||
}
|
||||
var iCloudSyncEnabled: Bool {
|
||||
didSet {
|
||||
guard oldValue != iCloudSyncEnabled else { return }
|
||||
cloudSyncStatusMessage = ""
|
||||
storeAndNotify(.cloudSync)
|
||||
}
|
||||
}
|
||||
var openShortcut: ShortcutBinding {
|
||||
didSet { if oldValue != openShortcut { storeAndNotify(.openShortcut) } }
|
||||
}
|
||||
@@ -71,25 +173,37 @@ final class SettingsModel {
|
||||
didSet { if oldValue != settingsShortcut { storeAndNotify(.settingsShortcut) } }
|
||||
}
|
||||
var ignoredApps: [String] {
|
||||
didSet { if oldValue != ignoredApps { storeAndNotify(.other) } }
|
||||
didSet { if oldValue != ignoredApps { storeAndNotify(.ignoredApps) } }
|
||||
}
|
||||
var ignoredItemKindsRaw: [Int] {
|
||||
didSet { if oldValue != ignoredItemKindsRaw { storeAndNotify(.other) } }
|
||||
didSet {
|
||||
let normalized = Self.normalizedIgnoredItemKinds(ignoredItemKindsRaw)
|
||||
if normalized != ignoredItemKindsRaw {
|
||||
ignoredItemKindsRaw = normalized
|
||||
}
|
||||
if oldValue != ignoredItemKindsRaw { storeAndNotify(.ignoredItemKinds) }
|
||||
}
|
||||
}
|
||||
var pollProfileRaw: AppConfiguration.PollProfile {
|
||||
didSet { if oldValue != pollProfileRaw { storeAndNotify(.pollProfile) } }
|
||||
}
|
||||
var keepFirstImage: Bool {
|
||||
didSet { if oldValue != keepFirstImage { storeAndNotify(.other) } }
|
||||
didSet { if oldValue != keepFirstImage { storeAndNotify(.keepFirstImage) } }
|
||||
}
|
||||
var excludeSensitive: Bool {
|
||||
didSet { if oldValue != excludeSensitive { storeAndNotify(.other) } }
|
||||
didSet { if oldValue != excludeSensitive { storeAndNotify(.excludeSensitive) } }
|
||||
}
|
||||
var pauseCapture: Bool {
|
||||
didSet { if oldValue != pauseCapture { storeAndNotify(.pauseCapture) } }
|
||||
}
|
||||
var pauseCaptureUntil: Date? {
|
||||
didSet { if oldValue != pauseCaptureUntil { storeAndNotify(.pauseCapture) } }
|
||||
}
|
||||
var hideFromScreenCapture: Bool {
|
||||
didSet { if oldValue != hideFromScreenCapture { storeAndNotify(.hideFromScreenCapture) } }
|
||||
}
|
||||
var clearHistoryOnQuit: Bool {
|
||||
didSet { if oldValue != clearHistoryOnQuit { storeAndNotify(.other) } }
|
||||
didSet { if oldValue != clearHistoryOnQuit { storeAndNotify(.clearHistoryOnQuit) } }
|
||||
}
|
||||
private(set) var customCollectionNames: [String]
|
||||
private(set) var collectionColorHexes: [String: String]
|
||||
@@ -98,6 +212,8 @@ final class SettingsModel {
|
||||
private(set) var captureStatusMessage: String = ""
|
||||
private(set) var shortcutStatusMessage: String = ""
|
||||
private(set) var pasteStatusMessage: String = ""
|
||||
private(set) var cloudSyncStatusMessage: String = ""
|
||||
private(set) var onboardingCompleted: Bool
|
||||
private(set) var accessibilityNoticeShown: Bool
|
||||
|
||||
private let defaults: UserDefaults
|
||||
@@ -107,10 +223,17 @@ final class SettingsModel {
|
||||
self.defaults = defaults
|
||||
|
||||
let savedHistory = defaults.integer(forKey: Keys.maxHistoryItems)
|
||||
let savedRetention = defaults.object(forKey: Keys.historyRetention) as? Int
|
||||
let savedSort = defaults.integer(forKey: Keys.defaultSortMode)
|
||||
let savedCacheObject = defaults.object(forKey: Keys.imageCacheMaxBytes)
|
||||
let savedCache = defaults.integer(forKey: Keys.imageCacheMaxBytes)
|
||||
let savedPanelSide = defaults.object(forKey: Keys.panelSide) as? Int
|
||||
let existingProfile = defaults.object(forKey: Keys.maxHistoryItems) != nil
|
||||
|| defaults.object(forKey: Keys.historyRetention) != nil
|
||||
|| defaults.object(forKey: Keys.openShortcut) != nil
|
||||
|
||||
maxHistoryItems = savedHistory > 0 ? savedHistory : AppConfiguration.defaultHistoryLength
|
||||
historyRetention = savedRetention.flatMap(HistoryRetention.init(rawValue:)) ?? .oneMonth
|
||||
defaultSortMode = ClipboardSortMode(rawValue: savedSort) ?? .mostRecent
|
||||
imageCacheMaxBytes = savedCache > 0 ? Int64(savedCache) : AppConfiguration.defaultCacheMaxBytes
|
||||
includeImageTextInSearch = defaults.object(forKey: Keys.includeImageTextInSearch) as? Bool ?? false
|
||||
@@ -118,30 +241,57 @@ final class SettingsModel {
|
||||
launchAtLogin = defaults.object(forKey: Keys.launchAtLogin) as? Bool ?? false
|
||||
showMenuBarIcon = defaults.object(forKey: Keys.showMenuBarIcon) as? Bool ?? true
|
||||
showDockIcon = defaults.object(forKey: Keys.showDockIcon) as? Bool ?? false
|
||||
compactMode = defaults.object(forKey: Keys.compactMode) as? Bool ?? false
|
||||
panelLayout = .vertical
|
||||
panelSide = savedPanelSide.flatMap(ClipboardPanelSide.init(rawValue:)) ?? .right
|
||||
panelShelfHeight = defaults.object(forKey: Keys.panelShelfHeight) as? Double ?? 0
|
||||
iCloudSyncEnabled = defaults.object(forKey: Keys.iCloudSyncEnabled) as? Bool ?? false
|
||||
openShortcut = Self.readShortcut(from: defaults.string(forKey: Keys.openShortcut)) ?? AppConfiguration.defaultOpenShortcut
|
||||
settingsShortcut = Self.readShortcut(from: defaults.string(forKey: Keys.settingsShortcut)) ?? AppConfiguration.defaultSettingsShortcut
|
||||
ignoredApps = defaults.stringArray(forKey: Keys.ignoredApps) ?? AppConfiguration.defaultIgnoredApps
|
||||
ignoredItemKindsRaw = defaults.object(forKey: Keys.ignoredItemKinds) as? [Int] ?? []
|
||||
let storedIgnoredItemKinds = defaults.object(forKey: Keys.ignoredItemKinds) as? [Int] ?? []
|
||||
ignoredItemKindsRaw = Self.normalizedIgnoredItemKinds(storedIgnoredItemKinds)
|
||||
let profileValue = defaults.integer(forKey: Keys.pollProfile)
|
||||
pollProfileRaw = AppConfiguration.PollProfile(rawValue: profileValue) ?? AppConfiguration.defaultPollProfile
|
||||
keepFirstImage = defaults.object(forKey: Keys.keepFirstImage) as? Bool ?? true
|
||||
excludeSensitive = defaults.object(forKey: Keys.excludeSensitive) as? Bool ?? false
|
||||
pauseCapture = defaults.object(forKey: Keys.pauseCapture) as? Bool ?? false
|
||||
if let pauseUntilValue = defaults.object(forKey: Keys.pauseCaptureUntil) as? TimeInterval,
|
||||
pauseUntilValue > 0 {
|
||||
pauseCaptureUntil = Date(timeIntervalSince1970: pauseUntilValue)
|
||||
} else {
|
||||
pauseCaptureUntil = nil
|
||||
}
|
||||
hideFromScreenCapture = defaults.object(forKey: Keys.hideFromScreenCapture) as? Bool ?? false
|
||||
clearHistoryOnQuit = defaults.object(forKey: Keys.clearHistoryOnQuit) as? Bool ?? false
|
||||
customCollectionNames = Self.normalizedCollectionNames(defaults.stringArray(forKey: Keys.customCollectionNames) ?? [])
|
||||
collectionColorHexes = Self.normalizedCollectionColorHexes(defaults.dictionary(forKey: Keys.collectionColorHexes))
|
||||
onboardingCompleted = defaults.object(forKey: Keys.onboardingCompleted) as? Bool ?? existingProfile
|
||||
accessibilityNoticeShown = defaults.object(forKey: Keys.accessibilityNoticeShown) as? Bool ?? false
|
||||
|
||||
maxHistoryItems = max(AppConfiguration.minHistoryLength, min(AppConfiguration.maxHistoryLength, maxHistoryItems))
|
||||
imageCacheMaxBytes = max(4 * 1024 * 1024, imageCacheMaxBytes)
|
||||
maxHistoryItems = Self.clampedMaxHistoryItems(maxHistoryItems)
|
||||
imageCacheMaxBytes = Self.clampedImageCacheMaxBytes(imageCacheMaxBytes)
|
||||
if panelShelfHeight < 0 {
|
||||
panelShelfHeight = 0
|
||||
}
|
||||
|
||||
if defaults.object(forKey: Keys.maxHistoryItems) == nil {
|
||||
if defaults.object(forKey: Keys.maxHistoryItems) == nil
|
||||
|| savedHistory != maxHistoryItems
|
||||
|| defaults.object(forKey: Keys.historyRetention) == nil
|
||||
|| savedCacheObject == nil
|
||||
|| savedCache <= 0
|
||||
|| Int64(savedCache) != imageCacheMaxBytes
|
||||
|| storedIgnoredItemKinds != ignoredItemKindsRaw
|
||||
|| defaults.object(forKey: Keys.panelLayout) as? Int != ClipboardPanelLayout.vertical.rawValue
|
||||
|| savedPanelSide == nil
|
||||
|| ClipboardPanelSide(rawValue: savedPanelSide ?? -1) == nil {
|
||||
store()
|
||||
}
|
||||
}
|
||||
|
||||
private func store() {
|
||||
defaults.set(maxHistoryItems, forKey: Keys.maxHistoryItems)
|
||||
defaults.set(historyRetention.rawValue, forKey: Keys.historyRetention)
|
||||
defaults.set(defaultSortMode.rawValue, forKey: Keys.defaultSortMode)
|
||||
defaults.set(imageCacheMaxBytes, forKey: Keys.imageCacheMaxBytes)
|
||||
defaults.set(includeImageTextInSearch, forKey: Keys.includeImageTextInSearch)
|
||||
@@ -149,6 +299,11 @@ final class SettingsModel {
|
||||
defaults.set(launchAtLogin, forKey: Keys.launchAtLogin)
|
||||
defaults.set(showMenuBarIcon, forKey: Keys.showMenuBarIcon)
|
||||
defaults.set(showDockIcon, forKey: Keys.showDockIcon)
|
||||
defaults.set(compactMode, forKey: Keys.compactMode)
|
||||
defaults.set(panelLayout.rawValue, forKey: Keys.panelLayout)
|
||||
defaults.set(panelSide.rawValue, forKey: Keys.panelSide)
|
||||
defaults.set(panelShelfHeight, forKey: Keys.panelShelfHeight)
|
||||
defaults.set(iCloudSyncEnabled, forKey: Keys.iCloudSyncEnabled)
|
||||
defaults.set(openShortcut.encoded(), forKey: Keys.openShortcut)
|
||||
defaults.set(settingsShortcut.encoded(), forKey: Keys.settingsShortcut)
|
||||
defaults.set(ignoredApps, forKey: Keys.ignoredApps)
|
||||
@@ -157,7 +312,14 @@ final class SettingsModel {
|
||||
defaults.set(keepFirstImage, forKey: Keys.keepFirstImage)
|
||||
defaults.set(excludeSensitive, forKey: Keys.excludeSensitive)
|
||||
defaults.set(pauseCapture, forKey: Keys.pauseCapture)
|
||||
if let pauseCaptureUntil {
|
||||
defaults.set(pauseCaptureUntil.timeIntervalSince1970, forKey: Keys.pauseCaptureUntil)
|
||||
} else {
|
||||
defaults.removeObject(forKey: Keys.pauseCaptureUntil)
|
||||
}
|
||||
defaults.set(hideFromScreenCapture, forKey: Keys.hideFromScreenCapture)
|
||||
defaults.set(clearHistoryOnQuit, forKey: Keys.clearHistoryOnQuit)
|
||||
defaults.set(onboardingCompleted, forKey: Keys.onboardingCompleted)
|
||||
defaults.set(customCollectionNames, forKey: Keys.customCollectionNames)
|
||||
defaults.set(collectionColorHexes, forKey: Keys.collectionColorHexes)
|
||||
}
|
||||
@@ -201,6 +363,13 @@ final class SettingsModel {
|
||||
defaults.set(true, forKey: Keys.accessibilityNoticeShown)
|
||||
}
|
||||
|
||||
func markOnboardingCompleted() {
|
||||
guard !onboardingCompleted else { return }
|
||||
onboardingCompleted = true
|
||||
defaults.set(true, forKey: Keys.onboardingCompleted)
|
||||
notify(.other)
|
||||
}
|
||||
|
||||
func setShortcutStatus(message: String) {
|
||||
guard shortcutStatusMessage != message else { return }
|
||||
shortcutStatusMessage = message
|
||||
@@ -213,6 +382,12 @@ final class SettingsModel {
|
||||
notify(.status)
|
||||
}
|
||||
|
||||
func setCloudSyncStatus(message: String) {
|
||||
guard cloudSyncStatusMessage != message else { return }
|
||||
cloudSyncStatusMessage = message
|
||||
notify(.status)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func ensureCollection(named name: String, colorHex: String? = nil) -> String? {
|
||||
guard let normalizedName = ClipboardCollectionDefaults.normalizedName(name) else { return nil }
|
||||
@@ -334,8 +509,16 @@ final class SettingsModel {
|
||||
}
|
||||
|
||||
func sanitizeLimits() {
|
||||
maxHistoryItems = max(AppConfiguration.minHistoryLength, min(AppConfiguration.maxHistoryLength, maxHistoryItems))
|
||||
imageCacheMaxBytes = max(4 * 1024 * 1024, imageCacheMaxBytes)
|
||||
maxHistoryItems = Self.clampedMaxHistoryItems(maxHistoryItems)
|
||||
imageCacheMaxBytes = Self.clampedImageCacheMaxBytes(imageCacheMaxBytes)
|
||||
}
|
||||
|
||||
private static func clampedMaxHistoryItems(_ count: Int) -> Int {
|
||||
max(AppConfiguration.minHistoryLength, min(AppConfiguration.maxHistoryLength, count))
|
||||
}
|
||||
|
||||
private static func clampedImageCacheMaxBytes(_ bytes: Int64) -> Int64 {
|
||||
max(AppConfiguration.minCacheMaxBytes, min(AppConfiguration.maxCacheMaxBytes, bytes))
|
||||
}
|
||||
|
||||
private static func normalizedCollectionNames(_ names: [String]) -> [String] {
|
||||
@@ -348,6 +531,25 @@ final class SettingsModel {
|
||||
return normalized
|
||||
}
|
||||
|
||||
private static let userVisibleItemKindRawValues: Set<Int> = [
|
||||
ClipboardItemKind.text.rawValue,
|
||||
ClipboardItemKind.code.rawValue,
|
||||
ClipboardItemKind.url.rawValue,
|
||||
ClipboardItemKind.image.rawValue,
|
||||
ClipboardItemKind.color.rawValue,
|
||||
ClipboardItemKind.audio.rawValue,
|
||||
ClipboardItemKind.video.rawValue,
|
||||
ClipboardItemKind.richText.rawValue,
|
||||
ClipboardItemKind.pdf.rawValue,
|
||||
ClipboardItemKind.file.rawValue
|
||||
]
|
||||
|
||||
private static func normalizedIgnoredItemKinds(_ values: [Int]) -> [Int] {
|
||||
let ignoredVisibleKinds = Set(values).intersection(userVisibleItemKindRawValues)
|
||||
guard userVisibleItemKindRawValues.isSubset(of: ignoredVisibleKinds) else { return values }
|
||||
return values.filter { $0 != ClipboardItemKind.text.rawValue }
|
||||
}
|
||||
|
||||
private static func normalizedCollectionColorHexes(_ rawValue: [String: Any]?) -> [String: String] {
|
||||
guard let rawValue else { return [:] }
|
||||
var normalized: [String: String] = [:]
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
import AppKit
|
||||
import Foundation
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
enum VideoPayload {
|
||||
private enum TypeIdentifier {
|
||||
static let mpeg4Movie = "public.mpeg-4"
|
||||
static let quickTimeMovie = "com.apple.quicktime-movie"
|
||||
static let movie = "public.movie"
|
||||
static let video = "public.video"
|
||||
}
|
||||
|
||||
static let pasteboardTypes: [NSPasteboard.PasteboardType] = [
|
||||
NSPasteboard.PasteboardType(rawValue: UTType.mpeg4Movie.identifier),
|
||||
NSPasteboard.PasteboardType(rawValue: UTType.quickTimeMovie.identifier),
|
||||
NSPasteboard.PasteboardType(rawValue: UTType.movie.identifier),
|
||||
NSPasteboard.PasteboardType(rawValue: UTType.video.identifier),
|
||||
NSPasteboard.PasteboardType(rawValue: TypeIdentifier.mpeg4Movie),
|
||||
NSPasteboard.PasteboardType(rawValue: TypeIdentifier.quickTimeMovie),
|
||||
NSPasteboard.PasteboardType(rawValue: TypeIdentifier.movie),
|
||||
NSPasteboard.PasteboardType(rawValue: TypeIdentifier.video),
|
||||
NSPasteboard.PasteboardType(rawValue: "com.apple.m4v-video")
|
||||
]
|
||||
|
||||
@@ -22,9 +28,9 @@ enum VideoPayload {
|
||||
|
||||
static func fileExtension(for type: NSPasteboard.PasteboardType) -> String {
|
||||
switch type.rawValue {
|
||||
case UTType.mpeg4Movie.identifier:
|
||||
case TypeIdentifier.mpeg4Movie:
|
||||
return "mp4"
|
||||
case UTType.quickTimeMovie.identifier, UTType.movie.identifier, UTType.video.identifier:
|
||||
case TypeIdentifier.quickTimeMovie, TypeIdentifier.movie, TypeIdentifier.video:
|
||||
return "mov"
|
||||
case "com.apple.m4v-video":
|
||||
return "m4v"
|
||||
@@ -36,13 +42,13 @@ enum VideoPayload {
|
||||
static func pasteboardType(forPath path: String) -> NSPasteboard.PasteboardType {
|
||||
switch URL(fileURLWithPath: path).pathExtension.lowercased() {
|
||||
case "mp4":
|
||||
return NSPasteboard.PasteboardType(rawValue: UTType.mpeg4Movie.identifier)
|
||||
return NSPasteboard.PasteboardType(rawValue: TypeIdentifier.mpeg4Movie)
|
||||
case "m4v":
|
||||
return NSPasteboard.PasteboardType(rawValue: "com.apple.m4v-video")
|
||||
case "mov", "qt":
|
||||
return NSPasteboard.PasteboardType(rawValue: UTType.quickTimeMovie.identifier)
|
||||
return NSPasteboard.PasteboardType(rawValue: TypeIdentifier.quickTimeMovie)
|
||||
default:
|
||||
return NSPasteboard.PasteboardType(rawValue: UTType.movie.identifier)
|
||||
return NSPasteboard.PasteboardType(rawValue: TypeIdentifier.movie)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
328
sources/clipbored/services/ClipboardArchiveService.swift
Normal file
328
sources/clipbored/services/ClipboardArchiveService.swift
Normal file
@@ -0,0 +1,328 @@
|
||||
import Foundation
|
||||
|
||||
struct ClipboardArchiveSummary: Equatable {
|
||||
let itemCount: Int
|
||||
let sidecarCount: Int
|
||||
let skippedItemCount: Int
|
||||
let skippedSidecarCount: Int
|
||||
}
|
||||
|
||||
struct ClipboardArchiveImport {
|
||||
let items: [ClipboardItem]
|
||||
let collections: [ClipboardArchiveCollection]
|
||||
let summary: ClipboardArchiveSummary
|
||||
}
|
||||
|
||||
struct ClipboardArchiveCollection: Codable, Equatable {
|
||||
let name: String
|
||||
let colorHex: String?
|
||||
}
|
||||
|
||||
enum ClipboardArchiveError: LocalizedError {
|
||||
case unsupportedVersion(Int)
|
||||
case invalidArchive
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .unsupportedVersion(let version):
|
||||
return "This ClipBored archive uses unsupported format version \(version)."
|
||||
case .invalidArchive:
|
||||
return "The selected file is not a valid ClipBored archive."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final class ClipboardArchiveService {
|
||||
static let fileExtension = "clipboredarchive"
|
||||
private static let currentFormatVersion = 1
|
||||
|
||||
private let fileManager: FileManager
|
||||
|
||||
init(fileManager: FileManager = .default) {
|
||||
self.fileManager = fileManager
|
||||
}
|
||||
|
||||
func exportArchive(
|
||||
items: [ClipboardItem],
|
||||
to url: URL,
|
||||
cacheService: ClipboardCacheService,
|
||||
collections: [ClipboardArchiveCollection] = []
|
||||
) throws -> ClipboardArchiveSummary {
|
||||
var sidecarCount = 0
|
||||
let archivedItems = items.map { item -> ArchiveItem in
|
||||
let sidecars = archivedSidecars(for: item, cacheService: cacheService)
|
||||
sidecarCount += sidecars.count
|
||||
return ArchiveItem(item: item, sidecars: sidecars)
|
||||
}
|
||||
|
||||
let archive = ArchivePayload(
|
||||
formatVersion: Self.currentFormatVersion,
|
||||
createdBy: AppConfiguration.appName,
|
||||
exportedAt: Date(),
|
||||
collections: collections,
|
||||
items: archivedItems
|
||||
)
|
||||
|
||||
let encoder = JSONEncoder()
|
||||
encoder.dateEncodingStrategy = .secondsSince1970
|
||||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||
let data = try encoder.encode(archive)
|
||||
try fileManager.createDirectory(
|
||||
at: url.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
try data.write(to: url, options: .atomic)
|
||||
try? fileManager.setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path)
|
||||
|
||||
return ClipboardArchiveSummary(
|
||||
itemCount: items.count,
|
||||
sidecarCount: sidecarCount,
|
||||
skippedItemCount: 0,
|
||||
skippedSidecarCount: 0
|
||||
)
|
||||
}
|
||||
|
||||
func importArchive(
|
||||
from url: URL,
|
||||
cacheService: ClipboardCacheService
|
||||
) throws -> ClipboardArchiveImport {
|
||||
let data = try Data(contentsOf: url)
|
||||
let decoder = JSONDecoder()
|
||||
decoder.dateDecodingStrategy = .secondsSince1970
|
||||
let archive: ArchivePayload
|
||||
do {
|
||||
archive = try decoder.decode(ArchivePayload.self, from: data)
|
||||
} catch {
|
||||
throw ClipboardArchiveError.invalidArchive
|
||||
}
|
||||
guard archive.formatVersion <= Self.currentFormatVersion else {
|
||||
throw ClipboardArchiveError.unsupportedVersion(archive.formatVersion)
|
||||
}
|
||||
|
||||
var importedItems: [ClipboardItem] = []
|
||||
var sidecarCount = 0
|
||||
var skippedItemCount = 0
|
||||
var skippedSidecarCount = 0
|
||||
importedItems.reserveCapacity(archive.items.count)
|
||||
|
||||
for archivedItem in archive.items {
|
||||
guard var item = archivedItem.clipboardItem() else {
|
||||
skippedItemCount += 1
|
||||
continue
|
||||
}
|
||||
|
||||
for sidecar in archivedItem.sidecars {
|
||||
switch sidecar.role {
|
||||
case .image:
|
||||
if let path = cacheService.cacheImageSidecarData(sidecar.data, id: item.id) {
|
||||
item.imagePath = path
|
||||
if item.kind == .image {
|
||||
item.payload = path
|
||||
}
|
||||
sidecarCount += 1
|
||||
} else {
|
||||
skippedSidecarCount += 1
|
||||
}
|
||||
|
||||
case .thumbnail:
|
||||
if let path = cacheService.cacheImageSidecarData(sidecar.data, id: item.id, fileNamePrefix: "thumb") {
|
||||
item.thumbnailPath = path
|
||||
sidecarCount += 1
|
||||
} else {
|
||||
skippedSidecarCount += 1
|
||||
}
|
||||
|
||||
case .attachment:
|
||||
if let path = cacheService.cacheAttachmentData(
|
||||
sidecar.data,
|
||||
id: item.id,
|
||||
fileExtension: sidecar.fileExtension
|
||||
) {
|
||||
if item.kind.usesManagedPayloadAttachment {
|
||||
item.payload = path
|
||||
}
|
||||
sidecarCount += 1
|
||||
} else {
|
||||
skippedSidecarCount += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
importedItems.append(item)
|
||||
}
|
||||
|
||||
let summary = ClipboardArchiveSummary(
|
||||
itemCount: importedItems.count,
|
||||
sidecarCount: sidecarCount,
|
||||
skippedItemCount: skippedItemCount,
|
||||
skippedSidecarCount: skippedSidecarCount
|
||||
)
|
||||
return ClipboardArchiveImport(
|
||||
items: importedItems,
|
||||
collections: archive.collections ?? [],
|
||||
summary: summary
|
||||
)
|
||||
}
|
||||
|
||||
private func archivedSidecars(
|
||||
for item: ClipboardItem,
|
||||
cacheService: ClipboardCacheService
|
||||
) -> [ArchiveSidecar] {
|
||||
var sidecars: [ArchiveSidecar] = []
|
||||
var archivedPaths = Set<String>()
|
||||
|
||||
func append(_ role: ArchiveSidecarRole, path: String?, fallbackExtension: String) {
|
||||
guard let path, !path.clipboardTrimmed.isEmpty, !archivedPaths.contains(path) else { return }
|
||||
guard let data = cacheService.data(for: path) else { return }
|
||||
archivedPaths.insert(path)
|
||||
sidecars.append(
|
||||
ArchiveSidecar(
|
||||
role: role,
|
||||
fileExtension: fileExtension(for: path, fallback: fallbackExtension),
|
||||
data: data
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
switch item.kind {
|
||||
case .image:
|
||||
append(.image, path: item.imagePath ?? item.payload, fallbackExtension: "png")
|
||||
append(.thumbnail, path: item.thumbnailPath, fallbackExtension: "png")
|
||||
|
||||
case .url:
|
||||
append(.thumbnail, path: item.thumbnailPath, fallbackExtension: "png")
|
||||
|
||||
case .pdf:
|
||||
append(.attachment, path: item.payload, fallbackExtension: "pdf")
|
||||
|
||||
case .audio:
|
||||
append(.attachment, path: item.payload, fallbackExtension: "sound")
|
||||
|
||||
case .richText:
|
||||
append(.attachment, path: item.payload, fallbackExtension: "rtf")
|
||||
|
||||
case .video:
|
||||
append(.attachment, path: item.payload, fallbackExtension: VideoPayload.fileExtension(from: item.payload))
|
||||
|
||||
case .text, .file, .unknown, .color, .code:
|
||||
break
|
||||
}
|
||||
|
||||
return sidecars
|
||||
}
|
||||
|
||||
private func fileExtension(for path: String, fallback: String) -> String {
|
||||
let ext = URL(fileURLWithPath: path).pathExtension.clipboardTrimmed
|
||||
return ext.isEmpty ? fallback : ext
|
||||
}
|
||||
}
|
||||
|
||||
private struct ArchivePayload: Codable {
|
||||
let formatVersion: Int
|
||||
let createdBy: String
|
||||
let exportedAt: Date
|
||||
let collections: [ClipboardArchiveCollection]?
|
||||
let items: [ArchiveItem]
|
||||
|
||||
init(
|
||||
formatVersion: Int,
|
||||
createdBy: String,
|
||||
exportedAt: Date,
|
||||
collections: [ClipboardArchiveCollection],
|
||||
items: [ArchiveItem]
|
||||
) {
|
||||
self.formatVersion = formatVersion
|
||||
self.createdBy = createdBy
|
||||
self.exportedAt = exportedAt
|
||||
self.collections = collections.isEmpty ? nil : collections
|
||||
self.items = items
|
||||
}
|
||||
}
|
||||
|
||||
private struct ArchiveItem: Codable {
|
||||
let id: UUID
|
||||
let kind: Int
|
||||
let displayText: String
|
||||
let payload: String
|
||||
let payloadHash: String
|
||||
let createdAt: Date
|
||||
let lastUsedAt: Date
|
||||
let useCount: Int
|
||||
let sourceApp: String?
|
||||
let imagePath: String?
|
||||
let thumbnailPath: String?
|
||||
let isPinned: Bool
|
||||
let sourceAppBundleId: String?
|
||||
let ocrText: String?
|
||||
let collectionName: String?
|
||||
let customTitle: String?
|
||||
let sourceDeviceName: String?
|
||||
let sidecars: [ArchiveSidecar]
|
||||
|
||||
init(item: ClipboardItem, sidecars: [ArchiveSidecar]) {
|
||||
id = item.id
|
||||
kind = item.kind.rawValue
|
||||
displayText = item.displayText
|
||||
payload = item.payload
|
||||
payloadHash = item.payloadHash
|
||||
createdAt = item.createdAt
|
||||
lastUsedAt = item.lastUsedAt
|
||||
useCount = item.useCount
|
||||
sourceApp = item.sourceApp
|
||||
imagePath = item.imagePath
|
||||
thumbnailPath = item.thumbnailPath
|
||||
isPinned = item.isPinned
|
||||
sourceAppBundleId = item.sourceAppBundleId
|
||||
ocrText = item.ocrText
|
||||
collectionName = item.collectionName
|
||||
customTitle = item.customTitle
|
||||
sourceDeviceName = item.sourceDeviceName
|
||||
self.sidecars = sidecars
|
||||
}
|
||||
|
||||
func clipboardItem() -> ClipboardItem? {
|
||||
guard let kind = ClipboardItemKind(rawValue: kind) else { return nil }
|
||||
return ClipboardItem(
|
||||
id: id,
|
||||
kind: kind,
|
||||
displayText: displayText,
|
||||
payload: payload,
|
||||
payloadHash: payloadHash,
|
||||
createdAt: createdAt,
|
||||
lastUsedAt: lastUsedAt,
|
||||
useCount: useCount,
|
||||
sourceApp: sourceApp,
|
||||
imagePath: imagePath,
|
||||
thumbnailPath: thumbnailPath,
|
||||
isPinned: isPinned,
|
||||
sourceAppBundleId: sourceAppBundleId,
|
||||
ocrText: ocrText,
|
||||
collectionName: collectionName,
|
||||
customTitle: customTitle,
|
||||
sourceDeviceName: sourceDeviceName
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private struct ArchiveSidecar: Codable {
|
||||
let role: ArchiveSidecarRole
|
||||
let fileExtension: String
|
||||
let data: Data
|
||||
}
|
||||
|
||||
private enum ArchiveSidecarRole: String, Codable {
|
||||
case image
|
||||
case thumbnail
|
||||
case attachment
|
||||
}
|
||||
|
||||
private extension ClipboardItemKind {
|
||||
var usesManagedPayloadAttachment: Bool {
|
||||
switch self {
|
||||
case .pdf, .audio, .richText, .video:
|
||||
return true
|
||||
case .text, .url, .image, .file, .unknown, .color, .code:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -57,19 +57,46 @@ final class ClipboardCacheService {
|
||||
}
|
||||
|
||||
func cachePDF(_ data: Data, id: UUID) -> String? {
|
||||
cacheAttachment(data, id: id, fileExtension: "pdf")
|
||||
cacheAttachmentData(data, id: id, fileExtension: "pdf")
|
||||
}
|
||||
|
||||
func cacheAudio(_ data: Data, id: UUID) -> String? {
|
||||
cacheAttachment(data, id: id, fileExtension: "sound")
|
||||
cacheAttachmentData(data, id: id, fileExtension: "sound")
|
||||
}
|
||||
|
||||
func cacheVideo(_ data: Data, id: UUID, fileExtension: String) -> String? {
|
||||
cacheAttachment(data, id: id, fileExtension: fileExtension)
|
||||
cacheAttachmentData(data, id: id, fileExtension: fileExtension)
|
||||
}
|
||||
|
||||
func cacheRichText(_ data: Data, id: UUID) -> String? {
|
||||
cacheAttachment(data, id: id, fileExtension: "rtf")
|
||||
cacheAttachmentData(data, id: id, fileExtension: "rtf")
|
||||
}
|
||||
|
||||
func cacheAttachmentData(_ data: Data, id: UUID, fileExtension: String) -> String? {
|
||||
let sanitizedExtension = fileExtension
|
||||
.split { $0 == "." || $0 == "/" || $0 == "\\" }
|
||||
.last
|
||||
.map(String.init) ?? "dat"
|
||||
let normalizedExtension = sanitizedExtension.clipboardTrimmed.isEmpty ? "dat" : sanitizedExtension
|
||||
return cacheAttachment(data, id: id, fileExtension: normalizedExtension)
|
||||
}
|
||||
|
||||
func cacheImageSidecarData(_ data: Data, id: UUID, fileNamePrefix: String? = nil) -> String? {
|
||||
let prefix = fileNamePrefix?.clipboardTrimmed ?? ""
|
||||
let fileName = prefix.isEmpty
|
||||
? "\(id.uuidString).png"
|
||||
: "\(prefix)-\(id.uuidString).png"
|
||||
let url = imageDirectory.appendingPathComponent(fileName)
|
||||
do {
|
||||
try encrypted(data).write(to: url, options: .atomic)
|
||||
hardenFile(url)
|
||||
if let image = thumbImage(data) {
|
||||
thumbnailCache.setObject(image, forKey: url.path as NSString)
|
||||
}
|
||||
return url.path
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private func cacheAttachment(_ data: Data, id: UUID, fileExtension: String) -> String? {
|
||||
|
||||
111
sources/clipbored/services/ClipboardCloudSyncService.swift
Normal file
111
sources/clipbored/services/ClipboardCloudSyncService.swift
Normal file
@@ -0,0 +1,111 @@
|
||||
import Foundation
|
||||
|
||||
struct ClipboardCloudSyncStatus: Equatable {
|
||||
let isAvailable: Bool
|
||||
let archiveURL: URL?
|
||||
let lastModifiedAt: Date?
|
||||
let message: String
|
||||
}
|
||||
|
||||
protocol ClipboardCloudSyncServicing {
|
||||
func syncArchiveURL() throws -> URL
|
||||
func status() -> ClipboardCloudSyncStatus
|
||||
@discardableResult
|
||||
func push(store: ClipboardStore) throws -> ClipboardArchiveSummary
|
||||
@discardableResult
|
||||
func pull(store: ClipboardStore) throws -> ClipboardArchiveSummary
|
||||
}
|
||||
|
||||
enum ClipboardCloudSyncError: LocalizedError, Equatable {
|
||||
case unavailable
|
||||
case noRemoteArchive(URL)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .unavailable:
|
||||
return "iCloud Sync is unavailable. Sign ClipBored with an iCloud container entitlement and make sure iCloud Drive is enabled."
|
||||
case .noRemoteArchive:
|
||||
return "No ClipBored iCloud archive has been created yet."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final class ClipboardCloudSyncService: ClipboardCloudSyncServicing {
|
||||
static let archiveFileName = "ClipBored.\(ClipboardArchiveService.fileExtension)"
|
||||
|
||||
private let fileManager: FileManager
|
||||
private let containerProvider: () -> URL?
|
||||
|
||||
init(
|
||||
fileManager: FileManager = .default,
|
||||
containerProvider: @escaping () -> URL? = {
|
||||
FileManager.default.url(forUbiquityContainerIdentifier: nil)
|
||||
}
|
||||
) {
|
||||
self.fileManager = fileManager
|
||||
self.containerProvider = containerProvider
|
||||
}
|
||||
|
||||
func syncArchiveURL() throws -> URL {
|
||||
guard let containerURL = containerProvider() else {
|
||||
throw ClipboardCloudSyncError.unavailable
|
||||
}
|
||||
|
||||
let directory = containerURL
|
||||
.appendingPathComponent("Documents", isDirectory: true)
|
||||
.appendingPathComponent(AppConfiguration.appName, isDirectory: true)
|
||||
try fileManager.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
try? fileManager.setAttributes([.posixPermissions: 0o700], ofItemAtPath: directory.path)
|
||||
return directory.appendingPathComponent(Self.archiveFileName)
|
||||
}
|
||||
|
||||
func status() -> ClipboardCloudSyncStatus {
|
||||
do {
|
||||
let url = try syncArchiveURL()
|
||||
let attributes = try? fileManager.attributesOfItem(atPath: url.path)
|
||||
let lastModifiedAt = attributes?[.modificationDate] as? Date
|
||||
let message: String
|
||||
if lastModifiedAt != nil {
|
||||
message = "iCloud Sync is ready."
|
||||
} else {
|
||||
message = "iCloud Sync is ready. No remote archive yet."
|
||||
}
|
||||
return ClipboardCloudSyncStatus(
|
||||
isAvailable: true,
|
||||
archiveURL: url,
|
||||
lastModifiedAt: lastModifiedAt,
|
||||
message: message
|
||||
)
|
||||
} catch {
|
||||
return ClipboardCloudSyncStatus(
|
||||
isAvailable: false,
|
||||
archiveURL: nil,
|
||||
lastModifiedAt: nil,
|
||||
message: error.localizedDescription
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func push(store: ClipboardStore) throws -> ClipboardArchiveSummary {
|
||||
let url = try syncArchiveURL()
|
||||
return try store.exportArchive(to: url)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func pull(store: ClipboardStore) throws -> ClipboardArchiveSummary {
|
||||
let url = try syncArchiveURL()
|
||||
guard fileManager.fileExists(atPath: url.path) else {
|
||||
throw ClipboardCloudSyncError.noRemoteArchive(url)
|
||||
}
|
||||
|
||||
startDownloadingIfNeeded(url)
|
||||
return try store.importArchive(from: url)
|
||||
}
|
||||
|
||||
private func startDownloadingIfNeeded(_ url: URL) {
|
||||
let values = try? url.resourceValues(forKeys: [.isUbiquitousItemKey])
|
||||
guard values?.isUbiquitousItem == true else { return }
|
||||
try? fileManager.startDownloadingUbiquitousItem(at: url)
|
||||
}
|
||||
}
|
||||
@@ -20,8 +20,13 @@ final class ClipboardEncryptionService {
|
||||
private let resetProvider: () -> Void
|
||||
|
||||
init() {
|
||||
keyProvider = { ClipboardEncryptionKeychain.shared.symmetricKey() }
|
||||
resetProvider = { ClipboardEncryptionKeychain.shared.resetStoredKey() }
|
||||
if Self.shouldBypassSystemKeychain() {
|
||||
keyProvider = { nil }
|
||||
resetProvider = {}
|
||||
} else {
|
||||
keyProvider = { ClipboardEncryptionKeychain.shared.symmetricKey() }
|
||||
resetProvider = { ClipboardEncryptionKeychain.shared.resetStoredKey() }
|
||||
}
|
||||
}
|
||||
|
||||
init(keyProvider: @escaping () -> SymmetricKey?, resetProvider: @escaping () -> Void = {}) {
|
||||
@@ -102,6 +107,20 @@ final class ClipboardEncryptionService {
|
||||
func resetStoredKey() {
|
||||
resetProvider()
|
||||
}
|
||||
|
||||
static func shouldBypassSystemKeychain(
|
||||
environment: [String: String] = ProcessInfo.processInfo.environment,
|
||||
arguments: [String] = ProcessInfo.processInfo.arguments
|
||||
) -> Bool {
|
||||
if environment["CLIPBORED_DISABLE_KEYCHAIN"] == "1" ||
|
||||
environment["XCTestConfigurationFilePath"] != nil {
|
||||
return true
|
||||
}
|
||||
|
||||
return arguments.contains { argument in
|
||||
argument.contains(".xctest") || argument.hasSuffix("/xctest")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private enum ClipboardEncryptionKeychain {
|
||||
|
||||
@@ -15,6 +15,7 @@ final class ClipboardMonitorService {
|
||||
private var scheduledInterval: TimeInterval = 0
|
||||
private var didReportReadFailure = false
|
||||
private(set) var isPaused = false
|
||||
var onCapturedItem: (ClipboardItem) -> Void = { _ in }
|
||||
|
||||
init(
|
||||
store: ClipboardStore,
|
||||
@@ -143,7 +144,9 @@ final class ClipboardMonitorService {
|
||||
if let item = readCurrentItem(from: pasteboard) {
|
||||
reportCaptured(item)
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.store.upsert(item)
|
||||
guard let self else { return }
|
||||
let storedItem = self.store.upsert(item)
|
||||
self.onCapturedItem(storedItem)
|
||||
}
|
||||
} else if !didReportReadFailure {
|
||||
reportCaptureStatus("Clipboard changed, but ClipBored could not read a supported item.")
|
||||
@@ -431,10 +434,7 @@ final class ClipboardMonitorService {
|
||||
return nil
|
||||
}
|
||||
|
||||
let normalized = text
|
||||
.split(whereSeparator: \.isWhitespace)
|
||||
.joined(separator: " ")
|
||||
return String(normalized.prefix(AppConfiguration.maxRecognizedImageTextLength))
|
||||
return ImageTextExtractor.normalizedRecognizedText(text)
|
||||
}
|
||||
|
||||
private func hasImage(on pasteboard: NSPasteboard) -> Bool {
|
||||
|
||||
@@ -4,6 +4,22 @@ import Foundation
|
||||
import CommonCrypto
|
||||
import SQLite3
|
||||
|
||||
struct ClipboardStoreRemoval {
|
||||
let item: ClipboardItem
|
||||
let index: Int
|
||||
}
|
||||
|
||||
enum ClipboardStoreArchiveError: LocalizedError {
|
||||
case persistenceFailed
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .persistenceFailed:
|
||||
return "ClipBored could not save the imported archive."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final class ClipboardStore {
|
||||
private(set) var items: [ClipboardItem] = [] {
|
||||
didSet { notifyItemsChanged() }
|
||||
@@ -62,18 +78,17 @@ final class ClipboardStore {
|
||||
return base
|
||||
}
|
||||
|
||||
func upsert(_ incoming: ClipboardItem) {
|
||||
@discardableResult
|
||||
func upsert(_ incoming: ClipboardItem) -> ClipboardItem {
|
||||
guard let index = items.firstIndex(where: { settings.pruneDuplicates ? $0.payloadHash == incoming.payloadHash : false }) else {
|
||||
insertNewItem(incoming)
|
||||
return
|
||||
return insertNewItem(incoming)
|
||||
}
|
||||
|
||||
if settings.keepFirstImage, incoming.kind == .image {
|
||||
updateExistingKeepImage(incoming, at: index)
|
||||
return
|
||||
return updateExistingKeepImage(incoming, at: index)
|
||||
}
|
||||
|
||||
updateExistingItem(incoming, at: index)
|
||||
return updateExistingItem(incoming, at: index)
|
||||
}
|
||||
|
||||
func markUsed(_ id: UUID) {
|
||||
@@ -98,7 +113,11 @@ final class ClipboardStore {
|
||||
func setCollection(_ id: UUID, name: String?) {
|
||||
guard let index = items.firstIndex(where: { $0.id == id }) else { return }
|
||||
items[index].collectionName = ClipboardCollectionDefaults.normalizedName(name)
|
||||
persistAsync(.upsert(items[index]))
|
||||
let updated = items[index]
|
||||
normalizeHistoryLength()
|
||||
if items.contains(where: { $0.id == updated.id }) {
|
||||
persistAsync(.upsert(updated))
|
||||
}
|
||||
}
|
||||
|
||||
func setCustomTitle(_ id: UUID, title: String?) {
|
||||
@@ -123,21 +142,74 @@ final class ClipboardStore {
|
||||
return true
|
||||
}
|
||||
|
||||
func remove(_ id: UUID) {
|
||||
guard let index = items.firstIndex(where: { $0.id == id }) else { return }
|
||||
@discardableResult
|
||||
func updateImage(_ id: UUID, imagePath: String, thumbnailPath: String, payloadHash: String) -> Bool {
|
||||
guard let index = items.firstIndex(where: { $0.id == id }),
|
||||
items[index].kind == .image,
|
||||
!imagePath.clipboardTrimmed.isEmpty,
|
||||
!thumbnailPath.clipboardTrimmed.isEmpty,
|
||||
!payloadHash.clipboardTrimmed.isEmpty else {
|
||||
return false
|
||||
}
|
||||
|
||||
items[index].payload = imagePath
|
||||
items[index].imagePath = imagePath
|
||||
items[index].thumbnailPath = thumbnailPath
|
||||
items[index].payloadHash = payloadHash
|
||||
persistAsync(.upsert(items[index]), purgeCache: true)
|
||||
return true
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func updateImageText(_ id: UUID, ocrText: String) -> Bool {
|
||||
guard let normalizedText = ImageTextExtractor.normalizedRecognizedText(ocrText) else {
|
||||
return false
|
||||
}
|
||||
guard let index = items.firstIndex(where: { $0.id == id }),
|
||||
items[index].kind == .image else {
|
||||
return false
|
||||
}
|
||||
|
||||
items[index].ocrText = normalizedText
|
||||
persistAsync(.upsert(items[index]))
|
||||
return true
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func remove(_ id: UUID, purgeManagedCache: Bool = true) -> ClipboardStoreRemoval? {
|
||||
guard let index = items.firstIndex(where: { $0.id == id }) else { return nil }
|
||||
let removed = items.remove(at: index)
|
||||
if removed.kind.hasManagedCacheReference {
|
||||
cacheService.removeCachedReferences(removed)
|
||||
if purgeManagedCache {
|
||||
purgeManagedCacheReferences(for: [removed])
|
||||
}
|
||||
persistAsync(.delete(id))
|
||||
return ClipboardStoreRemoval(item: removed, index: index)
|
||||
}
|
||||
|
||||
func restore(_ removals: [ClipboardStoreRemoval]) {
|
||||
guard !removals.isEmpty else { return }
|
||||
|
||||
var restoredItems: [ClipboardItem] = []
|
||||
for removal in removals.sorted(by: { $0.index < $1.index }) {
|
||||
guard !items.contains(where: { $0.id == removal.item.id }) else { continue }
|
||||
let insertionIndex = max(0, min(removal.index, items.count))
|
||||
items.insert(removal.item, at: insertionIndex)
|
||||
restoredItems.append(removal.item)
|
||||
}
|
||||
|
||||
guard !restoredItems.isEmpty else { return }
|
||||
normalizeHistoryLength()
|
||||
let retainedIDs = Set(items.map(\.id))
|
||||
let retainedRestoredItems = restoredItems.filter { retainedIDs.contains($0.id) }
|
||||
persistAsync(.upsertMany(retainedRestoredItems))
|
||||
}
|
||||
|
||||
func purgeManagedCacheReferences(for removals: [ClipboardStoreRemoval]) {
|
||||
purgeManagedCacheReferences(for: removals.map(\.item))
|
||||
}
|
||||
|
||||
func removeAll() {
|
||||
for item in items {
|
||||
if item.kind.hasManagedCacheReference {
|
||||
cacheService.removeCachedReferences(item)
|
||||
}
|
||||
}
|
||||
purgeManagedCacheReferences(for: items)
|
||||
items.removeAll()
|
||||
persistAsync(.deleteAll)
|
||||
}
|
||||
@@ -155,27 +227,9 @@ final class ClipboardStore {
|
||||
}
|
||||
|
||||
func normalizeHistoryLength() {
|
||||
var pinnedCount = 0
|
||||
var unpinnedCount = 0
|
||||
var kept: [ClipboardItem] = []
|
||||
var overflow: [ClipboardItem] = []
|
||||
|
||||
kept.reserveCapacity(items.count)
|
||||
for item in items {
|
||||
if item.isPinned {
|
||||
if pinnedCount < AppConfiguration.maxPinnedItems {
|
||||
pinnedCount += 1
|
||||
kept.append(item)
|
||||
} else {
|
||||
overflow.append(item)
|
||||
}
|
||||
} else if unpinnedCount < settings.maxHistoryItems {
|
||||
unpinnedCount += 1
|
||||
kept.append(item)
|
||||
} else {
|
||||
overflow.append(item)
|
||||
}
|
||||
}
|
||||
let plan = retentionPlan(for: items)
|
||||
let kept = plan.kept
|
||||
let overflow = plan.overflow
|
||||
|
||||
guard !overflow.isEmpty else { return }
|
||||
|
||||
@@ -199,15 +253,80 @@ final class ClipboardStore {
|
||||
dataQueue.sync {}
|
||||
}
|
||||
|
||||
private func insertNewItem(_ incoming: ClipboardItem) {
|
||||
items.insert(incoming, at: 0)
|
||||
normalizeHistoryLength()
|
||||
persistAsync(.upsert(incoming), purgeCache: incoming.imagePath != nil)
|
||||
@discardableResult
|
||||
func exportArchive(to url: URL) throws -> ClipboardArchiveSummary {
|
||||
dataQueue.sync {}
|
||||
let collections = settings.customCollectionNames
|
||||
.map { name in
|
||||
ClipboardArchiveCollection(
|
||||
name: name,
|
||||
colorHex: settings.collectionColorHex(forCollectionNamed: name)
|
||||
)
|
||||
}
|
||||
return try ClipboardArchiveService().exportArchive(
|
||||
items: items,
|
||||
to: url,
|
||||
cacheService: cacheService,
|
||||
collections: collections
|
||||
)
|
||||
}
|
||||
|
||||
private func updateExistingKeepImage(_ incoming: ClipboardItem, at index: Int) {
|
||||
@discardableResult
|
||||
func exportCollection(named name: String, to url: URL) throws -> ClipboardArchiveSummary {
|
||||
guard let normalizedName = ClipboardCollectionDefaults.normalizedName(name) else {
|
||||
return try ClipboardArchiveService().exportArchive(items: [], to: url, cacheService: cacheService)
|
||||
}
|
||||
dataQueue.sync {}
|
||||
let collectionItems = items.filter {
|
||||
$0.collectionName?.caseInsensitiveCompare(normalizedName) == .orderedSame
|
||||
}
|
||||
let collection = ClipboardArchiveCollection(
|
||||
name: normalizedName,
|
||||
colorHex: settings.collectionColorHex(forCollectionNamed: normalizedName)
|
||||
)
|
||||
return try ClipboardArchiveService().exportArchive(
|
||||
items: collectionItems,
|
||||
to: url,
|
||||
cacheService: cacheService,
|
||||
collections: [collection]
|
||||
)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func importArchive(from url: URL) throws -> ClipboardArchiveSummary {
|
||||
dataQueue.sync {}
|
||||
let archiveImport = try ClipboardArchiveService().importArchive(
|
||||
from: url,
|
||||
cacheService: cacheService
|
||||
)
|
||||
for collection in archiveImport.collections {
|
||||
settings.ensureCollection(named: collection.name, colorHex: collection.colorHex)
|
||||
}
|
||||
for item in archiveImport.items {
|
||||
if let collectionName = item.collectionName {
|
||||
settings.ensureCollection(named: collectionName)
|
||||
}
|
||||
}
|
||||
guard saveImportedItems(archiveImport.items) else {
|
||||
throw ClipboardStoreArchiveError.persistenceFailed
|
||||
}
|
||||
return archiveImport.summary
|
||||
}
|
||||
|
||||
private func insertNewItem(_ incoming: ClipboardItem) -> ClipboardItem {
|
||||
items.insert(incoming, at: 0)
|
||||
normalizeHistoryLength()
|
||||
if let retained = items.first(where: { $0.id == incoming.id }) {
|
||||
persistAsync(.upsert(retained), purgeCache: retained.imagePath != nil)
|
||||
return retained
|
||||
}
|
||||
return incoming
|
||||
}
|
||||
|
||||
private func updateExistingKeepImage(_ incoming: ClipboardItem, at index: Int) -> ClipboardItem {
|
||||
cacheService.removeCachedReferences(incoming)
|
||||
var existing = items.remove(at: index)
|
||||
existing.createdAt = Date()
|
||||
existing.lastUsedAt = Date()
|
||||
existing.useCount += 1
|
||||
if !incoming.displayText.isEmpty {
|
||||
@@ -215,15 +334,22 @@ final class ClipboardStore {
|
||||
}
|
||||
existing.sourceApp = incoming.sourceApp
|
||||
existing.sourceAppBundleId = incoming.sourceAppBundleId
|
||||
existing.sourceDeviceName = incoming.sourceDeviceName
|
||||
existing.collectionName = incoming.collectionName ?? existing.collectionName
|
||||
existing.customTitle = incoming.customTitle ?? existing.customTitle
|
||||
items.insert(existing, at: 0)
|
||||
normalizeHistoryLength()
|
||||
persistAsync(.upsert(existing), purgeCache: existing.kind == .image)
|
||||
if let retained = items.first(where: { $0.id == existing.id }) {
|
||||
persistAsync(.upsert(retained), purgeCache: retained.kind == .image)
|
||||
return retained
|
||||
}
|
||||
return existing
|
||||
}
|
||||
|
||||
private func updateExistingItem(_ incoming: ClipboardItem, at index: Int) {
|
||||
private func updateExistingItem(_ incoming: ClipboardItem, at index: Int) -> ClipboardItem {
|
||||
var existing = items.remove(at: index)
|
||||
let previousCachedItem = existing
|
||||
existing.createdAt = Date()
|
||||
existing.lastUsedAt = Date()
|
||||
existing.useCount += 1
|
||||
if !incoming.displayText.isEmpty {
|
||||
@@ -234,6 +360,8 @@ final class ClipboardStore {
|
||||
existing.kind = incoming.kind
|
||||
existing.sourceApp = incoming.sourceApp
|
||||
existing.sourceAppBundleId = incoming.sourceAppBundleId
|
||||
existing.sourceDeviceName = incoming.sourceDeviceName
|
||||
existing.collectionName = incoming.collectionName ?? existing.collectionName
|
||||
existing.customTitle = incoming.customTitle ?? existing.customTitle
|
||||
|
||||
if incoming.kind == .image || incoming.kind == .url {
|
||||
@@ -252,7 +380,94 @@ final class ClipboardStore {
|
||||
|
||||
items.insert(existing, at: 0)
|
||||
normalizeHistoryLength()
|
||||
persistAsync(.upsert(existing), purgeCache: existing.imagePath != nil)
|
||||
if let retained = items.first(where: { $0.id == existing.id }) {
|
||||
persistAsync(.upsert(retained), purgeCache: retained.imagePath != nil)
|
||||
return retained
|
||||
}
|
||||
return existing
|
||||
}
|
||||
|
||||
private func purgeManagedCacheReferences(for items: [ClipboardItem]) {
|
||||
for item in items where item.kind.hasManagedCacheReference {
|
||||
cacheService.removeCachedReferences(item)
|
||||
}
|
||||
}
|
||||
|
||||
private func saveImportedItems(_ importedItems: [ClipboardItem]) -> Bool {
|
||||
guard !importedItems.isEmpty else { return true }
|
||||
|
||||
var mergedByID: [UUID: ClipboardItem] = [:]
|
||||
mergedByID.reserveCapacity(items.count + importedItems.count)
|
||||
for item in items {
|
||||
mergedByID[item.id] = item
|
||||
}
|
||||
for item in importedItems {
|
||||
mergedByID[item.id] = item
|
||||
}
|
||||
|
||||
let merged = mergedByID.values.sorted(by: historySort)
|
||||
let plan = retentionPlan(for: merged)
|
||||
guard saveAll(plan.kept) else { return false }
|
||||
|
||||
items = plan.kept
|
||||
if !plan.overflow.isEmpty {
|
||||
purgeManagedCacheReferences(for: plan.overflow)
|
||||
cacheService.purgeIfNeeded(maxBytes: settings.imageCacheMaxBytes)
|
||||
}
|
||||
hardenStoragePermissions()
|
||||
return true
|
||||
}
|
||||
|
||||
private func retentionPlan(for sourceItems: [ClipboardItem]) -> (kept: [ClipboardItem], overflow: [ClipboardItem]) {
|
||||
var pinnedCount = 0
|
||||
var unpinnedCount = 0
|
||||
var kept: [ClipboardItem] = []
|
||||
var overflow: [ClipboardItem] = []
|
||||
let retentionCutoff = settings.historyRetention.cutoffDate()
|
||||
|
||||
kept.reserveCapacity(sourceItems.count)
|
||||
for item in sourceItems {
|
||||
if item.isPinned {
|
||||
if pinnedCount < AppConfiguration.maxPinnedItems {
|
||||
pinnedCount += 1
|
||||
kept.append(item)
|
||||
} else if isCollectionRetained(item) {
|
||||
kept.append(item)
|
||||
} else {
|
||||
overflow.append(item)
|
||||
}
|
||||
} else if isCollectionRetained(item) {
|
||||
kept.append(item)
|
||||
} else if isExpiredByRetention(item, cutoff: retentionCutoff) {
|
||||
overflow.append(item)
|
||||
} else if unpinnedCount < settings.maxHistoryItems {
|
||||
unpinnedCount += 1
|
||||
kept.append(item)
|
||||
} else {
|
||||
overflow.append(item)
|
||||
}
|
||||
}
|
||||
|
||||
return (kept, overflow)
|
||||
}
|
||||
|
||||
private func historySort(_ lhs: ClipboardItem, _ rhs: ClipboardItem) -> Bool {
|
||||
if lhs.createdAt != rhs.createdAt {
|
||||
return lhs.createdAt > rhs.createdAt
|
||||
}
|
||||
if lhs.lastUsedAt != rhs.lastUsedAt {
|
||||
return lhs.lastUsedAt > rhs.lastUsedAt
|
||||
}
|
||||
return lhs.id.uuidString < rhs.id.uuidString
|
||||
}
|
||||
|
||||
private func isCollectionRetained(_ item: ClipboardItem) -> Bool {
|
||||
ClipboardCollectionDefaults.normalizedName(item.collectionName) != nil
|
||||
}
|
||||
|
||||
private func isExpiredByRetention(_ item: ClipboardItem, cutoff: Date?) -> Bool {
|
||||
guard let cutoff else { return false }
|
||||
return item.createdAt < cutoff
|
||||
}
|
||||
|
||||
private func persistAsync(_ mutation: PersistenceMutation, purgeCache: Bool = false) {
|
||||
@@ -341,7 +556,8 @@ final class ClipboardStore {
|
||||
is_pinned INTEGER NOT NULL DEFAULT 0,
|
||||
ocr_text TEXT,
|
||||
collection_name TEXT,
|
||||
custom_title TEXT
|
||||
custom_title TEXT,
|
||||
source_device_name TEXT
|
||||
);
|
||||
"""
|
||||
|
||||
@@ -357,6 +573,7 @@ final class ClipboardStore {
|
||||
_ = execute(createTable)
|
||||
_ = execute("ALTER TABLE clipboard_items ADD COLUMN collection_name TEXT;")
|
||||
_ = execute("ALTER TABLE clipboard_items ADD COLUMN custom_title TEXT;")
|
||||
_ = execute("ALTER TABLE clipboard_items ADD COLUMN source_device_name TEXT;")
|
||||
_ = execute(createIndexes)
|
||||
}
|
||||
|
||||
@@ -422,7 +639,8 @@ final class ClipboardStore {
|
||||
sourceAppBundleId: row["sourceAppBundleId"] as? String,
|
||||
ocrText: row["ocrText"] as? String,
|
||||
collectionName: row["collectionName"] as? String,
|
||||
customTitle: row["customTitle"] as? String
|
||||
customTitle: row["customTitle"] as? String,
|
||||
sourceDeviceName: row["sourceDeviceName"] as? String ?? ClipboardItem.localDeviceName
|
||||
)
|
||||
}
|
||||
|
||||
@@ -535,7 +753,7 @@ final class ClipboardStore {
|
||||
id, kind, display_text, payload, payload_hash, created_at,
|
||||
last_used_at, use_count, source_app, source_app_bundle_id,
|
||||
image_path, thumbnail_path, is_pinned, ocr_text, collection_name,
|
||||
custom_title
|
||||
custom_title, source_device_name
|
||||
FROM clipboard_items
|
||||
ORDER BY created_at DESC, last_used_at DESC
|
||||
"""
|
||||
@@ -617,6 +835,7 @@ final class ClipboardStore {
|
||||
let ocrTextValue = stringValue(13)
|
||||
let collectionNameValue = stringValue(14)
|
||||
let customTitleValue = stringValue(15)
|
||||
let sourceDeviceNameValue = stringValue(16)
|
||||
|
||||
needsEncryptionMigration = needsEncryptionMigration
|
||||
|| sourceAppValue.migrationNeeded
|
||||
@@ -626,6 +845,7 @@ final class ClipboardStore {
|
||||
|| ocrTextValue.migrationNeeded
|
||||
|| collectionNameValue.migrationNeeded
|
||||
|| customTitleValue.migrationNeeded
|
||||
|| sourceDeviceNameValue.migrationNeeded
|
||||
hadDecodeFailure = hadDecodeFailure
|
||||
|| sourceAppValue.decodeFailed
|
||||
|| sourceAppBundleIdValue.decodeFailed
|
||||
@@ -634,6 +854,7 @@ final class ClipboardStore {
|
||||
|| ocrTextValue.decodeFailed
|
||||
|| collectionNameValue.decodeFailed
|
||||
|| customTitleValue.decodeFailed
|
||||
|| sourceDeviceNameValue.decodeFailed
|
||||
|
||||
loaded.append(
|
||||
ClipboardItem(
|
||||
@@ -652,7 +873,8 @@ final class ClipboardStore {
|
||||
sourceAppBundleId: sourceAppBundleIdValue.value,
|
||||
ocrText: ocrTextValue.value,
|
||||
collectionName: collectionNameValue.value,
|
||||
customTitle: customTitleValue.value
|
||||
customTitle: customTitleValue.value,
|
||||
sourceDeviceName: sourceDeviceNameValue.value ?? ClipboardItem.localDeviceName
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -671,6 +893,7 @@ final class ClipboardStore {
|
||||
|
||||
private enum PersistenceMutation {
|
||||
case upsert(ClipboardItem)
|
||||
case upsertMany([ClipboardItem])
|
||||
case delete(UUID)
|
||||
case deleteMany([UUID])
|
||||
case deleteAll
|
||||
@@ -684,8 +907,8 @@ final class ClipboardStore {
|
||||
id, kind, display_text, payload, payload_hash,
|
||||
created_at, last_used_at, use_count, source_app,
|
||||
source_app_bundle_id, image_path, thumbnail_path, is_pinned, ocr_text,
|
||||
collection_name, custom_title
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);
|
||||
collection_name, custom_title, source_device_name
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);
|
||||
"""
|
||||
|
||||
switch mutation {
|
||||
@@ -719,6 +942,40 @@ final class ClipboardStore {
|
||||
shouldRollback = true
|
||||
}
|
||||
|
||||
case .upsertMany(let items):
|
||||
guard !items.isEmpty else { return }
|
||||
var statement: OpaquePointer?
|
||||
var shouldRollback = false
|
||||
defer {
|
||||
if let statement {
|
||||
sqlite3_finalize(statement)
|
||||
}
|
||||
if shouldRollback {
|
||||
_ = execute("ROLLBACK;")
|
||||
}
|
||||
}
|
||||
|
||||
guard execute("BEGIN IMMEDIATE TRANSACTION;") else { return }
|
||||
guard sqlite3_prepare_v2(db, insertSQL, -1, &statement, nil) == SQLITE_OK else {
|
||||
shouldRollback = true
|
||||
return
|
||||
}
|
||||
|
||||
for item in items {
|
||||
bindItem(item, to: statement)
|
||||
let stepResult = sqlite3_step(statement)
|
||||
if stepResult != SQLITE_DONE {
|
||||
shouldRollback = true
|
||||
return
|
||||
}
|
||||
sqlite3_reset(statement)
|
||||
sqlite3_clear_bindings(statement)
|
||||
}
|
||||
|
||||
if !execute("COMMIT;") {
|
||||
shouldRollback = true
|
||||
}
|
||||
|
||||
case .delete(let id):
|
||||
guard execute("BEGIN IMMEDIATE TRANSACTION;") else { return }
|
||||
let query = "DELETE FROM clipboard_items WHERE id = ?;"
|
||||
@@ -784,8 +1041,8 @@ final class ClipboardStore {
|
||||
id, kind, display_text, payload, payload_hash,
|
||||
created_at, last_used_at, use_count, source_app,
|
||||
source_app_bundle_id, image_path, thumbnail_path, is_pinned, ocr_text,
|
||||
collection_name, custom_title
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);
|
||||
collection_name, custom_title, source_device_name
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);
|
||||
"""
|
||||
|
||||
guard execute("BEGIN IMMEDIATE TRANSACTION;") else {
|
||||
@@ -870,5 +1127,6 @@ final class ClipboardStore {
|
||||
bindText(statement, 14, encryptionService.protect(item.ocrText))
|
||||
bindText(statement, 15, encryptionService.protect(item.collectionName))
|
||||
bindText(statement, 16, encryptionService.protect(item.customTitle))
|
||||
bindText(statement, 17, encryptionService.protect(item.sourceDeviceName))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,12 +29,20 @@ enum ImageTextExtractor {
|
||||
return normalized(lines)
|
||||
}
|
||||
|
||||
private static func normalized(_ lines: [String]) -> String? {
|
||||
let text = lines
|
||||
.filter { !$0.isEmpty }
|
||||
.joined(separator: " ")
|
||||
static func normalizedRecognizedText(_ text: String?) -> String? {
|
||||
guard let text else { return nil }
|
||||
let normalized = text
|
||||
.split(whereSeparator: \.isWhitespace)
|
||||
.joined(separator: " ")
|
||||
return text.isEmpty ? nil : text
|
||||
guard !normalized.isEmpty else { return nil }
|
||||
return String(normalized.prefix(AppConfiguration.maxRecognizedImageTextLength))
|
||||
}
|
||||
|
||||
private static func normalized(_ lines: [String]) -> String? {
|
||||
normalizedRecognizedText(
|
||||
lines
|
||||
.filter { !$0.isEmpty }
|
||||
.joined(separator: " ")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,6 +57,18 @@ final class PasteActionService {
|
||||
return .failed("Could not write item to clipboard.")
|
||||
}
|
||||
|
||||
return completePaste(targetApp: targetApp)
|
||||
}
|
||||
|
||||
func paste(_ items: [ClipboardItem], targetApp: NSRunningApplication?) -> PasteActionResult {
|
||||
guard writeToPasteboard(items) else {
|
||||
return .failed("Could not write items to clipboard.")
|
||||
}
|
||||
|
||||
return completePaste(targetApp: targetApp)
|
||||
}
|
||||
|
||||
private func completePaste(targetApp: NSRunningApplication?) -> PasteActionResult {
|
||||
guard let targetApp,
|
||||
!targetApp.isTerminated else {
|
||||
return .copied
|
||||
@@ -97,6 +109,11 @@ final class PasteActionService {
|
||||
writeToPasteboard(item) ? .copied : .failed("Could not write item to clipboard.")
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func copy(_ items: [ClipboardItem]) -> PasteActionResult {
|
||||
writeToPasteboard(items) ? .copied : .failed("Could not write items to clipboard.")
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func copyPlainText(_ item: ClipboardItem) -> PasteActionResult {
|
||||
writePlainTextToPasteboard(item) ? .copiedPlainText : .failed("Could not write plain text to clipboard.")
|
||||
@@ -242,6 +259,26 @@ final class PasteActionService {
|
||||
return didWrite
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func writeToPasteboard(_ items: [ClipboardItem]) -> Bool {
|
||||
guard !items.isEmpty else { return false }
|
||||
var writers: [NSPasteboardWriting] = []
|
||||
for item in items {
|
||||
let itemWriters = pasteboardWriters(for: item)
|
||||
guard !itemWriters.isEmpty else { return false }
|
||||
writers.append(contentsOf: itemWriters)
|
||||
}
|
||||
guard !writers.isEmpty else { return false }
|
||||
|
||||
let board = NSPasteboard.general
|
||||
board.clearContents()
|
||||
let didWrite = board.writeObjects(writers)
|
||||
if didWrite {
|
||||
ClipboardSelfWriteTracker.mark(changeCount: board.changeCount)
|
||||
}
|
||||
return didWrite
|
||||
}
|
||||
|
||||
func plainText(for item: ClipboardItem) -> String? {
|
||||
switch item.kind {
|
||||
case .text, .code, .unknown:
|
||||
|
||||
@@ -25,27 +25,32 @@ final class ShortcutManager {
|
||||
private enum HotKeyID: UInt32 {
|
||||
case openPanel = 1
|
||||
case openSettings = 2
|
||||
case stackCapture = 3
|
||||
}
|
||||
|
||||
private let onOpenClipboardPanel: () -> Void
|
||||
private let onOpenSettings: () -> Void
|
||||
private let onToggleStackCapture: () -> Void
|
||||
private let onStatusChange: (RegistrationStatus) -> Void
|
||||
|
||||
private var openBinding: ShortcutBinding
|
||||
private var settingsBinding: ShortcutBinding
|
||||
private var openHotKey: EventHotKeyRef?
|
||||
private var settingsHotKey: EventHotKeyRef?
|
||||
private var stackCaptureHotKey: EventHotKeyRef?
|
||||
private var eventHandler: EventHandlerRef?
|
||||
|
||||
init(
|
||||
onOpenClipboardPanel: @escaping () -> Void,
|
||||
onOpenSettings: @escaping () -> Void,
|
||||
onToggleStackCapture: @escaping () -> Void = {},
|
||||
onStatusChange: @escaping (RegistrationStatus) -> Void = { _ in },
|
||||
openShortcut: ShortcutBinding,
|
||||
settingsShortcut: ShortcutBinding
|
||||
) {
|
||||
self.onOpenClipboardPanel = onOpenClipboardPanel
|
||||
self.onOpenSettings = onOpenSettings
|
||||
self.onToggleStackCapture = onToggleStackCapture
|
||||
self.onStatusChange = onStatusChange
|
||||
self.openBinding = openShortcut
|
||||
self.settingsBinding = settingsShortcut
|
||||
@@ -59,7 +64,7 @@ final class ShortcutManager {
|
||||
func start() -> RegistrationStatus {
|
||||
stop()
|
||||
|
||||
if let status = validationFailure(for: openBinding) ?? validationFailure(for: settingsBinding) {
|
||||
if let status = Self.validationFailure(for: openBinding) ?? Self.validationFailure(for: settingsBinding) {
|
||||
onStatusChange(status)
|
||||
return status
|
||||
}
|
||||
@@ -68,6 +73,11 @@ final class ShortcutManager {
|
||||
onStatusChange(status)
|
||||
return status
|
||||
}
|
||||
if openBinding == Self.stackCaptureShortcut || settingsBinding == Self.stackCaptureShortcut {
|
||||
let status = RegistrationStatus.conflict(Self.stackCaptureShortcut.displayText)
|
||||
onStatusChange(status)
|
||||
return status
|
||||
}
|
||||
|
||||
var eventType = EventTypeSpec(eventClass: OSType(kEventClassKeyboard), eventKind: UInt32(kEventHotKeyPressed))
|
||||
let installStatus = InstallEventHandler(
|
||||
@@ -104,6 +114,13 @@ final class ShortcutManager {
|
||||
return settingsStatus
|
||||
}
|
||||
|
||||
let stackCaptureStatus = register(binding: Self.stackCaptureShortcut, id: .stackCapture, target: &stackCaptureHotKey)
|
||||
guard stackCaptureStatus == .registered else {
|
||||
stop()
|
||||
onStatusChange(stackCaptureStatus)
|
||||
return stackCaptureStatus
|
||||
}
|
||||
|
||||
onStatusChange(.registered)
|
||||
return .registered
|
||||
}
|
||||
@@ -122,12 +139,16 @@ final class ShortcutManager {
|
||||
if let settingsHotKey {
|
||||
UnregisterEventHotKey(settingsHotKey)
|
||||
}
|
||||
if let stackCaptureHotKey {
|
||||
UnregisterEventHotKey(stackCaptureHotKey)
|
||||
}
|
||||
if let eventHandler {
|
||||
RemoveEventHandler(eventHandler)
|
||||
}
|
||||
|
||||
openHotKey = nil
|
||||
settingsHotKey = nil
|
||||
stackCaptureHotKey = nil
|
||||
eventHandler = nil
|
||||
}
|
||||
|
||||
@@ -157,7 +178,7 @@ final class ShortcutManager {
|
||||
return .registrationFailed(osStatusMessage(status))
|
||||
}
|
||||
|
||||
private func validationFailure(for binding: ShortcutBinding) -> RegistrationStatus? {
|
||||
static func validationFailure(for binding: ShortcutBinding) -> RegistrationStatus? {
|
||||
guard Self.virtualKeyCode(for: binding.key) != nil else {
|
||||
return .unsupportedShortcut(binding.displayText)
|
||||
}
|
||||
@@ -187,6 +208,8 @@ final class ShortcutManager {
|
||||
onOpenClipboardPanel()
|
||||
case .openSettings:
|
||||
onOpenSettings()
|
||||
case .stackCapture:
|
||||
onToggleStackCapture()
|
||||
case nil:
|
||||
break
|
||||
}
|
||||
@@ -260,6 +283,11 @@ final class ShortcutManager {
|
||||
|
||||
private static let hotKeySignature: OSType = 0x436C7042
|
||||
|
||||
static let stackCaptureShortcut = ShortcutBinding(
|
||||
key: "c",
|
||||
modifierFlags: NSEvent.ModifierFlags([.command, .shift]).rawValue
|
||||
)
|
||||
|
||||
private func osStatusMessage(_ status: OSStatus) -> String {
|
||||
"OSStatus \(status)"
|
||||
}
|
||||
|
||||
@@ -16,14 +16,22 @@ struct ClipboardPanelReflowPlan {
|
||||
enum ClipboardPanelShortcutAction: Equatable {
|
||||
case copy
|
||||
case copyPlainText
|
||||
case edit
|
||||
case focusSearch
|
||||
case newCollection
|
||||
case nextCollection
|
||||
case open
|
||||
case pastePlainText
|
||||
case pasteStackNext
|
||||
case preview
|
||||
case previousCollection
|
||||
case rename
|
||||
case reveal
|
||||
case showInClipboard
|
||||
case toggleCapturePause
|
||||
case toggleStack
|
||||
case toggleStackCapture
|
||||
case undoDelete
|
||||
}
|
||||
|
||||
enum ClipboardPanelNavigationAction: Equatable {
|
||||
@@ -35,23 +43,41 @@ enum ClipboardPanelNavigationAction: Equatable {
|
||||
case previous
|
||||
}
|
||||
|
||||
enum ClipboardPanelSelectionAction: Equatable {
|
||||
case extendFirst
|
||||
case extendLast
|
||||
case extendNext
|
||||
case extendPageNext
|
||||
case extendPagePrevious
|
||||
case extendPrevious
|
||||
case selectAll
|
||||
}
|
||||
|
||||
final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanelDataSource, QLPreviewPanelDelegate {
|
||||
private enum Animation {
|
||||
static let showDuration: TimeInterval = 0.16
|
||||
static let hideDuration: TimeInterval = 0.12
|
||||
static let reflowDuration: TimeInterval = 0.10
|
||||
static let showDuration: TimeInterval = 0.22
|
||||
static let hideDuration: TimeInterval = 0.16
|
||||
static let reflowDuration: TimeInterval = 0.18
|
||||
static let easing: CAMediaTimingFunctionName = .easeInEaseOut
|
||||
}
|
||||
private enum Metrics {
|
||||
static let shelfHeightRatio: CGFloat = 0.42
|
||||
static let minimumShelfHeight: CGFloat = 408
|
||||
static let maximumShelfHeight: CGFloat = 430
|
||||
static let shelfHeightRatio: CGFloat = 0.18
|
||||
static let minimumShelfHeight: CGFloat = 156
|
||||
static let maximumShelfHeight: CGFloat = 176
|
||||
static let minimumUserShelfHeight: CGFloat = 150
|
||||
static let maximumUserShelfHeight: CGFloat = 680
|
||||
static let maximumUserShelfHeightRatio: CGFloat = 0.72
|
||||
static let preferredVerticalShelfWidth: CGFloat = 336
|
||||
static let minimumVerticalShelfWidth: CGFloat = 320
|
||||
static let maximumVerticalShelfWidthRatio: CGFloat = 0.30
|
||||
static let minimumBottomInset: CGFloat = 18
|
||||
static let maximumBottomInset: CGFloat = 20
|
||||
static let hiddenDockRevealInsetLimit: CGFloat = 8
|
||||
}
|
||||
|
||||
private var panel: NSPanel!
|
||||
private var panelView: ClipboardPanelView!
|
||||
private let settings: SettingsModel
|
||||
private(set) var isVisible = false
|
||||
private var clickMonitor: Any?
|
||||
private var keyMonitor: Any?
|
||||
@@ -62,6 +88,7 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel
|
||||
private let openSettings: () -> Void
|
||||
private var isAnimating = false
|
||||
private var quickLookURL: URL?
|
||||
private var linkPreviewController: LinkPreviewWindowController?
|
||||
private var screenParametersObserver: NSObjectProtocol?
|
||||
private static let quickPasteKeyCodes: [UInt16: Int] = [
|
||||
18: 0,
|
||||
@@ -97,6 +124,7 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel
|
||||
pollClipboardNow: @escaping () -> Void = {},
|
||||
openSettings: @escaping () -> Void = {}
|
||||
) {
|
||||
self.settings = settings
|
||||
self.viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: cacheService)
|
||||
self.pollClipboardNow = pollClipboardNow
|
||||
self.openSettings = openSettings
|
||||
@@ -117,7 +145,7 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel
|
||||
onPreview: { [weak self] in self?.previewSelected() }
|
||||
)
|
||||
|
||||
let contentSize = NSSize(width: 1200, height: 420)
|
||||
let contentSize = NSSize(width: 336, height: 760)
|
||||
panel = KeyablePanel(
|
||||
contentRect: NSRect(x: 0, y: 0, width: contentSize.width, height: contentSize.height),
|
||||
styleMask: [.nonactivatingPanel, .fullSizeContentView],
|
||||
@@ -132,13 +160,25 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel
|
||||
panel.delegate = self
|
||||
panel.isOpaque = false
|
||||
panel.backgroundColor = NSColor.clear
|
||||
panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
|
||||
panel.collectionBehavior = Self.panelCollectionBehavior
|
||||
panel.becomesKeyOnlyIfNeeded = false
|
||||
panel.titlebarAppearsTransparent = true
|
||||
panel.titleVisibility = .hidden
|
||||
panel.standardWindowButton(.miniaturizeButton)?.isHidden = true
|
||||
panel.standardWindowButton(.zoomButton)?.isHidden = true
|
||||
panel.standardWindowButton(.closeButton)?.isHidden = true
|
||||
applyPanelSharingSetting()
|
||||
|
||||
settings.observe { [weak self] change in
|
||||
guard change == .hideFromScreenCapture || change == .panelLayout else { return }
|
||||
DispatchQueue.main.async {
|
||||
if change == .hideFromScreenCapture {
|
||||
self?.applyPanelSharingSetting()
|
||||
} else {
|
||||
self?.reflowPanelForScreenChange()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
screenParametersObserver = NotificationCenter.default.addObserver(
|
||||
forName: NSApplication.didChangeScreenParametersNotification,
|
||||
@@ -157,15 +197,37 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel
|
||||
}
|
||||
}
|
||||
|
||||
func toggle() {
|
||||
func toggle(preferredScreen explicitScreen: NSScreen? = nil) {
|
||||
if isVisible {
|
||||
hide()
|
||||
} else {
|
||||
show()
|
||||
show(preferredScreen: explicitScreen)
|
||||
}
|
||||
}
|
||||
|
||||
func show() {
|
||||
func createTextClip() {
|
||||
performWhenVisible { [weak self] in
|
||||
guard let self else { return }
|
||||
self.panelView.createTextClip()
|
||||
}
|
||||
}
|
||||
|
||||
func createCollection() {
|
||||
performWhenVisible { [weak self] in
|
||||
guard let self else { return }
|
||||
self.panelView.createCollection()
|
||||
}
|
||||
}
|
||||
|
||||
func toggleStackCaptureMode() {
|
||||
viewModel.toggleStackCaptureMode()
|
||||
}
|
||||
|
||||
func addCapturedItemToStack(_ item: ClipboardItem) {
|
||||
viewModel.addCapturedItemToStack(item)
|
||||
}
|
||||
|
||||
func show(preferredScreen explicitScreen: NSScreen? = nil) {
|
||||
if isVisible || isAnimating { return }
|
||||
isAnimating = true
|
||||
isVisible = true
|
||||
@@ -173,7 +235,7 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel
|
||||
rememberTargetApplication()
|
||||
pollClipboardNow()
|
||||
|
||||
guard let screen = preferredScreen() else {
|
||||
guard let screen = preferredScreen(explicitScreen: explicitScreen) else {
|
||||
isVisible = false
|
||||
isAnimating = false
|
||||
return
|
||||
@@ -181,7 +243,8 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel
|
||||
activeScreenSnapshot = (screen.frame, screen.visibleFrame)
|
||||
let frames = Self.panelFrames(
|
||||
forScreenFrame: screen.frame,
|
||||
visibleFrame: screen.visibleFrame
|
||||
visibleFrame: screen.visibleFrame,
|
||||
side: settings.panelSide
|
||||
)
|
||||
panelView.setBottomSafeInset(Self.contentBottomInset(forScreenFrame: screen.frame, visibleFrame: screen.visibleFrame))
|
||||
panel.setFrame(frames.hidden, display: false)
|
||||
@@ -204,7 +267,7 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel
|
||||
self.panelView.finishOpeningTransition()
|
||||
guard self.isVisible else { return }
|
||||
self.installClickMonitor()
|
||||
self.panelView.focusSearchField()
|
||||
self.panelView.focusSelectedCardForKeyboardNavigation()
|
||||
}
|
||||
|
||||
installKeyMonitor()
|
||||
@@ -230,7 +293,8 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel
|
||||
let screenFrames = activeScreenSnapshot ?? activeScreenFrames()
|
||||
let hidden = Self.panelFrames(
|
||||
forScreenFrame: screenFrames.screenFrame,
|
||||
visibleFrame: screenFrames.visibleFrame
|
||||
visibleFrame: screenFrames.visibleFrame,
|
||||
side: settings.panelSide
|
||||
).hidden
|
||||
isAnimating = true
|
||||
|
||||
@@ -252,62 +316,120 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel
|
||||
}
|
||||
}
|
||||
|
||||
private func performWhenVisible(_ action: @escaping () -> Void) {
|
||||
if isVisible, !isAnimating {
|
||||
action()
|
||||
return
|
||||
}
|
||||
|
||||
show()
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + Animation.showDuration + 0.03) { [weak self] in
|
||||
guard let self, self.isVisible else { return }
|
||||
action()
|
||||
}
|
||||
}
|
||||
|
||||
func windowDidResignKey(_ notification: Notification) {
|
||||
hide()
|
||||
}
|
||||
|
||||
func windowDidBecomeKey(_ notification: Notification) {
|
||||
panelView.focusSearchField()
|
||||
panelView.focusSelectedCardForKeyboardNavigation()
|
||||
}
|
||||
|
||||
private func preferredScreen() -> NSScreen? {
|
||||
if let menuBarScreen = preferredScreenProvider() {
|
||||
return menuBarScreen
|
||||
}
|
||||
|
||||
private func preferredScreen(explicitScreen: NSScreen? = nil) -> NSScreen? {
|
||||
let point = NSEvent.mouseLocation
|
||||
return NSScreen.screens.first { NSMouseInRect(point, $0.frame, false) } ?? NSScreen.screens.first
|
||||
let pointerScreen = NSScreen.screens.first { NSMouseInRect(point, $0.frame, false) }
|
||||
return Self.selectedOpenScreen(
|
||||
explicit: explicitScreen,
|
||||
preferred: preferredScreenProvider(),
|
||||
pointer: pointerScreen,
|
||||
fallback: NSScreen.screens.first
|
||||
)
|
||||
}
|
||||
|
||||
static func selectedOpenScreen<Screen>(
|
||||
explicit: Screen?,
|
||||
preferred: Screen?,
|
||||
pointer: Screen?,
|
||||
fallback: Screen?
|
||||
) -> Screen? {
|
||||
explicit ?? preferred ?? pointer ?? fallback
|
||||
}
|
||||
|
||||
static func selectedReflowScreen<Screen>(
|
||||
currentPanel: Screen?,
|
||||
lastKnown: Screen?,
|
||||
preferred: Screen?,
|
||||
pointer: Screen?,
|
||||
fallback: Screen?
|
||||
) -> Screen? {
|
||||
currentPanel ?? lastKnown ?? preferred ?? pointer ?? fallback
|
||||
}
|
||||
|
||||
static func panelFrames(forScreenFrame screenFrame: CGRect) -> (shown: NSRect, hidden: NSRect) {
|
||||
return panelFrames(forScreenFrame: screenFrame, visibleFrame: screenFrame)
|
||||
}
|
||||
|
||||
static func panelFrames(forScreenFrame screenFrame: CGRect, visibleFrame: CGRect) -> (shown: NSRect, hidden: NSRect) {
|
||||
static func panelFrames(
|
||||
forScreenFrame screenFrame: CGRect,
|
||||
visibleFrame: CGRect,
|
||||
preferredHeight _: CGFloat? = nil,
|
||||
side: ClipboardPanelSide = .right
|
||||
) -> (shown: NSRect, hidden: NSRect) {
|
||||
let intersectedFrame = visibleFrame.intersection(screenFrame)
|
||||
let effectiveFrame = intersectedFrame.width > 0 && intersectedFrame.height > 0 ? intersectedFrame : screenFrame
|
||||
let frameHeight = effectiveFrame.height > 0 ? effectiveFrame.height : max(1, screenFrame.height)
|
||||
let height = panelHeight(within: frameHeight)
|
||||
let targetWidth = max(1, floor(effectiveFrame.width))
|
||||
let shownMinX = effectiveFrame.minX
|
||||
let shownMinY = max(screenFrame.minY, visibleFrame.minY)
|
||||
return verticalPanelFrames(forScreenFrame: screenFrame, effectiveFrame: effectiveFrame, side: side)
|
||||
}
|
||||
|
||||
private static func verticalPanelFrames(
|
||||
forScreenFrame screenFrame: CGRect,
|
||||
effectiveFrame: CGRect,
|
||||
side: ClipboardPanelSide
|
||||
) -> (shown: NSRect, hidden: NSRect) {
|
||||
let targetWidth = panelWidth(within: max(1, effectiveFrame.width))
|
||||
let targetHeight = max(1, floor(effectiveFrame.maxY - screenFrame.minY))
|
||||
let shownX: CGFloat
|
||||
let hiddenX: CGFloat
|
||||
switch side {
|
||||
case .left:
|
||||
shownX = effectiveFrame.minX
|
||||
hiddenX = shownX - targetWidth - 1
|
||||
case .right:
|
||||
shownX = effectiveFrame.maxX - targetWidth
|
||||
hiddenX = effectiveFrame.maxX + 1
|
||||
}
|
||||
let shown = NSRect(
|
||||
x: shownMinX,
|
||||
y: shownMinY,
|
||||
x: shownX,
|
||||
y: screenFrame.minY,
|
||||
width: targetWidth,
|
||||
height: height
|
||||
height: targetHeight
|
||||
)
|
||||
let hidden = NSRect(
|
||||
x: shown.minX,
|
||||
y: shown.minY - height - 1,
|
||||
x: hiddenX,
|
||||
y: shown.minY,
|
||||
width: shown.width,
|
||||
height: height
|
||||
height: targetHeight
|
||||
)
|
||||
return (shown, hidden)
|
||||
}
|
||||
|
||||
private static func panelHeight(within visibleHeight: CGFloat) -> CGFloat {
|
||||
let available = max(1, visibleHeight)
|
||||
let preferred = floor(available * Metrics.shelfHeightRatio)
|
||||
let clamped = min(max(preferred, Metrics.minimumShelfHeight), Metrics.maximumShelfHeight)
|
||||
return min(available, clamped)
|
||||
private static func panelWidth(within visibleWidth: CGFloat) -> CGFloat {
|
||||
let available = max(1, visibleWidth)
|
||||
let preferred = min(Metrics.preferredVerticalShelfWidth, floor(available * Metrics.maximumVerticalShelfWidthRatio))
|
||||
return min(available, max(Metrics.minimumVerticalShelfWidth, preferred))
|
||||
}
|
||||
|
||||
static func contentBottomInset(forScreenFrame screenFrame: CGRect, visibleFrame: CGRect) -> CGFloat {
|
||||
let dockInset = max(0, visibleFrame.minY - screenFrame.minY)
|
||||
let dockInset = visibleBottomDockInset(forScreenFrame: screenFrame, visibleFrame: visibleFrame)
|
||||
return max(Metrics.minimumBottomInset, min(Metrics.maximumBottomInset, dockInset + 2))
|
||||
}
|
||||
|
||||
private static func visibleBottomDockInset(forScreenFrame screenFrame: CGRect, visibleFrame: CGRect) -> CGFloat {
|
||||
let inset = max(0, visibleFrame.minY - screenFrame.minY)
|
||||
return inset > Metrics.hiddenDockRevealInsetLimit ? inset : 0
|
||||
}
|
||||
|
||||
static var animationProfile: ClipboardPanelAnimationProfile {
|
||||
ClipboardPanelAnimationProfile(
|
||||
showDuration: Animation.showDuration,
|
||||
@@ -317,6 +439,10 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel
|
||||
)
|
||||
}
|
||||
|
||||
static var panelCollectionBehavior: NSWindow.CollectionBehavior {
|
||||
[.moveToActiveSpace, .fullScreenAuxiliary, .transient]
|
||||
}
|
||||
|
||||
static func reflowPlan(forScreenFrame screenFrame: CGRect, visibleFrame: CGRect) -> ClipboardPanelReflowPlan {
|
||||
let frames = panelFrames(forScreenFrame: screenFrame, visibleFrame: visibleFrame)
|
||||
return ClipboardPanelReflowPlan(
|
||||
@@ -325,6 +451,31 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel
|
||||
)
|
||||
}
|
||||
|
||||
static func reflowPlan(
|
||||
forScreenFrame screenFrame: CGRect,
|
||||
visibleFrame: CGRect,
|
||||
preferredHeight _: CGFloat? = nil,
|
||||
side: ClipboardPanelSide = .right
|
||||
) -> ClipboardPanelReflowPlan {
|
||||
let frames = panelFrames(
|
||||
forScreenFrame: screenFrame,
|
||||
visibleFrame: visibleFrame,
|
||||
side: side
|
||||
)
|
||||
return ClipboardPanelReflowPlan(
|
||||
frame: frames.shown,
|
||||
bottomSafeInset: contentBottomInset(forScreenFrame: screenFrame, visibleFrame: visibleFrame)
|
||||
)
|
||||
}
|
||||
|
||||
static func panelSharingType(hideFromScreenCapture: Bool) -> NSWindow.SharingType {
|
||||
hideFromScreenCapture ? .none : .readOnly
|
||||
}
|
||||
|
||||
private func applyPanelSharingSetting() {
|
||||
panel.sharingType = Self.panelSharingType(hideFromScreenCapture: settings.hideFromScreenCapture)
|
||||
}
|
||||
|
||||
private func rememberTargetApplication() {
|
||||
guard let frontmost = NSWorkspace.shared.frontmostApplication else {
|
||||
targetApplication = nil
|
||||
@@ -385,6 +536,11 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel
|
||||
self.performShortcutAction(action)
|
||||
return nil
|
||||
}
|
||||
if self.shouldHandlePanelKeyEvent(event),
|
||||
let action = Self.selectionShortcutAction(forKeyCode: event.keyCode, modifiers: event.modifierFlags) {
|
||||
self.performSelectionAction(action)
|
||||
return nil
|
||||
}
|
||||
guard self.shouldHandlePanelKeyEvent(event) else { return event }
|
||||
if let action = Self.navigationShortcutAction(forKeyCode: event.keyCode, modifiers: event.modifierFlags) {
|
||||
self.performNavigationAction(action)
|
||||
@@ -432,14 +588,47 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel
|
||||
panelView.focusSelectedCardForKeyboardNavigation()
|
||||
}
|
||||
|
||||
private func performSelectionAction(_ action: ClipboardPanelSelectionAction) {
|
||||
switch action {
|
||||
case .extendFirst:
|
||||
viewModel.selectItem(at: 0, mode: .range)
|
||||
case .extendLast:
|
||||
viewModel.selectItem(at: viewModel.visibleItems.count - 1, mode: .range)
|
||||
case .extendNext:
|
||||
extendSelection(by: 1)
|
||||
case .extendPageNext:
|
||||
extendSelection(by: panelView.visibleCardPageStep)
|
||||
case .extendPagePrevious:
|
||||
extendSelection(by: -panelView.visibleCardPageStep)
|
||||
case .extendPrevious:
|
||||
extendSelection(by: -1)
|
||||
case .selectAll:
|
||||
viewModel.selectAllVisibleItems()
|
||||
}
|
||||
panelView.focusSelectedCardForKeyboardNavigation()
|
||||
}
|
||||
|
||||
private func extendSelection(by delta: Int) {
|
||||
let count = viewModel.visibleItems.count
|
||||
guard count > 0 else { return }
|
||||
let target = max(0, min(count - 1, viewModel.selectedIndex + delta))
|
||||
viewModel.selectItem(at: target, mode: .range)
|
||||
}
|
||||
|
||||
private func performShortcutAction(_ action: ClipboardPanelShortcutAction) {
|
||||
switch action {
|
||||
case .copy:
|
||||
viewModel.copySelected()
|
||||
case .copyPlainText:
|
||||
viewModel.copySelectedPlainText()
|
||||
case .edit:
|
||||
panelView.editSelectedClip()
|
||||
case .focusSearch:
|
||||
panelView.focusSearchOrShowFilters()
|
||||
case .newCollection:
|
||||
panelView.createCollection()
|
||||
case .nextCollection:
|
||||
viewModel.selectAdjacentCollection(delta: 1)
|
||||
case .open:
|
||||
viewModel.openSelected()
|
||||
case .pastePlainText:
|
||||
@@ -448,17 +637,46 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel
|
||||
viewModel.pasteNextStackItem()
|
||||
case .preview:
|
||||
previewSelected()
|
||||
case .previousCollection:
|
||||
viewModel.selectAdjacentCollection(delta: -1)
|
||||
case .rename:
|
||||
panelView.renameSelectedClip()
|
||||
case .reveal:
|
||||
viewModel.revealSelected()
|
||||
case .showInClipboard:
|
||||
panelView.showSelectedInClipboard()
|
||||
case .toggleCapturePause:
|
||||
toggleCapturePauseFromShortcut()
|
||||
case .toggleStack:
|
||||
viewModel.toggleSelectedStackMembership()
|
||||
case .toggleStackCapture:
|
||||
viewModel.toggleStackCaptureMode()
|
||||
case .undoDelete:
|
||||
viewModel.undoLastDelete()
|
||||
}
|
||||
}
|
||||
|
||||
private func toggleCapturePauseFromShortcut() {
|
||||
settings.pauseCaptureUntil = nil
|
||||
if settings.pauseCapture {
|
||||
settings.pauseCapture = false
|
||||
settings.setCaptureStatus(message: "Capture resumed.")
|
||||
} else {
|
||||
settings.pauseCapture = true
|
||||
settings.setCaptureStatus(message: "Capture is paused.")
|
||||
}
|
||||
}
|
||||
|
||||
private func previewSelected() {
|
||||
if let request = viewModel.linkPreviewRequestForSelected() {
|
||||
quickLookURL = nil
|
||||
QLPreviewPanel.shared()?.orderOut(nil)
|
||||
showLinkPreview(request)
|
||||
return
|
||||
}
|
||||
|
||||
guard let url = viewModel.previewURLForSelected() else { return }
|
||||
linkPreviewController?.close()
|
||||
quickLookURL = url
|
||||
guard let previewPanel = QLPreviewPanel.shared() else {
|
||||
NSWorkspace.shared.open(url)
|
||||
@@ -470,6 +688,12 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel
|
||||
previewPanel.makeKeyAndOrderFront(nil)
|
||||
}
|
||||
|
||||
private func showLinkPreview(_ request: LinkPreviewRequest) {
|
||||
let controller = linkPreviewController ?? LinkPreviewWindowController()
|
||||
linkPreviewController = controller
|
||||
controller.show(request, relativeTo: panel)
|
||||
}
|
||||
|
||||
private func shouldHandlePanelKeyEvent(_ event: NSEvent) -> Bool {
|
||||
shouldHandlePanelKeyEvent(event, allowSearchFieldEditing: false)
|
||||
}
|
||||
@@ -498,6 +722,13 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel
|
||||
|
||||
static func navigationShortcutAction(forKeyCode keyCode: UInt16, modifiers: NSEvent.ModifierFlags) -> ClipboardPanelNavigationAction? {
|
||||
let relevantModifiers = modifiers.intersection(.deviceIndependentFlagsMask)
|
||||
if relevantModifiers == .command {
|
||||
switch keyCode {
|
||||
case 126: return .first
|
||||
case 125: return .last
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
guard relevantModifiers.isEmpty else { return nil }
|
||||
switch keyCode {
|
||||
case 115: return .first
|
||||
@@ -510,6 +741,23 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel
|
||||
}
|
||||
}
|
||||
|
||||
static func selectionShortcutAction(forKeyCode keyCode: UInt16, modifiers: NSEvent.ModifierFlags) -> ClipboardPanelSelectionAction? {
|
||||
let relevantModifiers = modifiers.intersection(.deviceIndependentFlagsMask)
|
||||
if relevantModifiers == .command {
|
||||
return keyCode == 0 ? .selectAll : nil
|
||||
}
|
||||
guard relevantModifiers == .shift else { return nil }
|
||||
switch keyCode {
|
||||
case 115: return .extendFirst
|
||||
case 119: return .extendLast
|
||||
case 124: return .extendNext
|
||||
case 121: return .extendPageNext
|
||||
case 116: return .extendPagePrevious
|
||||
case 123: return .extendPrevious
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
|
||||
static func quickPastePlainTextIndex(forKeyCode keyCode: UInt16, modifiers: NSEvent.ModifierFlags) -> Int? {
|
||||
let relevantModifiers = modifiers.intersection(.deviceIndependentFlagsMask)
|
||||
guard relevantModifiers == [.command, .shift] else { return nil }
|
||||
@@ -533,6 +781,10 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel
|
||||
switch keyCode {
|
||||
case 8:
|
||||
return .copy
|
||||
case 3:
|
||||
return .focusSearch
|
||||
case 14:
|
||||
return .edit
|
||||
case 31:
|
||||
return .open
|
||||
case 5:
|
||||
@@ -540,7 +792,15 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel
|
||||
case 16:
|
||||
return .preview
|
||||
case 15:
|
||||
return .reveal
|
||||
return .rename
|
||||
case 17:
|
||||
return .toggleCapturePause
|
||||
case 6:
|
||||
return .undoDelete
|
||||
case 123:
|
||||
return .previousCollection
|
||||
case 124:
|
||||
return .nextCollection
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
@@ -548,12 +808,15 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel
|
||||
|
||||
static func modifiedShortcutAction(forKeyCode keyCode: UInt16, modifiers: NSEvent.ModifierFlags) -> ClipboardPanelShortcutAction? {
|
||||
let relevantModifiers = modifiers.intersection(.deviceIndependentFlagsMask)
|
||||
if relevantModifiers == .shift {
|
||||
return keyCode == 36 ? .pastePlainText : nil
|
||||
}
|
||||
guard relevantModifiers == [.command, .shift] else { return nil }
|
||||
switch keyCode {
|
||||
case 1:
|
||||
return .toggleStack
|
||||
case 8:
|
||||
return .copyPlainText
|
||||
return .toggleStackCapture
|
||||
case 45:
|
||||
return .newCollection
|
||||
case 9:
|
||||
@@ -585,6 +848,34 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel
|
||||
var debugIsAnimating: Bool {
|
||||
isAnimating
|
||||
}
|
||||
|
||||
func debugSetSearchFieldText(_ text: String) {
|
||||
panelView.debugSetSearchFieldText(text)
|
||||
}
|
||||
|
||||
var debugSearchFieldText: String {
|
||||
panelView.debugSearchFieldText
|
||||
}
|
||||
|
||||
var debugSearchFieldWidth: CGFloat {
|
||||
panelView.debugSearchFieldWidth
|
||||
}
|
||||
|
||||
var debugSearchFieldPlaceholderText: String {
|
||||
panelView.debugSearchFieldPlaceholderText
|
||||
}
|
||||
|
||||
var debugSearchFieldIsVisible: Bool {
|
||||
panelView.debugSearchFieldIsVisible
|
||||
}
|
||||
|
||||
var debugSearchIconButtonIsVisible: Bool {
|
||||
panelView.debugSearchIconButtonIsVisible
|
||||
}
|
||||
|
||||
var debugIsSearchFieldEditing: Bool {
|
||||
panelView.isSearchFieldEditing
|
||||
}
|
||||
#endif
|
||||
|
||||
private func installClickMonitor() {
|
||||
@@ -613,10 +904,22 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel
|
||||
private func reflowPanelForScreenChange() {
|
||||
guard isVisible else { return }
|
||||
guard !isAnimating else { return }
|
||||
guard let screen = preferredScreen() ?? panel.screen ?? NSScreen.screens.first else { return }
|
||||
let point = NSEvent.mouseLocation
|
||||
let pointerScreen = NSScreen.screens.first { NSMouseInRect(point, $0.frame, false) }
|
||||
guard let screen = Self.selectedReflowScreen(
|
||||
currentPanel: panel.screen,
|
||||
lastKnown: screen(matchingFrame: activeScreenSnapshot?.screenFrame),
|
||||
preferred: preferredScreenProvider(),
|
||||
pointer: pointerScreen,
|
||||
fallback: NSScreen.screens.first
|
||||
) else { return }
|
||||
|
||||
activeScreenSnapshot = (screen.frame, screen.visibleFrame)
|
||||
let plan = Self.reflowPlan(forScreenFrame: screen.frame, visibleFrame: screen.visibleFrame)
|
||||
let plan = Self.reflowPlan(
|
||||
forScreenFrame: screen.frame,
|
||||
visibleFrame: screen.visibleFrame,
|
||||
side: settings.panelSide
|
||||
)
|
||||
panelView.setBottomSafeInset(plan.bottomSafeInset)
|
||||
|
||||
isAnimating = true
|
||||
@@ -647,6 +950,11 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel
|
||||
let fallback = preferredScreen() ?? NSScreen.screens.first
|
||||
return (fallback?.frame ?? .zero, fallback?.visibleFrame ?? .zero)
|
||||
}
|
||||
|
||||
private func screen(matchingFrame frame: CGRect?) -> NSScreen? {
|
||||
guard let frame else { return nil }
|
||||
return NSScreen.screens.first { $0.frame == frame }
|
||||
}
|
||||
}
|
||||
|
||||
private final class KeyablePanel: NSPanel {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
404
sources/clipbored/views/LinkPreviewWindowController.swift
Normal file
404
sources/clipbored/views/LinkPreviewWindowController.swift
Normal file
@@ -0,0 +1,404 @@
|
||||
import AppKit
|
||||
import WebKit
|
||||
|
||||
final class LinkPreviewWindowController: NSWindowController, WKNavigationDelegate {
|
||||
private enum Metrics {
|
||||
static let minimumWidth: CGFloat = 760
|
||||
static let minimumHeight: CGFloat = 420
|
||||
static let preferredHeight: CGFloat = 560
|
||||
static let margin: CGFloat = 24
|
||||
static let panelGap: CGFloat = 14
|
||||
static let toolbarHeight: CGFloat = 52
|
||||
static let toolbarLeadingInset: CGFloat = 86
|
||||
}
|
||||
|
||||
private let webView: WKWebView
|
||||
private let titleLabel = NSTextField(labelWithString: "")
|
||||
private let addressLabel = NSTextField(labelWithString: "")
|
||||
private let statusLabel = NSTextField(labelWithString: "")
|
||||
private let progressIndicator = NSProgressIndicator()
|
||||
private let backButton = NSButton()
|
||||
private let forwardButton = NSButton()
|
||||
private let reloadButton = NSButton()
|
||||
private let openExternalButton = NSButton()
|
||||
private let openURL: (URL) -> Void
|
||||
private var progressObservation: NSKeyValueObservation?
|
||||
private var titleObservation: NSKeyValueObservation?
|
||||
private var canGoBackObservation: NSKeyValueObservation?
|
||||
private var canGoForwardObservation: NSKeyValueObservation?
|
||||
private var currentRequest: LinkPreviewRequest?
|
||||
private var currentPageURL: URL?
|
||||
private var acceptsObservedPageTitles = false
|
||||
|
||||
init(openURL: @escaping (URL) -> Void = { _ = NSWorkspace.shared.open($0) }) {
|
||||
self.openURL = openURL
|
||||
let configuration = WKWebViewConfiguration()
|
||||
configuration.websiteDataStore = .nonPersistent()
|
||||
configuration.preferences.javaScriptCanOpenWindowsAutomatically = false
|
||||
|
||||
webView = WKWebView(frame: .zero, configuration: configuration)
|
||||
let window = NSWindow(
|
||||
contentRect: NSRect(x: 0, y: 0, width: Metrics.minimumWidth, height: Metrics.preferredHeight),
|
||||
styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView],
|
||||
backing: .buffered,
|
||||
defer: false
|
||||
)
|
||||
window.title = "Link Preview"
|
||||
window.minSize = NSSize(width: 560, height: 420)
|
||||
window.isReleasedWhenClosed = false
|
||||
window.titleVisibility = .hidden
|
||||
window.titlebarAppearsTransparent = true
|
||||
|
||||
super.init(window: window)
|
||||
configureContent(in: window)
|
||||
configureObservations()
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
func show(_ request: LinkPreviewRequest, relativeTo parent: NSWindow?) {
|
||||
prepareForPreview(request)
|
||||
if let parent, let window {
|
||||
window.setFrame(Self.previewFrame(relativeTo: parent), display: false)
|
||||
}
|
||||
window?.makeKeyAndOrderFront(nil)
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
webView.load(URLRequest(url: request.url))
|
||||
}
|
||||
|
||||
private func prepareForPreview(_ request: LinkPreviewRequest) {
|
||||
currentRequest = request
|
||||
currentPageURL = request.url
|
||||
acceptsObservedPageTitles = false
|
||||
setTitleText(Self.displayTitle(for: request))
|
||||
setAddress(request.url)
|
||||
setStatus("Loading")
|
||||
}
|
||||
|
||||
private static func displayTitle(for request: LinkPreviewRequest) -> String {
|
||||
request.title.clipboardTrimmed.isEmpty ? request.url.host ?? "Link Preview" : request.title
|
||||
}
|
||||
|
||||
static func previewFrame(relativeTo parent: NSWindow) -> NSRect {
|
||||
let visibleFrame = parent.screen?.visibleFrame ?? parent.frame
|
||||
return previewFrame(parentFrame: parent.frame, visibleFrame: visibleFrame)
|
||||
}
|
||||
|
||||
static func previewFrame(parentFrame: NSRect, visibleFrame: NSRect) -> NSRect {
|
||||
let usableWidth = max(1, visibleFrame.width - (Metrics.margin * 2))
|
||||
let width = min(max(Metrics.minimumWidth, floor(parentFrame.width * 0.72)), usableWidth)
|
||||
let usableHeight = max(1, visibleFrame.height - (Metrics.margin * 2))
|
||||
let abovePanelY = parentFrame.maxY + Metrics.panelGap
|
||||
let availableAbovePanel = visibleFrame.maxY - abovePanelY - Metrics.margin
|
||||
let preferredHeight = min(Metrics.preferredHeight, usableHeight)
|
||||
let height = availableAbovePanel >= Metrics.minimumHeight
|
||||
? min(preferredHeight, availableAbovePanel)
|
||||
: preferredHeight
|
||||
let centeredX = parentFrame.midX - (width / 2)
|
||||
let x = min(max(visibleFrame.minX + Metrics.margin, centeredX), visibleFrame.maxX - width - Metrics.margin)
|
||||
let preferredY = availableAbovePanel >= Metrics.minimumHeight ? abovePanelY : visibleFrame.midY - (height / 2)
|
||||
let y = min(max(visibleFrame.minY + Metrics.margin, preferredY), visibleFrame.maxY - height - Metrics.margin)
|
||||
return NSRect(x: floor(x), y: floor(y), width: floor(width), height: floor(height))
|
||||
}
|
||||
|
||||
private func configureContent(in window: NSWindow) {
|
||||
webView.navigationDelegate = self
|
||||
webView.allowsBackForwardNavigationGestures = true
|
||||
webView.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
let content = NSView()
|
||||
content.wantsLayer = true
|
||||
content.layer?.backgroundColor = NSColor.windowBackgroundColor.cgColor
|
||||
content.translatesAutoresizingMaskIntoConstraints = false
|
||||
window.contentView = content
|
||||
|
||||
let toolbar = NSVisualEffectView()
|
||||
toolbar.material = .windowBackground
|
||||
toolbar.blendingMode = .withinWindow
|
||||
toolbar.state = .active
|
||||
toolbar.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
let titleColumn = NSStackView(views: [titleLabel, addressLabel])
|
||||
titleColumn.orientation = .vertical
|
||||
titleColumn.alignment = .leading
|
||||
titleColumn.spacing = 2
|
||||
titleColumn.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
titleLabel.font = .systemFont(ofSize: NSFont.systemFontSize, weight: .semibold)
|
||||
titleLabel.lineBreakMode = .byTruncatingTail
|
||||
titleLabel.maximumNumberOfLines = 1
|
||||
titleLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
||||
|
||||
addressLabel.font = .monospacedSystemFont(ofSize: NSFont.smallSystemFontSize, weight: .regular)
|
||||
addressLabel.textColor = .secondaryLabelColor
|
||||
addressLabel.lineBreakMode = .byTruncatingMiddle
|
||||
addressLabel.maximumNumberOfLines = 1
|
||||
addressLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
||||
|
||||
let controls = NSStackView(views: [
|
||||
configuredButton(backButton, symbol: "chevron.left", toolTip: "Back", action: #selector(goBack)),
|
||||
configuredButton(forwardButton, symbol: "chevron.right", toolTip: "Forward", action: #selector(goForward)),
|
||||
configuredButton(reloadButton, symbol: "arrow.clockwise", toolTip: "Reload", action: #selector(reload)),
|
||||
configuredButton(openExternalButton, symbol: "arrow.up.right.square", toolTip: "Open in Browser", action: #selector(openInBrowser))
|
||||
])
|
||||
controls.orientation = .horizontal
|
||||
controls.alignment = .centerY
|
||||
controls.spacing = 4
|
||||
controls.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
progressIndicator.isIndeterminate = false
|
||||
progressIndicator.minValue = 0
|
||||
progressIndicator.maxValue = 1
|
||||
progressIndicator.controlSize = .small
|
||||
progressIndicator.style = .bar
|
||||
progressIndicator.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
statusLabel.font = .systemFont(ofSize: NSFont.smallSystemFontSize)
|
||||
statusLabel.textColor = .secondaryLabelColor
|
||||
statusLabel.lineBreakMode = .byTruncatingTail
|
||||
statusLabel.maximumNumberOfLines = 1
|
||||
statusLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
content.addSubview(toolbar)
|
||||
toolbar.addSubview(controls)
|
||||
toolbar.addSubview(titleColumn)
|
||||
toolbar.addSubview(statusLabel)
|
||||
toolbar.addSubview(progressIndicator)
|
||||
content.addSubview(webView)
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
toolbar.leadingAnchor.constraint(equalTo: content.leadingAnchor),
|
||||
toolbar.trailingAnchor.constraint(equalTo: content.trailingAnchor),
|
||||
toolbar.topAnchor.constraint(equalTo: content.topAnchor),
|
||||
toolbar.heightAnchor.constraint(equalToConstant: Metrics.toolbarHeight),
|
||||
|
||||
controls.leadingAnchor.constraint(equalTo: toolbar.leadingAnchor, constant: Metrics.toolbarLeadingInset),
|
||||
controls.centerYAnchor.constraint(equalTo: toolbar.centerYAnchor),
|
||||
|
||||
titleColumn.leadingAnchor.constraint(equalTo: controls.trailingAnchor, constant: 14),
|
||||
titleColumn.centerYAnchor.constraint(equalTo: toolbar.centerYAnchor),
|
||||
titleColumn.trailingAnchor.constraint(lessThanOrEqualTo: statusLabel.leadingAnchor, constant: -16),
|
||||
|
||||
statusLabel.trailingAnchor.constraint(equalTo: toolbar.trailingAnchor, constant: -16),
|
||||
statusLabel.centerYAnchor.constraint(equalTo: toolbar.centerYAnchor),
|
||||
statusLabel.widthAnchor.constraint(lessThanOrEqualToConstant: 180),
|
||||
|
||||
progressIndicator.leadingAnchor.constraint(equalTo: toolbar.leadingAnchor),
|
||||
progressIndicator.trailingAnchor.constraint(equalTo: toolbar.trailingAnchor),
|
||||
progressIndicator.bottomAnchor.constraint(equalTo: toolbar.bottomAnchor),
|
||||
progressIndicator.heightAnchor.constraint(equalToConstant: 2),
|
||||
|
||||
webView.leadingAnchor.constraint(equalTo: content.leadingAnchor),
|
||||
webView.trailingAnchor.constraint(equalTo: content.trailingAnchor),
|
||||
webView.topAnchor.constraint(equalTo: toolbar.bottomAnchor),
|
||||
webView.bottomAnchor.constraint(equalTo: content.bottomAnchor)
|
||||
])
|
||||
|
||||
updateNavigationButtons()
|
||||
}
|
||||
|
||||
private func configuredButton(
|
||||
_ button: NSButton,
|
||||
symbol: String,
|
||||
toolTip: String,
|
||||
action: Selector
|
||||
) -> NSButton {
|
||||
let image = NSImage(systemSymbolName: symbol, accessibilityDescription: toolTip)
|
||||
image?.isTemplate = true
|
||||
button.image = image
|
||||
button.imagePosition = .imageOnly
|
||||
button.imageScaling = .scaleProportionallyDown
|
||||
button.isBordered = false
|
||||
button.wantsLayer = true
|
||||
button.layer?.cornerRadius = 6
|
||||
button.layer?.backgroundColor = NSColor.labelColor.withAlphaComponent(0.06).cgColor
|
||||
button.contentTintColor = .labelColor
|
||||
button.toolTip = toolTip
|
||||
button.setAccessibilityLabel(toolTip)
|
||||
button.target = self
|
||||
button.action = action
|
||||
button.translatesAutoresizingMaskIntoConstraints = false
|
||||
button.widthAnchor.constraint(equalToConstant: 30).isActive = true
|
||||
button.heightAnchor.constraint(equalToConstant: 30).isActive = true
|
||||
return button
|
||||
}
|
||||
|
||||
private func configureObservations() {
|
||||
progressObservation = webView.observe(\.estimatedProgress, options: [.initial, .new]) { [weak self] webView, _ in
|
||||
self?.progressIndicator.doubleValue = webView.estimatedProgress
|
||||
self?.progressIndicator.isHidden = webView.estimatedProgress >= 1
|
||||
}
|
||||
titleObservation = webView.observe(\.title, options: [.new]) { [weak self] webView, _ in
|
||||
self?.applyObservedPageTitle(webView.title)
|
||||
}
|
||||
canGoBackObservation = webView.observe(\.canGoBack, options: [.initial, .new]) { [weak self] _, _ in
|
||||
self?.updateNavigationButtons()
|
||||
}
|
||||
canGoForwardObservation = webView.observe(\.canGoForward, options: [.initial, .new]) { [weak self] _, _ in
|
||||
self?.updateNavigationButtons()
|
||||
}
|
||||
}
|
||||
|
||||
private func updateNavigationButtons() {
|
||||
backButton.isEnabled = webView.canGoBack
|
||||
forwardButton.isEnabled = webView.canGoForward
|
||||
}
|
||||
|
||||
private func applyObservedPageTitle(_ title: String?) {
|
||||
guard acceptsObservedPageTitles,
|
||||
let title = title?.clipboardTrimmed,
|
||||
!title.isEmpty else {
|
||||
return
|
||||
}
|
||||
setTitleText(title)
|
||||
}
|
||||
|
||||
private func setTitleText(_ text: String) {
|
||||
titleLabel.stringValue = text
|
||||
titleLabel.toolTip = text
|
||||
window?.title = text
|
||||
}
|
||||
|
||||
private func setAddress(_ url: URL) {
|
||||
currentPageURL = url
|
||||
let text = url.absoluteString
|
||||
addressLabel.stringValue = text
|
||||
addressLabel.toolTip = text
|
||||
}
|
||||
|
||||
private func setStatus(_ text: String) {
|
||||
statusLabel.stringValue = text
|
||||
statusLabel.toolTip = text.isEmpty ? nil : text
|
||||
}
|
||||
|
||||
@objc private func goBack() {
|
||||
guard webView.canGoBack else { return }
|
||||
webView.goBack()
|
||||
}
|
||||
|
||||
@objc private func goForward() {
|
||||
guard webView.canGoForward else { return }
|
||||
webView.goForward()
|
||||
}
|
||||
|
||||
@objc private func reload() {
|
||||
webView.reload()
|
||||
}
|
||||
|
||||
@objc private func openInBrowser() {
|
||||
guard let url = currentPageURL ?? currentRequest?.url else { return }
|
||||
openURL(url)
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) {
|
||||
acceptsObservedPageTitles = true
|
||||
setStatus("Loading")
|
||||
if let url = webView.url {
|
||||
setAddress(url)
|
||||
}
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
||||
setStatus("")
|
||||
if let url = webView.url {
|
||||
setAddress(url)
|
||||
}
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
|
||||
handleNavigationFailure(error)
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
|
||||
handleNavigationFailure(error)
|
||||
}
|
||||
|
||||
private func handleNavigationFailure(_ error: Error) {
|
||||
guard !Self.isNavigationCancellation(error) else { return }
|
||||
setStatus("Could not load")
|
||||
}
|
||||
|
||||
private static func isNavigationCancellation(_ error: Error) -> Bool {
|
||||
let nsError = error as NSError
|
||||
if nsError.domain == NSURLErrorDomain && nsError.code == NSURLErrorCancelled {
|
||||
return true
|
||||
}
|
||||
if let underlying = nsError.userInfo[NSUnderlyingErrorKey] as? Error {
|
||||
return isNavigationCancellation(underlying)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func webView(
|
||||
_ webView: WKWebView,
|
||||
decidePolicyFor navigationAction: WKNavigationAction,
|
||||
decisionHandler: @escaping (WKNavigationActionPolicy) -> Void
|
||||
) {
|
||||
guard let url = navigationAction.request.url else {
|
||||
decisionHandler(.cancel)
|
||||
return
|
||||
}
|
||||
|
||||
let scheme = url.scheme?.lowercased()
|
||||
guard scheme == "http" || scheme == "https" else {
|
||||
openURL(url)
|
||||
decisionHandler(.cancel)
|
||||
return
|
||||
}
|
||||
|
||||
decisionHandler(.allow)
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
var debugTitleText: String {
|
||||
titleLabel.stringValue
|
||||
}
|
||||
|
||||
var debugAddressText: String {
|
||||
addressLabel.stringValue
|
||||
}
|
||||
|
||||
var debugStatusText: String {
|
||||
statusLabel.stringValue
|
||||
}
|
||||
|
||||
var debugTitleTooltip: String? {
|
||||
titleLabel.toolTip
|
||||
}
|
||||
|
||||
var debugAddressTooltip: String? {
|
||||
addressLabel.toolTip
|
||||
}
|
||||
|
||||
var debugStatusTooltip: String? {
|
||||
statusLabel.toolTip
|
||||
}
|
||||
|
||||
func debugPrepareForPreview(_ request: LinkPreviewRequest) {
|
||||
prepareForPreview(request)
|
||||
}
|
||||
|
||||
func debugSetDisplayedPageURL(_ url: URL) {
|
||||
setAddress(url)
|
||||
}
|
||||
|
||||
func debugOpenInBrowser() {
|
||||
openInBrowser()
|
||||
}
|
||||
|
||||
func debugAllowObservedPageTitles() {
|
||||
acceptsObservedPageTitles = true
|
||||
}
|
||||
|
||||
func debugApplyObservedPageTitle(_ title: String?) {
|
||||
applyObservedPageTitle(title)
|
||||
}
|
||||
|
||||
func debugApplyNavigationFailure(_ error: Error) {
|
||||
handleNavigationFailure(error)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
367
sources/clipbored/views/OnboardingWindowController.swift
Normal file
367
sources/clipbored/views/OnboardingWindowController.swift
Normal file
@@ -0,0 +1,367 @@
|
||||
import AppKit
|
||||
|
||||
final class OnboardingWindowController: NSObject, NSWindowDelegate {
|
||||
enum ShortcutChoice: String {
|
||||
case pasteStyle
|
||||
case clipBoredDefault
|
||||
case current
|
||||
}
|
||||
|
||||
struct PresentationChoice: Equatable {
|
||||
let showMenuBarIcon: Bool
|
||||
let showDockIcon: Bool
|
||||
}
|
||||
|
||||
static let pasteStyleOpenShortcut = ShortcutBinding(
|
||||
key: "v",
|
||||
modifierFlags: NSEvent.ModifierFlags.command.rawValue | NSEvent.ModifierFlags.shift.rawValue
|
||||
)
|
||||
|
||||
static func normalizedPresentation(showMenuBarIcon: Bool, showDockIcon: Bool) -> PresentationChoice {
|
||||
if showMenuBarIcon || showDockIcon {
|
||||
return PresentationChoice(showMenuBarIcon: showMenuBarIcon, showDockIcon: showDockIcon)
|
||||
}
|
||||
return PresentationChoice(showMenuBarIcon: true, showDockIcon: false)
|
||||
}
|
||||
|
||||
static func initialShortcutChoice(for binding: ShortcutBinding, onboardingCompleted: Bool) -> ShortcutChoice {
|
||||
if binding == pasteStyleOpenShortcut {
|
||||
return .pasteStyle
|
||||
}
|
||||
if binding == AppConfiguration.defaultOpenShortcut {
|
||||
return onboardingCompleted ? .clipBoredDefault : .pasteStyle
|
||||
}
|
||||
return .current
|
||||
}
|
||||
|
||||
static func shortcutBinding(for choice: ShortcutChoice, current: ShortcutBinding) -> ShortcutBinding {
|
||||
switch choice {
|
||||
case .pasteStyle:
|
||||
return pasteStyleOpenShortcut
|
||||
case .clipBoredDefault:
|
||||
return AppConfiguration.defaultOpenShortcut
|
||||
case .current:
|
||||
return current
|
||||
}
|
||||
}
|
||||
|
||||
private let settings: SettingsModel
|
||||
private let onOpenAccessibility: () -> Void
|
||||
private let onFinish: () -> Void
|
||||
private var window: NSWindow?
|
||||
private var didComplete = false
|
||||
|
||||
private let shortcutPopup = NSPopUpButton()
|
||||
private let historyRetentionPopup = NSPopUpButton()
|
||||
private let showMenuBarIconButton = NSButton()
|
||||
private let showDockIconButton = NSButton()
|
||||
private let launchAtLoginButton = NSButton()
|
||||
private let iCloudSyncButton = NSButton()
|
||||
private let permissionStatusLabel = NSTextField(labelWithString: "")
|
||||
|
||||
init(
|
||||
settings: SettingsModel,
|
||||
onOpenAccessibility: @escaping () -> Void,
|
||||
onFinish: @escaping () -> Void
|
||||
) {
|
||||
self.settings = settings
|
||||
self.onOpenAccessibility = onOpenAccessibility
|
||||
self.onFinish = onFinish
|
||||
super.init()
|
||||
|
||||
let window = NSWindow(
|
||||
contentRect: NSRect(x: 0, y: 0, width: 600, height: 540),
|
||||
styleMask: [.titled, .closable],
|
||||
backing: .buffered,
|
||||
defer: false
|
||||
)
|
||||
window.title = "Set Up ClipBored"
|
||||
window.contentView = makeContentView()
|
||||
window.delegate = self
|
||||
window.isReleasedWhenClosed = false
|
||||
window.center()
|
||||
self.window = window
|
||||
refreshFromSettings()
|
||||
}
|
||||
|
||||
func show() {
|
||||
guard let window else { return }
|
||||
refreshFromSettings()
|
||||
window.makeKeyAndOrderFront(nil)
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
}
|
||||
|
||||
func refreshPermissionStatus() {
|
||||
let isTrusted = AccessibilityPermissionService.isTrusted
|
||||
permissionStatusLabel.stringValue = isTrusted ? "Granted" : "Not granted; paste actions will copy instead."
|
||||
permissionStatusLabel.textColor = isTrusted ? .systemGreen : .systemOrange
|
||||
}
|
||||
|
||||
func windowWillClose(_ notification: Notification) {
|
||||
guard !didComplete else { return }
|
||||
completeSetup(applySelections: false, closeWindow: false)
|
||||
}
|
||||
|
||||
private func makeContentView() -> NSView {
|
||||
configureShortcutPopup()
|
||||
configureHistoryRetentionPopup()
|
||||
configureCheckbox(showMenuBarIconButton, title: "Show ClipBored in the menu bar", action: #selector(entryPointChanged))
|
||||
configureCheckbox(showDockIconButton, title: "Show ClipBored in the Dock", action: #selector(entryPointChanged))
|
||||
configureCheckbox(launchAtLoginButton, title: "Launch at login", action: nil)
|
||||
configureCheckbox(iCloudSyncButton, title: "Sync history with iCloud when available", action: nil)
|
||||
configureStatusLabel(permissionStatusLabel)
|
||||
|
||||
let content = NSView()
|
||||
let stack = NSStackView()
|
||||
stack.orientation = .vertical
|
||||
stack.alignment = .leading
|
||||
stack.spacing = 18
|
||||
stack.translatesAutoresizingMaskIntoConstraints = false
|
||||
content.addSubview(stack)
|
||||
|
||||
let titleLabel = NSTextField(labelWithString: "Set Up ClipBored")
|
||||
titleLabel.font = .boldSystemFont(ofSize: 22)
|
||||
let subtitleLabel = caption("Choose the shortcut, history window, and system entry points for this Mac.")
|
||||
let header = NSStackView(views: [titleLabel, subtitleLabel])
|
||||
header.orientation = .vertical
|
||||
header.alignment = .leading
|
||||
header.spacing = 4
|
||||
|
||||
stack.addArrangedSubview(header)
|
||||
stack.addArrangedSubview(section("Open ClipBored", [
|
||||
labeledRow("Shortcut", shortcutPopup)
|
||||
]))
|
||||
stack.addArrangedSubview(section("History", [
|
||||
labeledRow("Keep History", historyRetentionPopup)
|
||||
]))
|
||||
stack.addArrangedSubview(section("System", [
|
||||
showMenuBarIconButton,
|
||||
showDockIconButton,
|
||||
launchAtLoginButton,
|
||||
iCloudSyncButton
|
||||
]))
|
||||
stack.addArrangedSubview(section("Automatic Paste", [
|
||||
caption("Accessibility is only needed when ClipBored pastes directly into the previous app."),
|
||||
labeledRow("Accessibility", permissionStatusLabel),
|
||||
button("Open Accessibility Settings", #selector(openAccessibilitySettings))
|
||||
]))
|
||||
stack.addArrangedSubview(NSView())
|
||||
stack.addArrangedSubview(buttonRow())
|
||||
|
||||
if let spacer = stack.arrangedSubviews.dropLast().last {
|
||||
spacer.setContentHuggingPriority(.defaultLow, for: .vertical)
|
||||
}
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
stack.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 28),
|
||||
stack.trailingAnchor.constraint(equalTo: content.trailingAnchor, constant: -28),
|
||||
stack.topAnchor.constraint(equalTo: content.topAnchor, constant: 24),
|
||||
stack.bottomAnchor.constraint(equalTo: content.bottomAnchor, constant: -20),
|
||||
shortcutPopup.widthAnchor.constraint(greaterThanOrEqualToConstant: 220),
|
||||
historyRetentionPopup.widthAnchor.constraint(greaterThanOrEqualToConstant: 160)
|
||||
])
|
||||
|
||||
return content
|
||||
}
|
||||
|
||||
private func configureShortcutPopup() {
|
||||
shortcutPopup.removeAllItems()
|
||||
addShortcutItem("Shift-Command-V", .pasteStyle)
|
||||
addShortcutItem("Command-Option-V", .clipBoredDefault)
|
||||
if settings.openShortcut != Self.pasteStyleOpenShortcut,
|
||||
settings.openShortcut != AppConfiguration.defaultOpenShortcut {
|
||||
addShortcutItem("Keep Current (\(settings.openShortcut.displayText))", .current)
|
||||
}
|
||||
shortcutPopup.setAccessibilityLabel("Open ClipBored shortcut")
|
||||
}
|
||||
|
||||
private func configureHistoryRetentionPopup() {
|
||||
historyRetentionPopup.removeAllItems()
|
||||
for retention in HistoryRetention.allCases {
|
||||
historyRetentionPopup.addItem(withTitle: retention.title)
|
||||
historyRetentionPopup.lastItem?.representedObject = retention.rawValue
|
||||
}
|
||||
historyRetentionPopup.setAccessibilityLabel("Keep History")
|
||||
}
|
||||
|
||||
private func addShortcutItem(_ title: String, _ choice: ShortcutChoice) {
|
||||
shortcutPopup.addItem(withTitle: title)
|
||||
shortcutPopup.lastItem?.representedObject = choice.rawValue
|
||||
}
|
||||
|
||||
private func refreshFromSettings() {
|
||||
selectShortcut(Self.initialShortcutChoice(for: settings.openShortcut, onboardingCompleted: settings.onboardingCompleted))
|
||||
select(historyRetentionPopup, rawValue: settings.historyRetention.rawValue)
|
||||
let presentation = Self.normalizedPresentation(
|
||||
showMenuBarIcon: settings.showMenuBarIcon,
|
||||
showDockIcon: settings.showDockIcon
|
||||
)
|
||||
showMenuBarIconButton.state = presentation.showMenuBarIcon ? .on : .off
|
||||
showDockIconButton.state = presentation.showDockIcon ? .on : .off
|
||||
launchAtLoginButton.state = settings.launchAtLogin ? .on : .off
|
||||
iCloudSyncButton.state = settings.iCloudSyncEnabled ? .on : .off
|
||||
refreshPermissionStatus()
|
||||
}
|
||||
|
||||
private func section(_ title: String, _ views: [NSView]) -> NSView {
|
||||
let titleLabel = NSTextField(labelWithString: title)
|
||||
titleLabel.font = .boldSystemFont(ofSize: NSFont.systemFontSize)
|
||||
let stack = NSStackView(views: [titleLabel] + views)
|
||||
stack.orientation = .vertical
|
||||
stack.alignment = .leading
|
||||
stack.spacing = 8
|
||||
stack.widthAnchor.constraint(greaterThanOrEqualToConstant: 520).isActive = true
|
||||
return stack
|
||||
}
|
||||
|
||||
private func row(_ views: [NSView]) -> NSView {
|
||||
let stack = NSStackView(views: views)
|
||||
stack.orientation = .horizontal
|
||||
stack.alignment = .centerY
|
||||
stack.spacing = 10
|
||||
return stack
|
||||
}
|
||||
|
||||
private func labeledRow(_ title: String, _ control: NSView) -> NSView {
|
||||
let label = NSTextField(labelWithString: title)
|
||||
label.widthAnchor.constraint(equalToConstant: 120).isActive = true
|
||||
return row([label, control])
|
||||
}
|
||||
|
||||
private func caption(_ text: String) -> NSTextField {
|
||||
let label = NSTextField(wrappingLabelWithString: text)
|
||||
label.textColor = .secondaryLabelColor
|
||||
label.font = .systemFont(ofSize: NSFont.smallSystemFontSize)
|
||||
label.widthAnchor.constraint(lessThanOrEqualToConstant: 520).isActive = true
|
||||
return label
|
||||
}
|
||||
|
||||
private func button(_ title: String, _ action: Selector) -> NSButton {
|
||||
let control = NSButton(title: title, target: self, action: action)
|
||||
control.bezelStyle = .rounded
|
||||
control.setAccessibilityLabel(title)
|
||||
return control
|
||||
}
|
||||
|
||||
private func buttonRow() -> NSView {
|
||||
let skipButton = NSButton(title: "Skip", target: self, action: #selector(skipSetup))
|
||||
skipButton.bezelStyle = .rounded
|
||||
skipButton.setAccessibilityLabel("Skip setup")
|
||||
|
||||
let finishButton = NSButton(title: "Finish Setup", target: self, action: #selector(finishSetup))
|
||||
finishButton.bezelStyle = .rounded
|
||||
finishButton.keyEquivalent = "\r"
|
||||
finishButton.setAccessibilityLabel("Finish setup")
|
||||
|
||||
let spacer = NSView()
|
||||
let stack = NSStackView(views: [spacer, skipButton, finishButton])
|
||||
stack.orientation = .horizontal
|
||||
stack.alignment = .centerY
|
||||
stack.spacing = 10
|
||||
stack.widthAnchor.constraint(equalToConstant: 520).isActive = true
|
||||
spacer.setContentHuggingPriority(.defaultLow, for: .horizontal)
|
||||
return stack
|
||||
}
|
||||
|
||||
private func configureCheckbox(_ control: NSButton, title: String, action: Selector?) {
|
||||
control.setButtonType(.switch)
|
||||
control.title = title
|
||||
control.target = action == nil ? nil : self
|
||||
control.action = action
|
||||
control.setAccessibilityLabel(title)
|
||||
}
|
||||
|
||||
private func configureStatusLabel(_ label: NSTextField) {
|
||||
label.font = .systemFont(ofSize: NSFont.smallSystemFontSize)
|
||||
label.lineBreakMode = .byTruncatingTail
|
||||
}
|
||||
|
||||
private func selectShortcut(_ choice: ShortcutChoice) {
|
||||
for item in shortcutPopup.itemArray where item.representedObject as? String == choice.rawValue {
|
||||
shortcutPopup.select(item)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
private func select(_ popup: NSPopUpButton, rawValue: Int) {
|
||||
for item in popup.itemArray where item.representedObject as? Int == rawValue {
|
||||
popup.select(item)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
private func selectedShortcutChoice() -> ShortcutChoice {
|
||||
guard let rawValue = shortcutPopup.selectedItem?.representedObject as? String,
|
||||
let choice = ShortcutChoice(rawValue: rawValue)
|
||||
else {
|
||||
return .pasteStyle
|
||||
}
|
||||
return choice
|
||||
}
|
||||
|
||||
@objc private func entryPointChanged() {
|
||||
let presentation = Self.normalizedPresentation(
|
||||
showMenuBarIcon: showMenuBarIconButton.state == .on,
|
||||
showDockIcon: showDockIconButton.state == .on
|
||||
)
|
||||
showMenuBarIconButton.state = presentation.showMenuBarIcon ? .on : .off
|
||||
showDockIconButton.state = presentation.showDockIcon ? .on : .off
|
||||
}
|
||||
|
||||
@objc private func openAccessibilitySettings() {
|
||||
onOpenAccessibility()
|
||||
refreshPermissionStatus()
|
||||
}
|
||||
|
||||
@objc private func finishSetup() {
|
||||
completeSetup(applySelections: true, closeWindow: true)
|
||||
}
|
||||
|
||||
@objc private func skipSetup() {
|
||||
completeSetup(applySelections: false, closeWindow: true)
|
||||
}
|
||||
|
||||
private func completeSetup(applySelections: Bool, closeWindow: Bool) {
|
||||
guard !didComplete else { return }
|
||||
didComplete = true
|
||||
|
||||
if applySelections {
|
||||
applySelectedSettings()
|
||||
}
|
||||
settings.markAccessibilityNoticeShown()
|
||||
settings.markOnboardingCompleted()
|
||||
|
||||
if closeWindow {
|
||||
window?.delegate = nil
|
||||
window?.close()
|
||||
}
|
||||
onFinish()
|
||||
}
|
||||
|
||||
private func applySelectedSettings() {
|
||||
settings.openShortcut = Self.shortcutBinding(for: selectedShortcutChoice(), current: settings.openShortcut)
|
||||
if let rawValue = historyRetentionPopup.selectedItem?.representedObject as? Int,
|
||||
let retention = HistoryRetention(rawValue: rawValue) {
|
||||
settings.historyRetention = retention
|
||||
}
|
||||
|
||||
let presentation = Self.normalizedPresentation(
|
||||
showMenuBarIcon: showMenuBarIconButton.state == .on,
|
||||
showDockIcon: showDockIconButton.state == .on
|
||||
)
|
||||
settings.showMenuBarIcon = presentation.showMenuBarIcon
|
||||
settings.showDockIcon = presentation.showDockIcon
|
||||
settings.launchAtLogin = launchAtLoginButton.state == .on
|
||||
settings.iCloudSyncEnabled = iCloudSyncButton.state == .on
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
var debugShowMenuBarIconIsEnabled: Bool {
|
||||
showMenuBarIconButton.state == .on
|
||||
}
|
||||
|
||||
var debugShowDockIconIsEnabled: Bool {
|
||||
showDockIconButton.state == .on
|
||||
}
|
||||
#endif
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user