UI fixes
Some checks failed
CI / Test And Build (push) Has been cancelled

This commit is contained in:
Akshay Kolli
2026-07-08 01:13:02 -04:00
parent c7316105c7
commit d86d392c3a
46 changed files with 17543 additions and 1561 deletions

View File

@@ -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)
}

View File

@@ -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

View File

@@ -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 {

View File

@@ -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))
}
}

View File

@@ -0,0 +1,6 @@
import Foundation
struct LinkPreviewRequest: Equatable {
let url: URL
let title: String
}

View File

@@ -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] = [:]

View File

@@ -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)
}
}

View 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
}
}
}

View File

@@ -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? {

View 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)
}
}

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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))
}
}

View File

@@ -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: " ")
)
}
}

View File

@@ -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:

View File

@@ -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)"
}

View File

@@ -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

View 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
}

View 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