This commit is contained in:
Akshay Kolli
2026-06-30 01:12:19 -07:00
commit 4c1c6b2f37
55 changed files with 13180 additions and 0 deletions

View File

@@ -0,0 +1,512 @@
import AppKit
final class AppDelegate: NSObject, NSApplicationDelegate {
struct StatusMenuPresentation: Equatable {
let summary: String
let detail: String?
}
private static let statusMenuTextLimit = 68
private var cacheService: ClipboardCacheService!
private var settings: SettingsModel!
private var store: ClipboardStore!
private var monitor: ClipboardMonitorService!
private var panelController: ClipboardPanelController!
private var settingsController: SettingsWindowController!
private var shortcutManager: ShortcutManager!
private var lifecycleService: AppLifecycleService!
private var statusItem: NSStatusItem?
private var statusMenu: NSMenu?
func applicationDidFinishLaunching(_ notification: Notification) {
settings = SettingsModel()
cacheService = ClipboardCacheService()
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()
},
openSettings: { [weak self] in
self?.openSettings()
}
)
settingsController = SettingsWindowController(settings: settings, store: store, cacheService: cacheService)
lifecycleService = AppLifecycleService()
shortcutManager = ShortcutManager(
onOpenClipboardPanel: { [weak self] in
DispatchQueue.main.async {
self?.panelController.toggle()
}
},
onOpenSettings: { [weak self] in
DispatchQueue.main.async {
self?.refreshAccessibilityPermissionMessage()
self?.settingsController.show()
}
},
onStatusChange: { [weak self] status in
DispatchQueue.main.async {
self?.settings.setShortcutStatus(message: status.message)
}
},
openShortcut: settings.openShortcut,
settingsShortcut: settings.settingsShortcut
)
bindSettings()
monitor.setPaused(settings.pauseCapture)
monitor.start()
shortcutManager.start()
applyLaunchAtLoginSetting(settings.launchAtLogin)
refreshStatusItem()
configureMainMenu()
requestInitialAccessibilityPermissionIfNeeded()
}
func applicationDidBecomeActive(_ notification: Notification) {
refreshAccessibilityPermissionMessage()
}
func applicationWillTerminate(_ notification: Notification) {
monitor.stop()
shortcutManager.stop()
cacheService.clearTemporaryPreviews(wait: true)
if settings.clearHistoryOnQuit {
store.removeAll()
store.flushPersistenceForTesting()
cacheService.clearCache()
cacheService.flushForTesting()
}
}
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
false
}
@objc private func showClipboardPanel() {
panelController.toggle()
}
@objc private func statusItemClicked(_ sender: NSStatusBarButton) {
let event = NSApp.currentEvent
if shouldOpenStatusMenu(for: event) {
popStatusMenu(from: sender)
return
}
showClipboardPanel()
}
private func shouldOpenStatusMenu(for event: NSEvent?) -> Bool {
guard let event else { return false }
return Self.shouldOpenStatusMenu(eventType: event.type, modifierFlags: event.modifierFlags)
}
static func shouldOpenStatusMenu(eventType: NSEvent.EventType, modifierFlags: NSEvent.ModifierFlags) -> Bool {
switch eventType {
case .rightMouseDown, .rightMouseUp:
return true
case .leftMouseDown, .leftMouseUp:
return modifierFlags.intersection(.deviceIndependentFlagsMask).contains(.control)
case .otherMouseDown, .otherMouseUp:
return true
case .leftMouseDragged, .rightMouseDragged, .otherMouseDragged:
return false
default:
return false
}
}
private func statusMenuTemplate() -> NSMenu {
Self.makeStatusMenu(
presentation: Self.statusMenuPresentation(
historyCount: store.items.count,
isCapturePaused: settings.pauseCapture,
captureStatus: settings.captureStatusMessage,
pasteStatus: settings.pasteStatusMessage,
shortcutStatus: settings.shortcutStatusMessage,
accessibilityStatus: settings.accessibilityPermissionStatusMessage,
launchAtLoginStatus: settings.launchAtLoginErrorMessage
),
isCapturePaused: settings.pauseCapture,
openShortcut: settings.openShortcut,
settingsShortcut: settings.settingsShortcut,
target: self
)
}
private func refreshStatusMenu() {
statusMenu = statusMenuTemplate()
}
static func statusMenuPresentation(
historyCount: Int,
isCapturePaused: Bool,
captureStatus: String,
pasteStatus: String,
shortcutStatus: String,
accessibilityStatus: String,
launchAtLoginStatus: String
) -> StatusMenuPresentation {
let captureState = isCapturePaused ? "Capture Paused" : "Capture Running"
let summary = "\(captureState) - \(clipCountText(historyCount))"
let status = firstPresentStatus([
isCapturePaused ? "Capture is paused." : nil,
captureStatus,
pasteStatus,
shortcutStatus,
launchAtLoginStatus,
accessibilityStatus
])
return StatusMenuPresentation(
summary: boundedStatusText(summary),
detail: status.map { boundedStatusText($0) }
)
}
static func makeStatusMenu(
presentation: StatusMenuPresentation,
isCapturePaused: Bool,
openShortcut: ShortcutBinding,
settingsShortcut: ShortcutBinding,
target: AnyObject?
) -> NSMenu {
let menu = NSMenu(title: "ClipBored")
menu.autoenablesItems = false
addDisabledMenuItem("ClipBored", to: menu, symbolName: "doc.on.clipboard")
addDisabledMenuItem(presentation.summary, to: menu, symbolName: isCapturePaused ? "pause.circle" : "checkmark.circle")
if let detail = presentation.detail {
addDisabledMenuItem(detail, to: menu, symbolName: "info.circle")
}
menu.addItem(NSMenuItem.separator())
addActionMenuItem(
"Show Clipboard",
action: #selector(showClipboardPanel),
target: target,
keyEquivalent: openShortcut.key,
keyEquivalentModifierMask: modifierFlags(for: openShortcut),
symbolName: "rectangle.bottomthird.inset.filled",
to: menu
)
addActionMenuItem(
"Settings\u{2026}",
action: #selector(openSettings),
target: target,
keyEquivalent: settingsShortcut.key,
keyEquivalentModifierMask: modifierFlags(for: settingsShortcut),
symbolName: "gearshape",
to: menu
)
menu.addItem(NSMenuItem.separator())
let pause = addActionMenuItem(
isCapturePaused ? "Resume Capture" : "Pause Capture",
action: #selector(togglePauseCapture),
target: target,
symbolName: isCapturePaused ? "play.fill" : "pause.fill",
to: menu
)
pause.state = isCapturePaused ? .on : .off
menu.addItem(NSMenuItem.separator())
addActionMenuItem(
"Quit ClipBored",
action: #selector(quitApp),
target: target,
keyEquivalent: "q",
keyEquivalentModifierMask: .command,
symbolName: "power",
to: menu
)
return menu
}
@objc private func openSettings() {
refreshAccessibilityPermissionMessage()
settingsController.show()
}
@objc private func togglePauseCapture() {
settings.pauseCapture.toggle()
}
@objc private func quitApp() {
NSApp.terminate(nil)
}
private func refreshStatusItem() {
guard settings.showMenuBarIcon else {
if let statusItem {
NSStatusBar.system.removeStatusItem(statusItem)
self.statusItem = nil
}
return
}
if statusItem == nil {
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength)
}
if let button = statusItem?.button {
if let icon = appIconImage() {
button.image = icon
} else if let icon = NSImage(systemSymbolName: "doc.on.clipboard.fill", accessibilityDescription: "ClipBored") {
icon.isTemplate = true
button.image = icon
}
button.toolTip = "ClipBored"
button.sendAction(on: [
.leftMouseUp,
.rightMouseUp,
.otherMouseUp
])
button.target = self
button.action = #selector(statusItemClicked(_:))
}
refreshStatusMenu()
statusItem?.menu = nil
}
private func popStatusMenu(from button: NSStatusBarButton) {
refreshStatusMenu()
statusMenu?.popUp(positioning: nil, at: NSPoint(x: 0, y: button.bounds.maxY), in: button)
}
@discardableResult
private func addMenuItem(_ title: String, _ action: Selector, to menu: NSMenu) -> NSMenuItem {
let item = NSMenuItem(title: title, action: action, keyEquivalent: "")
item.target = self
item.isEnabled = true
menu.addItem(item)
return item
}
private static func firstPresentStatus(_ candidates: [String?]) -> String? {
for candidate in candidates {
guard let value = candidate?.clipboardTrimmed, !value.isEmpty else { continue }
return value
}
return nil
}
private static func clipCountText(_ count: Int) -> String {
if count <= 0 { return "No clips" }
if count == 1 { return "1 clip" }
return "\(count) clips"
}
private static func boundedStatusText(_ value: String) -> String {
let collapsed = value
.split { $0.isWhitespace || $0.isNewline }
.joined(separator: " ")
.clipboardTrimmed
guard collapsed.count > statusMenuTextLimit else { return collapsed }
return String(collapsed.prefix(statusMenuTextLimit - 3)).clipboardTrimmed + "..."
}
@discardableResult
private static func addDisabledMenuItem(_ title: String, to menu: NSMenu, symbolName: String) -> NSMenuItem {
let item = NSMenuItem(title: title, action: nil, keyEquivalent: "")
item.isEnabled = false
item.image = menuImage(symbolName)
menu.addItem(item)
return item
}
@discardableResult
private static func addActionMenuItem(
_ title: String,
action: Selector,
target: AnyObject?,
keyEquivalent: String = "",
keyEquivalentModifierMask: NSEvent.ModifierFlags = [],
symbolName: String,
to menu: NSMenu
) -> NSMenuItem {
let item = NSMenuItem(title: title, action: action, keyEquivalent: keyEquivalent)
item.keyEquivalentModifierMask = keyEquivalentModifierMask
item.target = target
item.isEnabled = true
item.image = menuImage(symbolName)
menu.addItem(item)
return item
}
private static func menuImage(_ symbolName: String) -> NSImage? {
guard let image = NSImage(systemSymbolName: symbolName, accessibilityDescription: nil) else {
return nil
}
image.size = NSSize(width: 15, height: 15)
image.isTemplate = true
return image
}
private static func modifierFlags(for binding: ShortcutBinding) -> NSEvent.ModifierFlags {
NSEvent.ModifierFlags(rawValue: binding.modifierFlags)
}
private func configureMainMenu() {
let appMenu = NSMenuItem()
let appSubMenu = NSMenu(title: "ClipBored")
let settingsShortcut = self.settings.settingsShortcut
let settings = NSMenuItem(
title: "Settings…",
action: #selector(openSettings),
keyEquivalent: settingsShortcut.key
)
settings.keyEquivalentModifierMask = menuModifierFlags(settingsShortcut)
settings.target = self
appSubMenu.addItem(settings)
appSubMenu.addItem(NSMenuItem.separator())
let quit = NSMenuItem(title: "Quit ClipBored", action: #selector(quitApp), keyEquivalent: "q")
quit.target = self
appSubMenu.addItem(quit)
appMenu.submenu = appSubMenu
let editMenu = NSMenuItem()
let editSubMenu = NSMenu(title: "Edit")
let openShortcut = self.settings.openShortcut
let showClipboard = NSMenuItem(
title: "Show Clipboard",
action: #selector(showClipboardPanel),
keyEquivalent: openShortcut.key
)
showClipboard.keyEquivalentModifierMask = menuModifierFlags(openShortcut)
showClipboard.target = self
editSubMenu.addItem(showClipboard)
editMenu.submenu = editSubMenu
let mainMenu = NSMenu()
mainMenu.addItem(appMenu)
mainMenu.addItem(editMenu)
NSApp.mainMenu = mainMenu
}
private func bindSettings() {
settings.observe { [weak self] change in
guard let self else { return }
DispatchQueue.main.async {
self.handleSettingsChange(change)
}
}
}
private func handleSettingsChange(_ change: SettingsModel.Change) {
switch change {
case .maxHistoryItems:
store.updateHistoryLimit(settings.maxHistoryItems)
case .imageCacheMaxBytes:
cacheService.purgeIfNeeded(maxBytes: settings.imageCacheMaxBytes)
case .openShortcut, .settingsShortcut:
let status = shortcutManager.reconfigure(openShortcut: settings.openShortcut, settingsShortcut: settings.settingsShortcut)
settings.setShortcutStatus(message: status.message)
refreshStatusItem()
configureMainMenu()
case .launchAtLogin:
applyLaunchAtLoginSetting(settings.launchAtLogin)
case .showMenuBarIcon:
refreshStatusItem()
case .pauseCapture:
monitor.setPaused(settings.pauseCapture)
if settings.showMenuBarIcon {
refreshStatusMenu()
}
case .pollProfile:
monitor.setPaused(settings.pauseCapture)
case .status, .other:
break
case .captureStatus:
break
}
}
private func applyLaunchAtLoginSetting(_ shouldLaunch: Bool) {
let result = lifecycleService.applyLaunchAtLogin(shouldLaunch)
switch result {
case .success:
settings.setLaunchAtLoginStatus(message: "")
if settings.launchAtLogin != shouldLaunch {
settings.launchAtLogin = shouldLaunch
}
case .noChange:
settings.setLaunchAtLoginStatus(message: "")
case .failure(let message):
settings.setLaunchAtLoginStatus(message: "Launch-at-login failed: \(message)")
let actualState = lifecycleService.isEnabled()
if settings.launchAtLogin != actualState {
settings.launchAtLogin = actualState
}
}
}
private func refreshAccessibilityPermissionMessage() {
if AccessibilityPermissionService.isTrusted {
settings.setAccessibilityPermissionStatus(message: "")
} else {
settings.setAccessibilityPermissionStatus(message: "Accessibility permission not granted. Capture still works; paste falls back to copy.")
}
refreshStatusItem()
}
private func requestInitialAccessibilityPermissionIfNeeded() {
refreshAccessibilityPermissionMessage()
if AccessibilityPermissionService.isTrusted {
return
}
if !settings.accessibilityNoticeShown {
settings.markAccessibilityNoticeShown()
DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { [weak self] in
self?.showAccessibilityPermissionNoticeIfNeeded()
}
}
}
private func showAccessibilityPermissionNoticeIfNeeded() {
guard !AccessibilityPermissionService.isTrusted else {
refreshAccessibilityPermissionMessage()
return
}
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.addButton(withTitle: "Open Accessibility Settings")
alert.addButton(withTitle: "Later")
alert.alertStyle = .warning
if alert.runModal() == .alertFirstButtonReturn {
_ = AccessibilityPermissionService.requestPromptIfNeeded()
if !AccessibilityPermissionService.isTrusted {
AccessibilityPermissionService.openSystemSettings()
}
}
refreshAccessibilityPermissionMessage()
}
private func menuModifierFlags(_ binding: ShortcutBinding) -> NSEvent.ModifierFlags {
NSEvent.ModifierFlags(rawValue: binding.modifierFlags)
}
private func appIconImage() -> NSImage? {
guard let url = Bundle.main.url(forResource: "AppIcon", withExtension: "icns"),
let icon = NSImage(contentsOf: url)
else {
return nil
}
icon.size = NSSize(width: 18, height: 18)
icon.isTemplate = false
return icon
}
}

View File

@@ -0,0 +1,14 @@
import AppKit
@main
struct ClipBoredApp {
private static let appDelegate = AppDelegate()
static func main() {
let application = NSApplication.shared
application.setActivationPolicy(.accessory)
application.delegate = appDelegate
application.activate(ignoringOtherApps: true)
application.run()
}
}

View File

@@ -0,0 +1,117 @@
import Foundation
import AppKit
enum AppConfiguration {
static let appName = "ClipBored"
static let storageDirectoryOverrideEnvironmentKey = "CLIPBORED_STORAGE_DIR"
static let defaultHistoryLength = 300
static let minHistoryLength = 50
static let maxHistoryLength = 2000
static let defaultCacheMaxBytes: Int64 = 120 * 1024 * 1024
static let maxPinnedItems = 250
static let maxFullImagePixelSize: CGFloat = 1600
static let maxRecognizedImageTextLength = 4096
static let maxImageCacheFiles = 1000
static let defaultOpenShortcut = ShortcutBinding(key: "v", modifierFlags: NSEvent.ModifierFlags.command.rawValue | NSEvent.ModifierFlags.option.rawValue)
static let defaultSettingsShortcut = ShortcutBinding(key: ",", modifierFlags: NSEvent.ModifierFlags.command.rawValue)
static let defaultIgnoredApps: [String] = [
"1password",
"bitwarden",
"lastpass",
"dashlane",
"keeper",
"keepass",
"authy"
]
static let defaultPollProfile: PollProfile = .balanced
static let minResponsiveActiveInterval: TimeInterval = 0.075
enum PollProfile: Int {
case battery = 0
case balanced = 1
case responsive = 2
static let allCases: [PollProfile] = [.battery, .balanced, .responsive]
var title: String {
switch self {
case .battery: return "Battery Saver"
case .balanced: return "Balanced"
case .responsive: return "Responsive"
}
}
var idleInterval: TimeInterval {
switch self {
case .battery: return 0.50
case .balanced: return 0.25
case .responsive: return 0.12
}
}
var activeInterval: TimeInterval {
switch self {
case .battery: return 0.12
case .balanced: return 0.075
case .responsive: return 0.075
}
}
var idleRecoveryWindow: TimeInterval {
switch self {
case .battery: return 3.5
case .balanced: return 2.0
case .responsive: return 1.2
}
}
}
}
struct ShortcutBinding: Equatable {
let key: String
let modifierFlags: UInt
init(key: String, modifierFlags: UInt) {
self.key = key.lowercased()
self.modifierFlags = modifierFlags
}
var displayText: String {
var text = ""
if modifierFlags & NSEvent.ModifierFlags.command.rawValue != 0 { text += "" }
if modifierFlags & NSEvent.ModifierFlags.option.rawValue != 0 { text += "" }
if modifierFlags & NSEvent.ModifierFlags.control.rawValue != 0 { text += "" }
if modifierFlags & NSEvent.ModifierFlags.shift.rawValue != 0 { text += "" }
return text + key.uppercased()
}
func matches(_ event: NSEvent) -> Bool {
guard let eventChar = event.charactersIgnoringModifiers?.lowercased(), eventChar.count == 1 else {
return false
}
let mask = event.modifierFlags.rawValue & (
NSEvent.ModifierFlags.command.rawValue |
NSEvent.ModifierFlags.option.rawValue |
NSEvent.ModifierFlags.control.rawValue |
NSEvent.ModifierFlags.shift.rawValue
)
return eventChar == key && mask == modifierFlags
}
func encoded() -> String {
"\(modifierFlags)|\(key)"
}
func has(_ flag: NSEvent.ModifierFlags) -> Bool {
modifierFlags & flag.rawValue != 0
}
init?(encoded value: String) {
let parts = value.split(separator: "|")
guard parts.count == 2, let flags = UInt(parts[0]), let key = parts.last else {
return nil
}
self.key = String(key).lowercased()
self.modifierFlags = flags
}
}

View File

@@ -0,0 +1,34 @@
import AppKit
extension NSImage {
func resized(to fitSize: CGSize) -> NSImage {
let target = NSSize(width: fitSize.width, height: fitSize.height)
let currentSize = size
let ratio = min(target.width / currentSize.width, target.height / currentSize.height, 1.0)
let newSize = NSSize(width: currentSize.width * ratio, height: currentSize.height * ratio)
let newImage = NSImage(size: newSize)
newImage.lockFocus()
draw(
in: NSRect(origin: .zero, size: newSize),
from: NSRect(origin: .zero, size: currentSize),
operation: .sourceOver,
fraction: 1.0
)
newImage.unlockFocus()
newImage.size = newSize
return newImage
}
func pngData() -> Data? {
guard let cgImage = cgImage(forProposedRect: nil, context: nil, hints: nil) else { return nil }
let rep = NSBitmapImageRep(cgImage: cgImage)
return rep.representation(using: .png, properties: [:])
}
}
extension NSView {
var isInAnyViewHierarchy: Bool {
return window != nil
}
}

View File

@@ -0,0 +1,19 @@
extension StringProtocol {
var clipboardTrimmed: String {
var start = startIndex
var end = endIndex
while start < end, self[start].isWhitespace {
formIndex(after: &start)
}
while end > start {
let previous = index(before: end)
if !self[previous].isWhitespace {
break
}
end = previous
}
return String(self[start..<end])
}
}

View File

@@ -0,0 +1,181 @@
import Foundation
enum ClipboardItemKind: Int {
case text = 0
case url
case image
case richText
case file
case unknown
case pdf
case audio
var displayName: String {
switch self {
case .text: return "text"
case .url: return "link"
case .image: return "image"
case .richText: return "rich text"
case .file: return "file"
case .unknown: return "item"
case .pdf: return "PDF"
case .audio: return "audio"
}
}
}
extension ClipboardItemKind {
var canOpen: Bool {
switch self {
case .url, .file, .image, .pdf, .audio:
return true
case .text, .richText, .unknown:
return false
}
}
var canReveal: Bool {
switch self {
case .file, .image, .pdf, .audio:
return true
case .text, .richText, .unknown, .url:
return false
}
}
var hasManagedCacheReference: Bool {
switch self {
case .url, .image, .pdf, .audio, .richText:
return true
case .text, .file, .unknown:
return false
}
}
}
enum ClipboardSortMode: Int {
case mostRecent = 0
case mostUsed
case images
case links
case text
case pinned
case files
case audio
static let allCases: [ClipboardSortMode] = [.mostRecent, .mostUsed, .text, .links, .images, .audio, .files, .pinned]
var title: String {
switch self {
case .mostRecent: return "Most Recent"
case .mostUsed: return "Most Used"
case .images: return "Images"
case .links: return "Links"
case .text: return "Text"
case .pinned: return "Pinned"
case .files: return "Files"
case .audio: return "Audio"
}
}
}
enum ClipboardCollectionDefaults {
static let names = [
"Useful Links",
"Important Notes",
"Code Snippets",
"Read Later"
]
static func normalizedName(_ value: String?) -> String? {
guard let value else { return nil }
let name = value
.split { $0.isWhitespace }
.joined(separator: " ")
.clipboardTrimmed
guard !name.isEmpty else { return nil }
return String(name.prefix(40))
}
}
struct ClipboardItem {
var id: UUID
var kind: ClipboardItemKind
var displayText: String
var payload: String
var payloadHash: String
var createdAt: Date
var lastUsedAt: Date
var useCount: Int
var sourceApp: String?
var imagePath: String?
var thumbnailPath: String?
var isPinned: Bool
var sourceAppBundleId: String?
var ocrText: String?
var collectionName: String?
var searchableText: String {
var text = kindLabel + " " + displayText.lowercased() + " " + payload.lowercased()
if let sourceApp {
text += " " + sourceApp.lowercased()
}
if let ocrText {
text += " " + ocrText.lowercased()
}
if let sourceAppBundleId {
text += " " + sourceAppBundleId.lowercased()
}
if let collectionName {
text += " " + collectionName.lowercased()
}
return text
}
private var kindLabel: String {
switch kind {
case .text: return "text"
case .url: return "link url"
case .image: return "image"
case .richText: return "richtext rtf"
case .file: return "file"
case .unknown: return "unknown"
case .pdf: return "pdf document"
case .audio: return "audio sound"
}
}
init(
id: UUID,
kind: ClipboardItemKind,
displayText: String,
payload: String,
payloadHash: String,
createdAt: Date,
lastUsedAt: Date,
useCount: Int,
sourceApp: String?,
imagePath: String?,
thumbnailPath: String?,
isPinned: Bool = false,
sourceAppBundleId: String? = nil,
ocrText: String? = nil,
collectionName: String? = nil
) {
self.id = id
self.kind = kind
self.displayText = displayText
self.payload = payload
self.payloadHash = payloadHash
self.createdAt = createdAt
self.lastUsedAt = lastUsedAt
self.useCount = useCount
self.sourceApp = sourceApp
self.imagePath = imagePath
self.thumbnailPath = thumbnailPath
self.isPinned = isPinned
self.sourceAppBundleId = sourceAppBundleId
self.ocrText = ocrText
self.collectionName = collectionName
}
}

View File

@@ -0,0 +1,26 @@
import Foundation
enum FilePayload {
static func paths(from payload: String) -> [String] {
payload
.split(separator: "\n", omittingEmptySubsequences: true)
.map { String($0).clipboardTrimmed }
.filter { !$0.isEmpty }
}
static func urls(from payload: String) -> [URL] {
paths(from: payload).map(fileURL(from:))
}
static func payload(from urls: [URL]) -> String {
urls.map(\.path).joined(separator: "\n")
}
static func fileURL(from value: String) -> URL {
let trimmed = value.clipboardTrimmed
if trimmed.lowercased().hasPrefix("file://"), let url = URL(string: trimmed) {
return url
}
return URL(fileURLWithPath: trimmed)
}
}

View File

@@ -0,0 +1,214 @@
import Foundation
final class SettingsModel {
enum Change {
case maxHistoryItems
case imageCacheMaxBytes
case openShortcut
case settingsShortcut
case launchAtLogin
case showMenuBarIcon
case pauseCapture
case pollProfile
case captureStatus
case status
case other
}
enum Keys {
static let maxHistoryItems = "maxHistoryItems"
static let defaultSortMode = "defaultSortMode"
static let imageCacheMaxBytes = "imageCacheMaxBytes"
static let includeImageTextInSearch = "includeImageTextInSearch"
static let pruneDuplicates = "pruneDuplicates"
static let launchAtLogin = "launchAtLogin"
static let showMenuBarIcon = "showMenuBarIcon"
static let openShortcut = "openShortcut"
static let settingsShortcut = "settingsShortcut"
static let ignoredApps = "ignoredApps"
static let ignoredItemKinds = "ignoredItemKinds"
static let pollProfile = "pollProfile"
static let keepFirstImage = "keepFirstImage"
static let excludeSensitive = "excludeSensitive"
static let pauseCapture = "pauseCapture"
static let clearHistoryOnQuit = "clearHistoryOnQuit"
static let accessibilityNoticeShown = "accessibilityNoticeShown"
}
var maxHistoryItems: Int {
didSet { if oldValue != maxHistoryItems { storeAndNotify(.maxHistoryItems) } }
}
var defaultSortMode: ClipboardSortMode {
didSet { if oldValue != defaultSortMode { storeAndNotify(.other) } }
}
var imageCacheMaxBytes: Int64 {
didSet { if oldValue != imageCacheMaxBytes { storeAndNotify(.imageCacheMaxBytes) } }
}
var includeImageTextInSearch: Bool {
didSet { if oldValue != includeImageTextInSearch { storeAndNotify(.other) } }
}
var pruneDuplicates: Bool {
didSet { if oldValue != pruneDuplicates { storeAndNotify(.other) } }
}
var launchAtLogin: Bool {
didSet { if oldValue != launchAtLogin { storeAndNotify(.launchAtLogin) } }
}
var showMenuBarIcon: Bool {
didSet { if oldValue != showMenuBarIcon { storeAndNotify(.showMenuBarIcon) } }
}
var openShortcut: ShortcutBinding {
didSet { if oldValue != openShortcut { storeAndNotify(.openShortcut) } }
}
var settingsShortcut: ShortcutBinding {
didSet { if oldValue != settingsShortcut { storeAndNotify(.settingsShortcut) } }
}
var ignoredApps: [String] {
didSet { if oldValue != ignoredApps { storeAndNotify(.other) } }
}
var ignoredItemKindsRaw: [Int] {
didSet { if oldValue != ignoredItemKindsRaw { storeAndNotify(.other) } }
}
var pollProfileRaw: AppConfiguration.PollProfile {
didSet { if oldValue != pollProfileRaw { storeAndNotify(.pollProfile) } }
}
var keepFirstImage: Bool {
didSet { if oldValue != keepFirstImage { storeAndNotify(.other) } }
}
var excludeSensitive: Bool {
didSet { if oldValue != excludeSensitive { storeAndNotify(.other) } }
}
var pauseCapture: Bool {
didSet { if oldValue != pauseCapture { storeAndNotify(.pauseCapture) } }
}
var clearHistoryOnQuit: Bool {
didSet { if oldValue != clearHistoryOnQuit { storeAndNotify(.other) } }
}
private(set) var launchAtLoginErrorMessage: String = ""
private(set) var accessibilityPermissionStatusMessage: String = ""
private(set) var captureStatusMessage: String = ""
private(set) var shortcutStatusMessage: String = ""
private(set) var pasteStatusMessage: String = ""
private(set) var accessibilityNoticeShown: Bool
private let defaults: UserDefaults
private var observers: [(Change) -> Void] = []
init(defaults: UserDefaults = .standard) {
self.defaults = defaults
let savedHistory = defaults.integer(forKey: Keys.maxHistoryItems)
let savedSort = defaults.integer(forKey: Keys.defaultSortMode)
let savedCache = defaults.integer(forKey: Keys.imageCacheMaxBytes)
maxHistoryItems = savedHistory > 0 ? savedHistory : AppConfiguration.defaultHistoryLength
defaultSortMode = ClipboardSortMode(rawValue: savedSort) ?? .mostRecent
imageCacheMaxBytes = savedCache > 0 ? Int64(savedCache) : AppConfiguration.defaultCacheMaxBytes
includeImageTextInSearch = defaults.object(forKey: Keys.includeImageTextInSearch) as? Bool ?? false
pruneDuplicates = defaults.object(forKey: Keys.pruneDuplicates) as? Bool ?? true
launchAtLogin = defaults.object(forKey: Keys.launchAtLogin) as? Bool ?? false
showMenuBarIcon = defaults.object(forKey: Keys.showMenuBarIcon) as? Bool ?? true
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 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
clearHistoryOnQuit = defaults.object(forKey: Keys.clearHistoryOnQuit) as? Bool ?? false
accessibilityNoticeShown = defaults.object(forKey: Keys.accessibilityNoticeShown) as? Bool ?? false
maxHistoryItems = max(AppConfiguration.minHistoryLength, min(AppConfiguration.maxHistoryLength, maxHistoryItems))
imageCacheMaxBytes = max(4 * 1024 * 1024, imageCacheMaxBytes)
if defaults.object(forKey: Keys.maxHistoryItems) == nil {
store()
}
}
private func store() {
defaults.set(maxHistoryItems, forKey: Keys.maxHistoryItems)
defaults.set(defaultSortMode.rawValue, forKey: Keys.defaultSortMode)
defaults.set(imageCacheMaxBytes, forKey: Keys.imageCacheMaxBytes)
defaults.set(includeImageTextInSearch, forKey: Keys.includeImageTextInSearch)
defaults.set(pruneDuplicates, forKey: Keys.pruneDuplicates)
defaults.set(launchAtLogin, forKey: Keys.launchAtLogin)
defaults.set(showMenuBarIcon, forKey: Keys.showMenuBarIcon)
defaults.set(openShortcut.encoded(), forKey: Keys.openShortcut)
defaults.set(settingsShortcut.encoded(), forKey: Keys.settingsShortcut)
defaults.set(ignoredApps, forKey: Keys.ignoredApps)
defaults.set(ignoredItemKindsRaw, forKey: Keys.ignoredItemKinds)
defaults.set(pollProfileRaw.rawValue, forKey: Keys.pollProfile)
defaults.set(keepFirstImage, forKey: Keys.keepFirstImage)
defaults.set(excludeSensitive, forKey: Keys.excludeSensitive)
defaults.set(pauseCapture, forKey: Keys.pauseCapture)
defaults.set(clearHistoryOnQuit, forKey: Keys.clearHistoryOnQuit)
}
func observe(_ observer: @escaping (Change) -> Void) {
observers.append(observer)
}
private func storeAndNotify(_ change: Change) {
store()
notify(change)
}
private func notify(_ change: Change) {
for observer in observers {
observer(change)
}
}
func setLaunchAtLoginStatus(message: String) {
guard launchAtLoginErrorMessage != message else { return }
launchAtLoginErrorMessage = message
notify(.status)
}
func setAccessibilityPermissionStatus(message: String) {
guard accessibilityPermissionStatusMessage != message else { return }
accessibilityPermissionStatusMessage = message
notify(.status)
}
func setCaptureStatus(message: String) {
guard captureStatusMessage != message else { return }
captureStatusMessage = message
notify(.captureStatus)
}
func markAccessibilityNoticeShown() {
guard !accessibilityNoticeShown else { return }
accessibilityNoticeShown = true
defaults.set(true, forKey: Keys.accessibilityNoticeShown)
}
func setShortcutStatus(message: String) {
guard shortcutStatusMessage != message else { return }
shortcutStatusMessage = message
notify(.status)
}
func setPasteStatus(message: String) {
guard pasteStatusMessage != message else { return }
pasteStatusMessage = message
notify(.status)
}
private static func readShortcut(from value: String?) -> ShortcutBinding? {
guard let value else { return nil }
return ShortcutBinding(encoded: value)
}
var pollProfile: AppConfiguration.PollProfile {
get { pollProfileRaw }
set { pollProfileRaw = newValue }
}
func sanitizeLimits() {
maxHistoryItems = max(AppConfiguration.minHistoryLength, min(AppConfiguration.maxHistoryLength, maxHistoryItems))
imageCacheMaxBytes = max(4 * 1024 * 1024, imageCacheMaxBytes)
}
}

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

View File

@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDisplayName</key>
<string>ClipBored</string>
<key>CFBundleName</key>
<string>ClipBored</string>
<key>CFBundleIdentifier</key>
<string>com.local.clipbored</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>CFBundleShortVersionString</key>
<string>0.1.0</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleExecutable</key>
<string>ClipBored</string>
<key>CFBundleIconFile</key>
<string>AppIcon.icns</string>
<key>LSUIElement</key>
<true/>
<key>LSMinimumSystemVersion</key>
<string>13.0</string>
<key>NSHumanReadableCopyright</key>
<string>ClipBored</string>
</dict>
</plist>

View File

@@ -0,0 +1,32 @@
import ApplicationServices
import Foundation
import AppKit
enum AccessibilityPermissionService {
static var isTrusted: Bool {
AXIsProcessTrusted()
}
@discardableResult
static func requestPromptIfNeeded() -> Bool {
if isTrusted {
return true
}
guard let optionPrompt = kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String? else {
return false
}
let options: CFDictionary = [optionPrompt: true] as CFDictionary
return AXIsProcessTrustedWithOptions(options)
}
static func openSystemSettings() {
if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility"),
NSWorkspace.shared.open(url) {
return
}
let fallback = URL(fileURLWithPath: "/System/Applications/System Settings.app")
_ = NSWorkspace.shared.open(fallback)
}
}

View File

@@ -0,0 +1,40 @@
import Foundation
import ServiceManagement
final class AppLifecycleService {
enum LaunchAtLoginResult: Equatable {
case success
case noChange
case failure(String)
}
private let service = SMAppService.mainApp
func applyLaunchAtLogin(_ enabled: Bool) -> LaunchAtLoginResult {
do {
if enabled {
switch service.status {
case .notRegistered:
try service.register()
return .success
case .enabled:
return .noChange
default:
try service.register()
return .success
}
} else if service.status == .enabled {
try service.unregister()
return .success
} else {
return .noChange
}
} catch {
return .failure(error.localizedDescription)
}
}
func isEnabled() -> Bool {
service.status == .enabled
}
}

View File

@@ -0,0 +1,342 @@
import AppKit
import Foundation
final class ClipboardCacheService {
private let thumbnailCache = NSCache<NSString, NSImage>()
private let fileManager = FileManager.default
private let queue = DispatchQueue(label: "clipboard.cache.service", qos: .utility)
private let imageDirectory: URL
private let attachmentDirectory: URL
private let temporaryPreviewDirectory: URL
private let encryptionService: ClipboardEncryptionService
init(baseURL: URL? = nil, encryptionService: ClipboardEncryptionService = ClipboardEncryptionService()) {
let base = baseURL ?? ClipboardStore.storageDirectory()
imageDirectory = base.appendingPathComponent("images", isDirectory: true)
attachmentDirectory = base.appendingPathComponent("attachments", isDirectory: true)
temporaryPreviewDirectory = FileManager.default.temporaryDirectory
.appendingPathComponent(AppConfiguration.appName, isDirectory: true)
.appendingPathComponent("Previews", isDirectory: true)
self.encryptionService = encryptionService
thumbnailCache.countLimit = 128
try? fileManager.createDirectory(at: imageDirectory, withIntermediateDirectories: true)
try? fileManager.createDirectory(at: attachmentDirectory, withIntermediateDirectories: true)
hardenDirectory(imageDirectory)
hardenDirectory(attachmentDirectory)
clearTemporaryPreviews()
}
func cacheImage(_ image: NSImage, id: UUID) -> (full: String, thumb: String)? {
let fullURL = imageDirectory.appendingPathComponent("\(id.uuidString).png")
let thumbURL = imageDirectory.appendingPathComponent("thumb-\(id.uuidString).png")
let boundedFullImage = image.resized(to: .init(width: AppConfiguration.maxFullImagePixelSize, height: AppConfiguration.maxFullImagePixelSize))
guard let fullData = boundedFullImage.pngData() else { return nil }
let thumbnail = image.resized(to: .init(width: 320, height: 320))
guard let thumbData = thumbnail.pngData() else { return nil }
do {
try encrypted(fullData).write(to: fullURL, options: .atomic)
try encrypted(thumbData).write(to: thumbURL, options: .atomic)
hardenFile(fullURL)
hardenFile(thumbURL)
thumbnailCache.setObject(thumbImage(thumbData) ?? image, forKey: thumbURL.path as NSString)
return (full: fullURL.path, thumb: thumbURL.path)
} catch {
return nil
}
}
func cachePDF(_ data: Data, id: UUID) -> String? {
cacheAttachment(data, id: id, fileExtension: "pdf")
}
func cacheAudio(_ data: Data, id: UUID) -> String? {
cacheAttachment(data, id: id, fileExtension: "sound")
}
func cacheRichText(_ data: Data, id: UUID) -> String? {
cacheAttachment(data, id: id, fileExtension: "rtf")
}
private func cacheAttachment(_ data: Data, id: UUID, fileExtension: String) -> String? {
let url = attachmentDirectory.appendingPathComponent("\(id.uuidString).\(fileExtension)")
do {
try encrypted(data).write(to: url, options: .atomic)
hardenFile(url)
return url.path
} catch {
return nil
}
}
func image(for path: String) -> NSImage? {
let key = NSString(string: path)
if let cached = thumbnailCache.object(forKey: key) {
return cached
}
guard let data = data(for: path), let image = NSImage(data: data) else { return nil }
thumbnailCache.setObject(image, forKey: key)
return image
}
func previewThumbnail(for item: ClipboardItem) -> NSImage? {
switch item.kind {
case .url, .image:
guard let path = item.thumbnailPath else { return nil }
return image(for: path)
case .pdf:
let key = NSString(string: "pdf-preview:\(item.id.uuidString):\(item.payload)")
if let cached = thumbnailCache.object(forKey: key) {
return cached
}
if let data = data(for: item.payload),
let image = NSImage(data: data),
hasDrawableSize(image) {
let thumbnail = image.resized(to: CGSize(width: 260, height: 132))
thumbnailCache.setObject(thumbnail, forKey: key)
return thumbnail
}
return filePreviewThumbnail(for: item.payload)
case .file:
return filePreviewThumbnail(for: item.payload)
case .text, .unknown, .audio, .richText:
return nil
}
}
func data(for path: String) -> Data? {
let url = URL(fileURLWithPath: path)
guard let stored = try? Data(contentsOf: url) else {
return nil
}
if ClipboardEncryptionService.isProtected(stored) {
return encryptionService.unprotectData(stored)
}
if isManagedSidecar(path: path), encryptionService.isAvailable {
try? encrypted(stored).write(to: url, options: .atomic)
hardenFile(url)
}
return stored
}
private func filePreviewThumbnail(for path: String) -> NSImage? {
guard let url = fileURL(from: path), fileManager.fileExists(atPath: url.path) else {
return nil
}
let key = NSString(string: "file-preview:\(url.standardizedFileURL.path)")
if let cached = thumbnailCache.object(forKey: key) {
return cached
}
let image: NSImage
if let decoded = NSImage(contentsOf: url), decoded.isValid, hasDrawableSize(decoded) {
image = decoded.resized(to: CGSize(width: 260, height: 132))
} else {
let icon = NSWorkspace.shared.icon(forFile: url.path)
icon.size = NSSize(width: 96, height: 96)
image = icon
}
thumbnailCache.setObject(image, forKey: key)
return image
}
private func hasDrawableSize(_ image: NSImage) -> Bool {
image.size.width > 0 && image.size.height > 0
}
private func fileURL(from path: String) -> URL? {
let trimmed = path.clipboardTrimmed
guard !trimmed.isEmpty else { return nil }
if trimmed.lowercased().hasPrefix("file://"), let url = URL(string: trimmed) {
return url
}
return URL(fileURLWithPath: trimmed)
}
func temporaryReadableURL(for item: ClipboardItem) -> URL? {
switch item.kind {
case .image:
guard let path = item.imagePath, let data = data(for: path) else { return nil }
return writeTemporaryCopy(data: data, id: item.id, fileExtension: "png")
case .pdf:
guard let data = data(for: item.payload) else { return nil }
return writeTemporaryCopy(data: data, id: item.id, fileExtension: "pdf")
case .audio:
guard let data = data(for: item.payload) else { return nil }
return writeTemporaryCopy(data: data, id: item.id, fileExtension: "sound")
case .richText:
guard let data = data(for: item.payload) else { return nil }
return writeTemporaryCopy(data: data, id: item.id, fileExtension: "rtf")
default:
return nil
}
}
func encryptCachedReferencesIfNeeded(for items: [ClipboardItem]) {
queue.async { [weak self] in
guard let self else { return }
for item in items {
if let imagePath = item.imagePath {
_ = self.data(for: imagePath)
}
if let thumbnailPath = item.thumbnailPath {
_ = self.data(for: thumbnailPath)
}
if (item.kind == .pdf || item.kind == .audio || item.kind == .richText), self.isManagedAttachment(path: item.payload) {
_ = self.data(for: item.payload)
}
}
}
}
func removeCachedReferences(_ item: ClipboardItem) {
queue.async { [weak self] in
guard let self else { return }
if let path = item.imagePath {
try? self.fileManager.removeItem(atPath: path)
self.thumbnailCache.removeObject(forKey: NSString(string: path))
}
if let path = item.thumbnailPath {
try? self.fileManager.removeItem(atPath: path)
self.thumbnailCache.removeObject(forKey: NSString(string: path))
}
if (item.kind == .pdf || item.kind == .audio || item.kind == .richText), self.isManagedAttachment(path: item.payload) {
try? self.fileManager.removeItem(atPath: item.payload)
}
}
}
func purgeIfNeeded(maxBytes: Int64) {
queue.async {
DiagnosticsService.shared.incrementCachePurge()
let urls = (try? self.fileManager.contentsOfDirectory(at: self.imageDirectory, includingPropertiesForKeys: nil, options: [])) ?? []
var items: [(url: URL, size: Int64, date: Date)] = []
var totalSize: Int64 = 0
for url in urls {
guard
let attrs = try? self.fileManager.attributesOfItem(atPath: url.path),
let size = attrs[.size] as? NSNumber,
let mod = attrs[.modificationDate] as? Date
else { continue }
let bytes = Int64(size.int64Value)
totalSize += bytes
items.append((url, bytes, mod))
}
if totalSize <= maxBytes && items.count <= AppConfiguration.maxImageCacheFiles {
return
}
let ordered = items.sorted { $0.date < $1.date }
var remaining = totalSize
var pointer = 0
while (remaining > maxBytes || ordered.count - pointer > AppConfiguration.maxImageCacheFiles) && pointer < ordered.count {
let candidate = ordered[pointer]
try? self.fileManager.removeItem(at: candidate.url)
self.thumbnailCache.removeObject(forKey: NSString(string: candidate.url.path))
remaining -= candidate.size
pointer += 1
}
}
}
func clearCache() {
queue.async { [weak self] in
guard let self else { return }
thumbnailCache.removeAllObjects()
let contents = (try? self.fileManager.contentsOfDirectory(at: self.imageDirectory, includingPropertiesForKeys: nil, options: [])) ?? []
for url in contents {
try? self.fileManager.removeItem(at: url)
}
self.removeTemporaryPreviewFiles()
}
}
func clearTemporaryPreviews(wait: Bool = false) {
let work: () -> Void = { [weak self] in
guard let self else { return }
self.removeTemporaryPreviewFiles()
}
if wait {
queue.sync(execute: work)
} else {
queue.async(execute: work)
}
}
func flushForTesting() {
queue.sync {}
}
private func thumbImage(_ data: Data) -> NSImage? {
NSImage(data: data)
}
private func encrypted(_ data: Data) -> Data {
encryptionService.protectData(data)
}
private func hardenDirectory(_ url: URL) {
try? fileManager.setAttributes([.posixPermissions: 0o700], ofItemAtPath: url.path)
}
private func hardenFile(_ url: URL) {
try? fileManager.setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path)
}
private func isManagedAttachment(path: String) -> Bool {
URL(fileURLWithPath: path).deletingLastPathComponent().standardizedFileURL == attachmentDirectory.standardizedFileURL
}
private func isManagedSidecar(path: String) -> Bool {
let directory = URL(fileURLWithPath: path).deletingLastPathComponent().standardizedFileURL
return directory == imageDirectory.standardizedFileURL || directory == attachmentDirectory.standardizedFileURL
}
private func writeTemporaryCopy(data: Data, id: UUID, fileExtension: String) -> URL? {
let url = temporaryPreviewDirectory.appendingPathComponent("\(id.uuidString)-\(UUID().uuidString).\(fileExtension)")
do {
try fileManager.createDirectory(at: temporaryPreviewDirectory, withIntermediateDirectories: true)
try fileManager.setAttributes([.posixPermissions: 0o700], ofItemAtPath: temporaryPreviewDirectory.path)
try data.write(to: url, options: .atomic)
hardenFile(url)
return url
} catch {
return nil
}
}
private func removeTemporaryPreviewFiles() {
guard fileManager.fileExists(atPath: temporaryPreviewDirectory.path) else {
return
}
let contents = (try? fileManager.contentsOfDirectory(
at: temporaryPreviewDirectory,
includingPropertiesForKeys: nil,
options: []
)) ?? []
for url in contents {
try? fileManager.removeItem(at: url)
}
if ((try? fileManager.contentsOfDirectory(at: temporaryPreviewDirectory, includingPropertiesForKeys: nil)) ?? []).isEmpty {
try? fileManager.removeItem(at: temporaryPreviewDirectory)
}
}
}

View File

@@ -0,0 +1,284 @@
import CryptoKit
import Foundation
import LocalAuthentication
import Security
// Swift marks SecAccessCreate deprecated, but macOS still needs kSecAttrAccess
// here to avoid legacy keychain authorization UI during generic-password creation.
@_silgen_name("SecAccessCreate")
private func clipBoredSecAccessCreate(
_ descriptor: CFString,
_ trustedList: CFArray?,
_ accessRef: UnsafeMutablePointer<SecAccess?>
) -> OSStatus
final class ClipboardEncryptionService {
static let marker = "clipbored:v1:"
private static let markerData = Data(marker.utf8)
private let keyProvider: () -> SymmetricKey?
private let resetProvider: () -> Void
init() {
keyProvider = { ClipboardEncryptionKeychain.shared.symmetricKey() }
resetProvider = { ClipboardEncryptionKeychain.shared.resetStoredKey() }
}
init(keyProvider: @escaping () -> SymmetricKey?, resetProvider: @escaping () -> Void = {}) {
self.keyProvider = keyProvider
self.resetProvider = resetProvider
}
var isAvailable: Bool {
keyProvider() != nil
}
func protect(_ value: String?) -> String? {
guard let value else { return nil }
guard let key = keyProvider() else {
return value
}
guard let sealed = try? AES.GCM.seal(Data(value.utf8), using: key),
let combined = sealed.combined
else {
return value
}
return Self.marker + combined.base64EncodedString()
}
func unprotect(_ value: String?) -> String? {
guard let value else { return nil }
guard Self.isProtected(value) else { return value }
let encoded = String(value.dropFirst(Self.marker.count))
guard let data = Data(base64Encoded: encoded),
let sealed = try? AES.GCM.SealedBox(combined: data)
else {
return value
}
guard let key = keyProvider(),
let decrypted = try? AES.GCM.open(sealed, using: key)
else {
return nil
}
return String(data: decrypted, encoding: .utf8)
}
func protectData(_ data: Data) -> Data {
guard let key = keyProvider() else {
return data
}
guard let sealed = try? AES.GCM.seal(data, using: key),
let combined = sealed.combined
else {
return data
}
var output = Self.markerData
output.append(combined)
return output
}
func unprotectData(_ data: Data) -> Data? {
guard Self.isProtected(data) else {
return data
}
let encrypted = data.dropFirst(Self.markerData.count)
guard let sealed = try? AES.GCM.SealedBox(combined: encrypted),
let key = keyProvider(),
let decrypted = try? AES.GCM.open(sealed, using: key)
else {
return nil
}
return decrypted
}
static func isProtected(_ value: String) -> Bool {
value.hasPrefix(marker)
}
static func isProtected(_ data: Data) -> Bool {
data.starts(with: markerData)
}
func resetStoredKey() {
resetProvider()
}
}
private enum ClipboardEncryptionKeychain {
static let shared = KeychainBackedKeyProvider()
}
private final class KeychainBackedKeyProvider {
private let queue = DispatchQueue(label: "clipboard.encryption-keychain")
private let keychainTimeout: TimeInterval = 0.35
private var cachedKey: SymmetricKey?
func symmetricKey() -> SymmetricKey? {
queue.sync {
if let cachedKey {
return cachedKey
}
if let fallback = readFallbackKeyData() {
let key = SymmetricKey(data: fallback)
cachedKey = key
return key
}
if let existing = readKeyData() {
let key = SymmetricKey(data: existing)
cachedKey = key
return key
}
let generated = SymmetricKey(size: .bits256)
let data = generated.withUnsafeBytes { Data($0) }
if saveKeyData(data) {
cachedKey = generated
return generated
}
guard let fallback = loadOrCreateFallbackKeyData() else {
return nil
}
let fallbackKey = SymmetricKey(data: fallback)
cachedKey = fallbackKey
return fallbackKey
}
}
func resetStoredKey() {
queue.sync {
cachedKey = nil
_ = deleteKeyData()
deleteFallbackKeyData()
}
}
private func readKeyData() -> Data? {
runKeychainOperation {
var query = self.baseQuery()
query[kSecReturnData as String] = true
query[kSecMatchLimit as String] = kSecMatchLimitOne
query[kSecUseAuthenticationContext as String] = self.nonInteractiveContext()
var result: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess else {
return nil
}
return result as? Data
}
}
private func saveKeyData(_ data: Data) -> Bool {
runKeychainOperation {
var query = self.baseQuery()
query[kSecValueData as String] = data
query[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
query[kSecUseAuthenticationContext as String] = self.nonInteractiveContext()
if let access = self.keychainAccess() {
query[kSecAttrAccess as String] = access
}
let status = SecItemAdd(query as CFDictionary, nil)
if status == errSecSuccess {
return true
}
if status == errSecDuplicateItem {
return self.readKeyData() != nil
}
return false
} ?? false
}
private func runKeychainOperation<T>(_ operation: @escaping () -> T?) -> T? {
let lock = NSLock()
var result: T?
let semaphore = DispatchSemaphore(value: 0)
DispatchQueue.global(qos: .utility).async {
let value = operation()
lock.lock()
result = value
lock.unlock()
semaphore.signal()
}
guard semaphore.wait(timeout: .now() + keychainTimeout) == .success else {
return nil
}
lock.lock()
defer { lock.unlock() }
return result
}
private func baseQuery() -> [String: Any] {
[
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: "com.local.clipbored.encryption",
kSecAttrAccount as String: "history-v1"
]
}
private func nonInteractiveContext() -> LAContext {
let context = LAContext()
context.interactionNotAllowed = true
return context
}
private func keychainAccess() -> SecAccess? {
var access: SecAccess?
let status = clipBoredSecAccessCreate("ClipBored encryption key" as CFString, nil, &access)
guard status == errSecSuccess else {
return nil
}
return access
}
private func loadOrCreateFallbackKeyData() -> Data? {
if let existing = readFallbackKeyData() {
return existing
}
let generated = SymmetricKey(size: .bits256)
let data = generated.withUnsafeBytes { Data($0) }
guard saveFallbackKeyData(data) else {
return nil
}
return data
}
private func readFallbackKeyData() -> Data? {
let url = fallbackKeyURL()
guard let data = try? Data(contentsOf: url), data.count == 32 else {
return nil
}
return data
}
private func saveFallbackKeyData(_ data: Data) -> Bool {
let url = fallbackKeyURL()
let directory = url.deletingLastPathComponent()
do {
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: directory.path)
try data.write(to: url, options: [.atomic])
try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path)
return true
} catch {
return false
}
}
private func deleteKeyData() -> Bool {
runKeychainOperation {
let status = SecItemDelete(self.baseQuery() as CFDictionary)
return status == errSecSuccess || status == errSecItemNotFound
} ?? false
}
private func deleteFallbackKeyData() {
try? FileManager.default.removeItem(at: fallbackKeyURL())
}
private func fallbackKeyURL() -> URL {
let base = ClipboardStore.storageDirectory()
return base.appendingPathComponent("history-encryption.key")
}
}

View File

@@ -0,0 +1,761 @@
import AppKit
import Foundation
final class ClipboardMonitorService {
private let store: ClipboardStore
private let cacheService: ClipboardCacheService
private let settings: SettingsModel
private let imageTextExtractor: (NSImage) -> String?
private var timer: DispatchSourceTimer?
private let queue = DispatchQueue(label: "clipboard.monitor", qos: .utility)
private let queueKey = DispatchSpecificKey<Void>()
private var lastChangeCount: Int
private var lastActiveChange = Date.distantPast
private var scheduledInterval: TimeInterval = 0
private var didReportReadFailure = false
private(set) var isPaused = false
init(
store: ClipboardStore,
cacheService: ClipboardCacheService,
settings: SettingsModel,
imageTextExtractor: @escaping (NSImage) -> String? = ImageTextExtractor.recognizedText(in:)
) {
self.store = store
self.cacheService = cacheService
self.settings = settings
self.imageTextExtractor = imageTextExtractor
self.lastChangeCount = NSPasteboard.general.changeCount
queue.setSpecific(key: queueKey, value: ())
}
func start() {
if isPaused {
reportCaptureStatus("Capture is paused.")
scheduleTimer(interval: 1.0)
return
}
reportCaptureStatus("Capture is running. Waiting for clipboard changes.")
scheduleTimer(interval: effectiveProfile.idleInterval)
pollNow()
}
func pollNow() {
queue.async { [weak self] in
self?.pollPasteboard(rescheduleAfterCapture: false)
}
}
func pollNowAndWait() {
if DispatchQueue.getSpecific(key: queueKey) != nil {
pollPasteboard(rescheduleAfterCapture: false)
} else {
queue.sync {
pollPasteboard(rescheduleAfterCapture: false)
}
}
}
func setPaused(_ paused: Bool) {
isPaused = paused
if paused {
reportCaptureStatus("Capture is paused.")
scheduleTimer(interval: 1.0)
lastActiveChange = .distantPast
} else {
reportCaptureStatus("Capture resumed. Waiting for clipboard changes.")
scheduleTimer(interval: effectiveProfile.idleInterval)
lastActiveChange = .distantPast
}
}
func stop() {
timer?.cancel()
timer = nil
scheduledInterval = 0
}
#if DEBUG
var scheduledIntervalForTesting: TimeInterval {
scheduledInterval
}
#endif
private var effectiveProfile: AppConfiguration.PollProfile {
settings.pollProfile
}
private func scheduleTimer(interval: TimeInterval) {
let effective = clampedInterval(interval)
if timer != nil && scheduledInterval == effective {
return
}
timer?.cancel()
let newTimer = DispatchSource.makeTimerSource(queue: queue)
timer = nil
scheduledInterval = 0
newTimer.schedule(deadline: .now() + effective, repeating: effective, leeway: .milliseconds(12))
newTimer.setEventHandler { [weak self] in
self?.tick()
}
newTimer.resume()
timer = newTimer
scheduledInterval = effective
}
private func tick() {
DiagnosticsService.shared.incrementMonitorTick()
pollPasteboard(rescheduleAfterCapture: true)
}
private func pollPasteboard(rescheduleAfterCapture: Bool) {
if isPaused {
reportCaptureStatus("Capture is paused.")
return
}
let pasteboard = NSPasteboard.general
let changeCount = pasteboard.changeCount
if changeCount == lastChangeCount {
if Date().timeIntervalSince(lastActiveChange) > effectiveProfile.idleRecoveryWindow {
if rescheduleAfterCapture {
scheduleTimer(interval: effectiveProfile.idleInterval)
}
}
return
}
lastChangeCount = changeCount
lastActiveChange = Date()
if ClipboardSelfWriteTracker.consume(changeCount: changeCount) {
reportReadFailureStatus("Clipboard was updated by ClipBored; skipping capture.")
return
}
DiagnosticsService.shared.incrementPasteboardChange()
didReportReadFailure = false
if let item = readCurrentItem(from: pasteboard) {
reportCaptured(item)
DispatchQueue.main.async { [weak self] in
self?.store.upsert(item)
}
} else if !didReportReadFailure {
reportCaptureStatus("Clipboard changed, but ClipBored could not read a supported item.")
}
if rescheduleAfterCapture {
scheduleTimer(interval: effectiveProfile.activeInterval)
}
}
func clampedInterval(_ interval: TimeInterval) -> TimeInterval {
max(interval, AppConfiguration.minResponsiveActiveInterval)
}
private func readCurrentItem(from pasteboard: NSPasteboard) -> ClipboardItem? {
DiagnosticsService.shared.incrementExtractionAttempt()
let source = frontmostApp()
func isIgnored(_ kind: ClipboardItemKind) -> Bool {
return settings.ignoredItemKindsRaw.contains(kind.rawValue)
}
func ignoredKindMessage(_ kind: ClipboardItemKind) -> String {
return "\(displayNameForStatus(kind)) items are ignored in capture settings."
}
if isSourceIgnored(source) {
reportReadFailureStatus("Ignored clipboard change from \(sourceDescription(source)).")
return nil
}
if isIgnored(.file), hasFileItems(on: pasteboard) {
reportReadFailureStatus(ignoredKindMessage(.file))
return nil
}
if let filePayload = itemFromFiles(pasteboard, sourceApp: source.name, sourceBundleId: source.bundleId) {
return filePayload
}
let url = urlPayloadFromPasteboard(pasteboard)
if isIgnored(.url), url != nil {
reportReadFailureStatus(ignoredKindMessage(.url))
return nil
}
if let url, hasImage(on: pasteboard) {
return itemFromURL(url.url, title: url.title, sourceApp: source.name, sourceBundleId: source.bundleId, previewPasteboard: pasteboard)
}
if isIgnored(.image), hasImage(on: pasteboard) {
reportReadFailureStatus(ignoredKindMessage(.image))
return nil
}
if let imageItem = itemFromImage(pasteboard, sourceApp: source.name, sourceBundleId: source.bundleId) {
return imageItem
}
if isIgnored(.pdf), hasPDF(on: pasteboard) {
reportReadFailureStatus(ignoredKindMessage(.pdf))
return nil
}
if let pdfItem = itemFromPDF(pasteboard, sourceApp: source.name, sourceBundleId: source.bundleId) {
return pdfItem
}
if isIgnored(.audio), hasAudio(on: pasteboard) {
reportReadFailureStatus(ignoredKindMessage(.audio))
return nil
}
if let audioItem = itemFromAudio(pasteboard, sourceApp: source.name, sourceBundleId: source.bundleId) {
return audioItem
}
if isIgnored(.richText), hasRichText(on: pasteboard) {
reportReadFailureStatus(ignoredKindMessage(.richText))
return nil
}
if let rtfPayload = itemFromRichText(pasteboard, sourceApp: source.name, sourceBundleId: source.bundleId) {
return rtfPayload
}
if let url {
let item = itemFromURL(url.url, title: url.title, sourceApp: source.name, sourceBundleId: source.bundleId)
return item
}
if isIgnored(.richText), hasHTMLRichText(on: pasteboard) {
reportReadFailureStatus(ignoredKindMessage(.richText))
return nil
}
if let htmlPayload = itemFromHTMLRichText(pasteboard, sourceApp: source.name, sourceBundleId: source.bundleId) {
return htmlPayload
}
if isIgnored(.text), let string = pasteboard.string(forType: .string) {
let trimmed = string.clipboardTrimmed
if !trimmed.isEmpty {
reportReadFailureStatus(ignoredKindMessage(.text))
return nil
}
}
if let string = pasteboard.string(forType: .string),
let item = itemFromString(string, sourceApp: source.name, sourceBundleId: source.bundleId) {
if item.kind == .text, item.payload.isEmpty {
reportReadFailureStatus("Clipboard contains no readable text.")
return nil
}
return item
}
reportReadFailureStatus("Clipboard changed, but ClipBored could not read a supported item.")
return nil
}
private func itemFromString(_ value: String, sourceApp: String?, sourceBundleId: String?) -> ClipboardItem? {
let trimmed = value.clipboardTrimmed
if trimmed.isEmpty {
reportReadFailureStatus("Clipboard string is empty.")
return nil
}
if settings.excludeSensitive, SensitiveContentDetector.isLikelySensitive(trimmed, sourceBundleId: sourceBundleId, sourceApp: sourceApp) {
reportReadFailureStatus("Copy was ignored because it looks sensitive.")
return nil
}
if let url = detectURL(trimmed) {
return ClipboardItem(
id: UUID(),
kind: .url,
displayText: url.absoluteString,
payload: url.absoluteString,
payloadHash: store.hashString(url.absoluteString),
createdAt: Date(),
lastUsedAt: Date(),
useCount: 1,
sourceApp: sourceApp,
imagePath: nil,
thumbnailPath: nil,
isPinned: false,
sourceAppBundleId: sourceBundleId
)
}
return ClipboardItem(
id: UUID(),
kind: .text,
displayText: trimmed,
payload: trimmed,
payloadHash: store.hashString(trimmed),
createdAt: Date(),
lastUsedAt: Date(),
useCount: 1,
sourceApp: sourceApp,
imagePath: nil,
thumbnailPath: nil,
isPinned: false,
sourceAppBundleId: sourceBundleId
)
}
private func itemFromURL(
_ url: URL,
title: String?,
sourceApp: String?,
sourceBundleId: String?,
previewPasteboard: NSPasteboard? = nil
) -> ClipboardItem {
let displayText = urlDisplayText(url: url, title: title)
let id = UUID()
let previewPaths = previewPasteboard.flatMap { previewImagePaths(from: $0, id: id) }
return ClipboardItem(
id: id,
kind: .url,
displayText: displayText,
payload: url.absoluteString,
payloadHash: store.hashString(url.absoluteString),
createdAt: Date(),
lastUsedAt: Date(),
useCount: 1,
sourceApp: sourceApp,
imagePath: previewPaths?.full,
thumbnailPath: previewPaths?.thumb,
isPinned: false,
sourceAppBundleId: sourceBundleId
)
}
private func previewImagePaths(from pasteboard: NSPasteboard, id: UUID) -> (full: String, thumb: String)? {
guard let data = pasteboard.data(forType: .tiff) ?? pasteboard.data(forType: .png),
let image = NSImage(data: data) else {
return nil
}
return cacheService.cacheImage(image, id: id)
}
private func itemFromImage(_ pasteboard: NSPasteboard, sourceApp: String?, sourceBundleId: String?) -> ClipboardItem? {
let imageData = pasteboard.data(forType: .tiff) ?? pasteboard.data(forType: .png)
guard let data = imageData, let image = NSImage(data: data) else {
if imageData != nil {
reportReadFailureStatus("Clipboard image data is present but could not be decoded.")
}
return nil
}
let id = UUID()
guard let cachePaths = cacheService.cacheImage(image, id: id) else {
reportReadFailureStatus("Failed to cache image for clipboard history.")
return nil
}
let recognizedText = recognizedTextIfEnabled(for: image)
return ClipboardItem(
id: id,
kind: .image,
displayText: "Image",
payload: cachePaths.full,
payloadHash: store.hashString(data.base64EncodedString()),
createdAt: Date(),
lastUsedAt: Date(),
useCount: 1,
sourceApp: sourceApp,
imagePath: cachePaths.full,
thumbnailPath: cachePaths.thumb,
isPinned: false,
sourceAppBundleId: sourceBundleId,
ocrText: recognizedText
)
}
private func recognizedTextIfEnabled(for image: NSImage) -> String? {
guard settings.includeImageTextInSearch,
let text = imageTextExtractor(image)?.clipboardTrimmed,
!text.isEmpty else {
return nil
}
let normalized = text
.split(whereSeparator: \.isWhitespace)
.joined(separator: " ")
return String(normalized.prefix(AppConfiguration.maxRecognizedImageTextLength))
}
private func hasImage(on pasteboard: NSPasteboard) -> Bool {
pasteboard.data(forType: .tiff) != nil || pasteboard.data(forType: .png) != nil
}
private func hasPDF(on pasteboard: NSPasteboard) -> Bool {
pasteboard.data(forType: .pdf) != nil
}
private func hasAudio(on pasteboard: NSPasteboard) -> Bool {
pasteboard.data(forType: .sound) != nil
}
private func hasFileItems(on pasteboard: NSPasteboard) -> Bool {
guard let urls = pasteboard.readObjects(forClasses: [NSURL.self], options: nil) as? [URL], !urls.isEmpty else {
return false
}
return urls.contains(where: \.isFileURL)
}
private func hasRichText(on pasteboard: NSPasteboard) -> Bool {
pasteboard.data(forType: .rtf) != nil
}
private func hasHTMLRichText(on pasteboard: NSPasteboard) -> Bool {
htmlData(from: pasteboard) != nil
}
private func itemFromPDF(_ pasteboard: NSPasteboard, sourceApp: String?, sourceBundleId: String?) -> ClipboardItem? {
guard let data = pasteboard.data(forType: .pdf) else { return nil }
let id = UUID()
let hash = store.hashString(data.base64EncodedString())
guard let path = cacheService.cachePDF(data, id: id) else {
reportReadFailureStatus("Failed to cache PDF for clipboard history.")
return nil
}
return ClipboardItem(
id: id,
kind: .pdf,
displayText: "PDF (\(ByteCountFormatter.string(fromByteCount: Int64(data.count), countStyle: .file)))",
payload: path,
payloadHash: hash,
createdAt: Date(),
lastUsedAt: Date(),
useCount: 1,
sourceApp: sourceApp,
imagePath: nil,
thumbnailPath: nil,
isPinned: false,
sourceAppBundleId: sourceBundleId
)
}
private func itemFromAudio(_ pasteboard: NSPasteboard, sourceApp: String?, sourceBundleId: String?) -> ClipboardItem? {
guard let data = pasteboard.data(forType: .sound) else { return nil }
let id = UUID()
let hash = store.hashString(data.base64EncodedString())
guard let path = cacheService.cacheAudio(data, id: id) else {
reportReadFailureStatus("Failed to cache audio for clipboard history.")
return nil
}
return ClipboardItem(
id: id,
kind: .audio,
displayText: "Audio (\(ByteCountFormatter.string(fromByteCount: Int64(data.count), countStyle: .file)))",
payload: path,
payloadHash: hash,
createdAt: Date(),
lastUsedAt: Date(),
useCount: 1,
sourceApp: sourceApp,
imagePath: nil,
thumbnailPath: nil,
isPinned: false,
sourceAppBundleId: sourceBundleId
)
}
private func itemFromRichText(_ pasteboard: NSPasteboard, sourceApp: String?, sourceBundleId: String?) -> ClipboardItem? {
guard let data = pasteboard.data(forType: .rtf),
let attributed = NSAttributedString(rtf: data, documentAttributes: nil)
else {
reportReadFailureStatus("Clipboard pasteboard reported RTF but text could not be read.")
return nil
}
let text = attributed.string.clipboardTrimmed
if text.isEmpty {
reportReadFailureStatus("Rich text from pasteboard is empty.")
return nil
}
if settings.excludeSensitive, SensitiveContentDetector.isLikelySensitive(text, sourceBundleId: sourceBundleId, sourceApp: sourceApp) {
reportReadFailureStatus("Rich text was ignored because it looks sensitive.")
return nil
}
let id = UUID()
guard let path = cacheService.cacheRichText(data, id: id) else {
reportReadFailureStatus("Failed to cache rich text for clipboard history.")
return nil
}
return ClipboardItem(
id: id,
kind: .richText,
displayText: text,
payload: path,
payloadHash: store.hashData(data),
createdAt: Date(),
lastUsedAt: Date(),
useCount: 1,
sourceApp: sourceApp,
imagePath: nil,
thumbnailPath: nil,
isPinned: false,
sourceAppBundleId: sourceBundleId
)
}
private func itemFromHTMLRichText(_ pasteboard: NSPasteboard, sourceApp: String?, sourceBundleId: String?) -> ClipboardItem? {
guard let htmlData = htmlData(from: pasteboard) else { return nil }
guard let attributed = attributedString(fromHTMLData: htmlData) else {
reportReadFailureStatus("Clipboard pasteboard reported HTML but text could not be read.")
return nil
}
let text = attributed.string.clipboardTrimmed
if text.isEmpty {
reportReadFailureStatus("HTML text from pasteboard is empty.")
return nil
}
if settings.excludeSensitive, SensitiveContentDetector.isLikelySensitive(text, sourceBundleId: sourceBundleId, sourceApp: sourceApp) {
reportReadFailureStatus("HTML text was ignored because it looks sensitive.")
return nil
}
guard let rtfData = attributed.rtf(from: NSRange(location: 0, length: attributed.length), documentAttributes: [:]) else {
reportReadFailureStatus("Failed to convert HTML clipboard data for clipboard history.")
return nil
}
let id = UUID()
guard let path = cacheService.cacheRichText(rtfData, id: id) else {
reportReadFailureStatus("Failed to cache HTML text for clipboard history.")
return nil
}
return ClipboardItem(
id: id,
kind: .richText,
displayText: text,
payload: path,
payloadHash: store.hashData(htmlData),
createdAt: Date(),
lastUsedAt: Date(),
useCount: 1,
sourceApp: sourceApp,
imagePath: nil,
thumbnailPath: nil,
isPinned: false,
sourceAppBundleId: sourceBundleId
)
}
private func itemFromFiles(_ pasteboard: NSPasteboard, sourceApp: String?, sourceBundleId: String?) -> ClipboardItem? {
guard let urls = pasteboard.readObjects(forClasses: [NSURL.self], options: nil) as? [URL],
!urls.isEmpty
else {
if let maybeURLs = pasteboard.readObjects(forClasses: [NSURL.self], options: nil) as? [URL],
!maybeURLs.isEmpty {
reportReadFailureStatus("Clipboard file list could not be read.")
}
return nil
}
let fileURLs = urls.filter(\.isFileURL)
guard !fileURLs.isEmpty else { return nil }
let text = FilePayload.payload(from: fileURLs)
let display = fileURLs.count == 1 ? text : "\(fileURLs.count) files"
return ClipboardItem(
id: UUID(),
kind: .file,
displayText: display,
payload: text,
payloadHash: store.hashString(text),
createdAt: Date(),
lastUsedAt: Date(),
useCount: 1,
sourceApp: sourceApp,
imagePath: nil,
thumbnailPath: nil,
isPinned: false,
sourceAppBundleId: sourceBundleId
)
}
private func detectURL(_ candidate: String) -> URL? {
if let direct = URL(string: candidate), let scheme = direct.scheme, !scheme.isEmpty {
return direct
}
if let detector = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue) {
let range = NSRange(location: 0, length: candidate.utf16.count)
if let match = detector.firstMatch(in: candidate, options: [], range: range),
match.resultType == .link,
match.range.location == range.location,
match.range.length == range.length,
let value = match.url {
return value
}
}
if let comps = URLComponents(string: "https://" + candidate), let host = comps.host, host.contains(".") {
return comps.url
}
return nil
}
private struct PasteboardURLPayload {
let url: URL
let title: String?
}
private func urlPayloadFromPasteboard(_ pasteboard: NSPasteboard) -> PasteboardURLPayload? {
guard let url = pasteboard.string(forType: .URL) else {
return nil
}
guard let detected = detectURL(url) else { return nil }
return PasteboardURLPayload(url: detected, title: urlTitle(from: pasteboard, url: detected))
}
private func urlDisplayText(url: URL, title: String?) -> String {
if let candidate = cleanURLTitle(title, url: url) {
return candidate
}
return url.absoluteString
}
private func urlTitle(from pasteboard: NSPasteboard, url: URL) -> String? {
let titleTypes = [
NSPasteboard.PasteboardType(rawValue: "public.url-name"),
NSPasteboard.PasteboardType(rawValue: "com.apple.pasteboard.promised-file-url-name")
]
for type in titleTypes {
if let title = cleanURLTitle(pasteboard.string(forType: type), url: url) {
return title
}
}
if let html = pasteboard.string(forType: .html),
let title = cleanURLTitle(plainText(fromHTML: html), url: url) {
return title
}
if let data = pasteboard.data(forType: .html),
let html = String(data: data, encoding: .utf8),
let title = cleanURLTitle(plainText(fromHTML: html), url: url) {
return title
}
return nil
}
private func cleanURLTitle(_ value: String?, url: URL) -> String? {
guard let value else { return nil }
let normalized = value.split { $0.isWhitespace }.joined(separator: " ").clipboardTrimmed
guard !normalized.isEmpty else { return nil }
guard normalized != url.absoluteString else { return nil }
guard detectURL(normalized) == nil else { return nil }
return normalized
}
private func plainText(fromHTML html: String) -> String? {
guard let data = html.data(using: .utf8) else { return nil }
return attributedString(fromHTMLData: data)?.string
}
private func htmlData(from pasteboard: NSPasteboard) -> Data? {
if let data = pasteboard.data(forType: .html), !data.isEmpty {
return data
}
if let html = pasteboard.string(forType: .html),
let data = html.data(using: .utf8),
!data.isEmpty {
return data
}
return nil
}
private func attributedString(fromHTMLData data: Data) -> NSAttributedString? {
try? NSAttributedString(
data: data,
options: [
.documentType: NSAttributedString.DocumentType.html,
.characterEncoding: String.Encoding.utf8.rawValue
],
documentAttributes: nil
)
}
private func isSourceIgnored(_ source: (name: String?, bundleId: String?)) -> Bool {
guard !settings.ignoredApps.isEmpty else { return false }
let lowerName = source.name?.lowercased() ?? ""
let lowerBundle = source.bundleId?.lowercased() ?? ""
return settings.ignoredApps.contains { ignored in
let candidate = ignored.clipboardTrimmed.lowercased()
if candidate.isEmpty { return false }
return !candidate.isEmpty && (lowerName.contains(candidate) || lowerBundle.contains(candidate))
}
}
private func frontmostApp() -> (name: String?, bundleId: String?) {
guard let app = NSWorkspace.shared.frontmostApplication else {
return (nil, nil)
}
return (app.localizedName, app.bundleIdentifier)
}
private func reportCaptured(_ item: ClipboardItem) {
let source = item.sourceApp ?? "unknown app"
reportCaptureStatus("Captured \(item.kind.displayName) from \(source).")
}
private func displayNameForStatus(_ kind: ClipboardItemKind) -> String {
let name = kind.displayName
if name == name.uppercased() {
return name
}
return name.capitalized
}
private func reportCaptureStatus(_ message: String) {
DispatchQueue.main.async { [weak self] in
self?.settings.setCaptureStatus(message: message)
}
}
private func reportReadFailureStatus(_ message: String) {
didReportReadFailure = true
reportCaptureStatus(captureFailureDisplayMessage(message))
}
private func captureFailureDisplayMessage(_ message: String) -> String {
let trimmed = message.clipboardTrimmed
guard !trimmed.isEmpty else { return "Skipped: Clipboard changed, but there was no readable content." }
let lower = trimmed.lowercased()
if lower.hasPrefix("skipped:") || lower.hasPrefix("error:") {
return trimmed
}
if lower.contains("failed") {
return "Error: \(trimmed)"
}
return "Skipped: \(trimmed)"
}
private func sourceDescription(_ source: (name: String?, bundleId: String?)) -> String {
source.name ?? source.bundleId ?? "ignored app"
}
}

View File

@@ -0,0 +1,25 @@
import Foundation
enum ClipboardSelfWriteTracker {
private static let queue = DispatchQueue(label: "clipboard.self-write-tracker")
private static var changeCounts: [Int] = []
static func mark(changeCount: Int) {
queue.sync {
changeCounts.append(changeCount)
if changeCounts.count > 16 {
changeCounts.removeFirst(changeCounts.count - 16)
}
}
}
static func consume(changeCount: Int) -> Bool {
queue.sync {
guard let index = changeCounts.firstIndex(of: changeCount) else {
return false
}
changeCounts.remove(at: index)
return true
}
}
}

View File

@@ -0,0 +1,841 @@
import AppKit
import Darwin
import Foundation
import CommonCrypto
import SQLite3
final class ClipboardStore {
private(set) var items: [ClipboardItem] = [] {
didSet { notifyItemsChanged() }
}
private let settings: SettingsModel
private let cacheService: ClipboardCacheService
private let encryptionService: ClipboardEncryptionService
private let dataQueue = DispatchQueue(label: "clipboard.store.persistence", qos: .utility)
private let baseURL: URL
private let historyURL: URL
private let dbURL: URL
private var db: OpaquePointer?
private var itemObservers: [([ClipboardItem]) -> Void] = []
init(
settings: SettingsModel,
cacheService: ClipboardCacheService,
baseURL: URL? = nil,
encryptionService: ClipboardEncryptionService = ClipboardEncryptionService()
) {
self.settings = settings
self.cacheService = cacheService
self.encryptionService = encryptionService
self.baseURL = baseURL ?? ClipboardStore.storageDirectory()
dbURL = self.baseURL.appendingPathComponent("history.sqlite")
historyURL = self.baseURL.appendingPathComponent("history.json")
settings.sanitizeLimits()
hardenStoragePermissions()
openDatabase()
configureDatabase()
createSchema()
hardenStoragePermissions()
migrateLegacyJSONIfNeeded()
load()
}
deinit {
if let db {
sqlite3_close(db)
}
}
static func storageDirectory() -> URL {
if let pointer = getenv(AppConfiguration.storageDirectoryOverrideEnvironmentKey), pointer.pointee != 0 {
let base = URL(fileURLWithPath: String(cString: pointer), isDirectory: true).standardizedFileURL
try? FileManager.default.createDirectory(at: base, withIntermediateDirectories: true)
try? FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: base.path)
return base
}
let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
let base = appSupport.appendingPathComponent(AppConfiguration.appName, isDirectory: true)
try? FileManager.default.createDirectory(at: base, withIntermediateDirectories: true)
try? FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: base.path)
return base
}
func upsert(_ incoming: ClipboardItem) {
guard let index = items.firstIndex(where: { settings.pruneDuplicates ? $0.payloadHash == incoming.payloadHash : false }) else {
insertNewItem(incoming)
return
}
if settings.keepFirstImage, incoming.kind == .image {
updateExistingKeepImage(incoming, at: index)
return
}
updateExistingItem(incoming, at: index)
}
func markUsed(_ id: UUID) {
guard let index = items.firstIndex(where: { $0.id == id }) else { return }
var used = items.remove(at: index)
used.lastUsedAt = Date()
used.useCount += 1
items.insert(used, at: 0)
persistAsync(.upsert(used))
}
func togglePin(_ id: UUID) {
guard let index = items.firstIndex(where: { $0.id == id }) else { return }
items[index].isPinned.toggle()
let updated = items[index]
normalizeHistoryLength()
if items.contains(where: { $0.id == updated.id }) {
persistAsync(.upsert(updated))
}
}
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]))
}
func remove(_ id: UUID) {
guard let index = items.firstIndex(where: { $0.id == id }) else { return }
let removed = items.remove(at: index)
if removed.kind.hasManagedCacheReference {
cacheService.removeCachedReferences(removed)
}
persistAsync(.delete(id))
}
func removeAll() {
for item in items {
if item.kind.hasManagedCacheReference {
cacheService.removeCachedReferences(item)
}
}
items.removeAll()
persistAsync(.deleteAll)
}
func updateHistoryLimit(_ newLimit: Int) {
if settings.maxHistoryItems != newLimit {
settings.maxHistoryItems = newLimit
}
normalizeHistoryLength()
}
func observeItems(_ observer: @escaping ([ClipboardItem]) -> Void) {
itemObservers.append(observer)
observer(items)
}
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)
}
}
guard !overflow.isEmpty else { return }
items = kept
var removedCachedPayload = false
var idsToDelete: [UUID] = []
idsToDelete.reserveCapacity(overflow.count)
for item in overflow {
idsToDelete.append(item.id)
if item.kind.hasManagedCacheReference {
removedCachedPayload = true
cacheService.removeCachedReferences(item)
}
}
persistAsync(.deleteMany(idsToDelete), purgeCache: removedCachedPayload)
}
func flushPersistenceForTesting() {
dataQueue.sync {}
}
private func insertNewItem(_ incoming: ClipboardItem) {
items.insert(incoming, at: 0)
normalizeHistoryLength()
persistAsync(.upsert(incoming), purgeCache: incoming.imagePath != nil)
}
private func updateExistingKeepImage(_ incoming: ClipboardItem, at index: Int) {
cacheService.removeCachedReferences(incoming)
var existing = items.remove(at: index)
existing.lastUsedAt = Date()
existing.useCount += 1
if !incoming.displayText.isEmpty {
existing.displayText = incoming.displayText
}
existing.sourceApp = incoming.sourceApp
existing.sourceAppBundleId = incoming.sourceAppBundleId
items.insert(existing, at: 0)
normalizeHistoryLength()
persistAsync(.upsert(existing), purgeCache: existing.kind == .image)
}
private func updateExistingItem(_ incoming: ClipboardItem, at index: Int) {
var existing = items.remove(at: index)
let previousCachedItem = existing
existing.lastUsedAt = Date()
existing.useCount += 1
if !incoming.displayText.isEmpty {
existing.displayText = incoming.displayText
}
existing.payload = incoming.payload
existing.payloadHash = incoming.payloadHash
existing.kind = incoming.kind
existing.sourceApp = incoming.sourceApp
existing.sourceAppBundleId = incoming.sourceAppBundleId
if incoming.kind == .image || incoming.kind == .url {
existing.imagePath = incoming.imagePath
existing.thumbnailPath = incoming.thumbnailPath
} else {
existing.imagePath = nil
existing.thumbnailPath = nil
}
if previousCachedItem.kind.hasManagedCacheReference {
cacheService.removeCachedReferences(previousCachedItem)
}
existing.ocrText = incoming.ocrText
items.insert(existing, at: 0)
normalizeHistoryLength()
persistAsync(.upsert(existing), purgeCache: existing.imagePath != nil)
}
private func persistAsync(_ mutation: PersistenceMutation, purgeCache: Bool = false) {
dataQueue.async {
self.applyPersistence(mutation)
if purgeCache {
self.cacheService.purgeIfNeeded(maxBytes: self.settings.imageCacheMaxBytes)
}
}
}
private func load() {
loadFromDatabase()
}
private func notifyItemsChanged() {
for observer in itemObservers {
observer(items)
}
}
func hashString(_ value: String) -> String {
hashData(Data(value.utf8))
}
func hashData(_ data: Data) -> String {
var digest = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH))
data.withUnsafeBytes { bytes in
_ = CC_SHA256(bytes.baseAddress, CC_LONG(bytes.count), &digest)
}
return hexString(digest)
}
private func hexString(_ bytes: [UInt8]) -> String {
var output: [UInt8] = []
output.reserveCapacity(bytes.count * 2)
for byte in bytes {
output.append(hexDigit(byte >> 4))
output.append(hexDigit(byte & 0x0f))
}
return String(decoding: output, as: UTF8.self)
}
private func hexDigit(_ value: UInt8) -> UInt8 {
value < 10 ? value + 48 : value + 87
}
private func openDatabase() {
if sqlite3_open(dbURL.path, &db) != SQLITE_OK {
db = nil
}
}
private func configureDatabase() {
_ = execute("PRAGMA secure_delete = ON;")
_ = execute("PRAGMA journal_mode = DELETE;")
}
private func hardenStoragePermissions() {
try? FileManager.default.createDirectory(at: baseURL, withIntermediateDirectories: true)
try? FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: baseURL.path)
if FileManager.default.fileExists(atPath: dbURL.path) {
try? FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: dbURL.path)
}
if FileManager.default.fileExists(atPath: historyURL.path) {
try? FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: historyURL.path)
}
}
private func createSchema() {
if db == nil { return }
let createTable = """
CREATE TABLE IF NOT EXISTS clipboard_items (
id TEXT PRIMARY KEY NOT NULL,
kind INTEGER NOT NULL,
display_text TEXT NOT NULL,
payload TEXT NOT NULL,
payload_hash TEXT NOT NULL,
created_at REAL NOT NULL,
last_used_at REAL NOT NULL,
use_count INTEGER NOT NULL DEFAULT 0,
source_app TEXT,
source_app_bundle_id TEXT,
image_path TEXT,
thumbnail_path TEXT,
is_pinned INTEGER NOT NULL DEFAULT 0,
ocr_text TEXT,
collection_name TEXT
);
"""
let createIndexes = """
CREATE INDEX IF NOT EXISTS idx_created_at ON clipboard_items (created_at DESC);
CREATE INDEX IF NOT EXISTS idx_last_used_at ON clipboard_items (last_used_at DESC);
CREATE INDEX IF NOT EXISTS idx_use_count ON clipboard_items (use_count DESC);
CREATE INDEX IF NOT EXISTS idx_kind ON clipboard_items (kind);
CREATE INDEX IF NOT EXISTS idx_hash ON clipboard_items (payload_hash);
CREATE INDEX IF NOT EXISTS idx_collection_name ON clipboard_items (collection_name);
"""
_ = execute(createTable)
_ = execute("ALTER TABLE clipboard_items ADD COLUMN collection_name TEXT;")
_ = execute(createIndexes)
}
private func migrateLegacyJSONIfNeeded() {
guard isDatabaseEmpty(), let data = try? Data(contentsOf: historyURL), !data.isEmpty else {
return
}
if let decoded = decodeLegacyJSONItems(from: data) {
items = decoded
normalizeHistoryLength()
if saveAll(items) {
try? FileManager.default.removeItem(at: historyURL)
hardenStoragePermissions()
}
}
}
private func decodeLegacyJSONItems(from data: Data) -> [ClipboardItem]? {
guard let rows = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] else {
return nil
}
var items: [ClipboardItem] = []
items.reserveCapacity(rows.count)
for row in rows {
if let item = decodeLegacyJSONItem(row) {
items.append(item)
}
}
return items
}
private func decodeLegacyJSONItem(_ row: [String: Any]) -> ClipboardItem? {
guard
let kindValue = row["kind"] as? Int,
let kind = ClipboardItemKind(rawValue: kindValue),
let displayText = row["displayText"] as? String,
let payload = row["payload"] as? String,
let payloadHash = row["payloadHash"] as? String,
let createdAt = legacyDate(row["createdAt"]),
let lastUsedAt = legacyDate(row["lastUsedAt"])
else {
return nil
}
let id = (row["id"] as? String).flatMap(UUID.init(uuidString:)) ?? UUID()
let useCount = row["useCount"] as? Int ?? 0
return ClipboardItem(
id: id,
kind: kind,
displayText: displayText,
payload: payload,
payloadHash: payloadHash,
createdAt: createdAt,
lastUsedAt: lastUsedAt,
useCount: useCount,
sourceApp: row["sourceApp"] as? String,
imagePath: row["imagePath"] as? String,
thumbnailPath: row["thumbnailPath"] as? String,
isPinned: row["isPinned"] as? Bool ?? false,
sourceAppBundleId: row["sourceAppBundleId"] as? String,
ocrText: row["ocrText"] as? String,
collectionName: row["collectionName"] as? String
)
}
private func legacyDate(_ value: Any?) -> Date? {
if let seconds = value as? Double {
return Date(timeIntervalSince1970: seconds)
}
if let number = value as? NSNumber {
return Date(timeIntervalSince1970: number.doubleValue)
}
guard let string = value as? String else {
return nil
}
return legacyISO8601Date(string)
}
private func legacyISO8601Date(_ string: String) -> Date? {
string.withCString { pointer -> Date? in
let byteCount = strlen(pointer)
guard byteCount >= 20,
byte(pointer, 4) == 45,
byte(pointer, 7) == 45,
byte(pointer, 10) == 84 || byte(pointer, 10) == 32,
byte(pointer, 13) == 58,
byte(pointer, 16) == 58,
let year = decimal(pointer, byteCount, 0, 4),
let month = decimal(pointer, byteCount, 5, 2),
let day = decimal(pointer, byteCount, 8, 2),
let hour = decimal(pointer, byteCount, 11, 2),
let minute = decimal(pointer, byteCount, 14, 2),
let second = decimal(pointer, byteCount, 17, 2)
else {
return nil
}
var cursor = 19
var fraction = 0.0
if cursor < byteCount, byte(pointer, cursor) == 46 {
cursor += 1
var scale = 0.1
while cursor < byteCount {
let digit = byte(pointer, cursor)
guard digit >= 48, digit <= 57 else { break }
fraction += Double(digit - 48) * scale
scale /= 10
cursor += 1
}
}
var offset = 0
if cursor < byteCount, byte(pointer, cursor) == 90 {
offset = 0
} else if cursor + 5 < byteCount, byte(pointer, cursor) == 43 || byte(pointer, cursor) == 45 {
let sign = byte(pointer, cursor) == 43 ? 1 : -1
guard let offsetHour = decimal(pointer, byteCount, cursor + 1, 2),
let offsetMinute = decimal(pointer, byteCount, cursor + 4, 2)
else { return nil }
offset = sign * ((offsetHour * 3600) + (offsetMinute * 60))
}
var components = tm()
components.tm_year = Int32(year - 1900)
components.tm_mon = Int32(month - 1)
components.tm_mday = Int32(day)
components.tm_hour = Int32(hour)
components.tm_min = Int32(minute)
components.tm_sec = Int32(second)
components.tm_isdst = 0
let epoch = timegm(&components)
guard epoch >= 0 else { return nil }
return Date(timeIntervalSince1970: TimeInterval(epoch - time_t(offset)) + fraction)
}
}
private func decimal(_ pointer: UnsafePointer<CChar>, _ byteCount: Int, _ start: Int, _ length: Int) -> Int? {
guard start + length <= byteCount else { return nil }
var result = 0
for index in start..<(start + length) {
let digit = byte(pointer, index)
guard digit >= 48, digit <= 57 else { return nil }
result = (result * 10) + Int(digit - 48)
}
return result
}
private func byte(_ pointer: UnsafePointer<CChar>, _ index: Int) -> UInt8 {
UInt8(bitPattern: pointer[index])
}
private func isDatabaseEmpty() -> Bool {
guard let db else { return true }
let query = "SELECT COUNT(*) FROM clipboard_items;"
var statement: OpaquePointer?
defer { sqlite3_finalize(statement) }
guard sqlite3_prepare_v2(db, query, -1, &statement, nil) == SQLITE_OK else {
return true
}
guard sqlite3_step(statement) == SQLITE_ROW else { return true }
let count = sqlite3_column_int64(statement, 0)
return count == 0
}
private func loadFromDatabase() {
guard let db else { return }
let query = """
SELECT
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
FROM clipboard_items
ORDER BY created_at DESC, last_used_at DESC
"""
var statement: OpaquePointer?
defer { sqlite3_finalize(statement) }
guard sqlite3_prepare_v2(db, query, -1, &statement, nil) == SQLITE_OK else { return }
var canEncryptCache: Bool?
func canEncryptForMigration() -> Bool {
if let canEncryptCache {
return canEncryptCache
}
let canEncrypt = encryptionService.isAvailable
canEncryptCache = canEncrypt
return canEncrypt
}
var loaded: [ClipboardItem] = []
var needsEncryptionMigration = false
var hadDecodeFailure = false
while sqlite3_step(statement) == SQLITE_ROW {
guard
let idText = sqlite3_column_text(statement, 0),
let kindValue = Int(exactly: sqlite3_column_int(statement, 1)),
let kind = ClipboardItemKind(rawValue: kindValue)
else {
continue
}
let id = UUID(uuidString: String(cString: idText)) ?? UUID()
func stringValue(_ index: Int32) -> (value: String?, migrationNeeded: Bool, decodeFailed: Bool) {
guard let raw = sqlite3_column_text(statement, index) else {
return (nil, false, false)
}
let value = String(cString: raw)
if ClipboardEncryptionService.isProtected(value) {
guard let decoded = encryptionService.unprotect(value) else {
return (nil, false, true)
}
return (decoded, decoded == value && canEncryptForMigration(), false)
}
return (value, canEncryptForMigration(), false)
}
let displayTextValue = stringValue(2)
let payloadValue = stringValue(3)
let payloadHashValue = stringValue(4)
guard
let displayText = displayTextValue.value,
let payload = payloadValue.value,
let payloadHash = payloadHashValue.value
else {
hadDecodeFailure = hadDecodeFailure
|| displayTextValue.decodeFailed
|| payloadValue.decodeFailed
|| payloadHashValue.decodeFailed
continue
}
needsEncryptionMigration = needsEncryptionMigration
|| displayTextValue.migrationNeeded
|| payloadValue.migrationNeeded
|| payloadHashValue.migrationNeeded
hadDecodeFailure = hadDecodeFailure
|| displayTextValue.decodeFailed
|| payloadValue.decodeFailed
|| payloadHashValue.decodeFailed
let createdAt = Date(timeIntervalSince1970: sqlite3_column_double(statement, 5))
let lastUsedAt = Date(timeIntervalSince1970: sqlite3_column_double(statement, 6))
let useCount = Int(sqlite3_column_int(statement, 7))
let sourceAppValue = stringValue(8)
let sourceAppBundleIdValue = stringValue(9)
let imagePathValue = stringValue(10)
let thumbnailPathValue = stringValue(11)
let isPinned = sqlite3_column_int(statement, 12) != 0
let ocrTextValue = stringValue(13)
let collectionNameValue = stringValue(14)
needsEncryptionMigration = needsEncryptionMigration
|| sourceAppValue.migrationNeeded
|| sourceAppBundleIdValue.migrationNeeded
|| imagePathValue.migrationNeeded
|| thumbnailPathValue.migrationNeeded
|| ocrTextValue.migrationNeeded
|| collectionNameValue.migrationNeeded
hadDecodeFailure = hadDecodeFailure
|| sourceAppValue.decodeFailed
|| sourceAppBundleIdValue.decodeFailed
|| imagePathValue.decodeFailed
|| thumbnailPathValue.decodeFailed
|| ocrTextValue.decodeFailed
|| collectionNameValue.decodeFailed
loaded.append(
ClipboardItem(
id: id,
kind: kind,
displayText: displayText,
payload: payload,
payloadHash: payloadHash,
createdAt: createdAt,
lastUsedAt: lastUsedAt,
useCount: useCount,
sourceApp: sourceAppValue.value,
imagePath: imagePathValue.value,
thumbnailPath: thumbnailPathValue.value,
isPinned: isPinned,
sourceAppBundleId: sourceAppBundleIdValue.value,
ocrText: ocrTextValue.value,
collectionName: collectionNameValue.value
)
)
}
sqlite3_finalize(statement)
statement = nil
items = loaded
normalizeHistoryLength()
cacheService.encryptCachedReferencesIfNeeded(for: items)
if needsEncryptionMigration, !hadDecodeFailure, saveAll(items) {
vacuumDatabase()
hardenStoragePermissions()
}
}
private enum PersistenceMutation {
case upsert(ClipboardItem)
case delete(UUID)
case deleteMany([UUID])
case deleteAll
}
private func applyPersistence(_ mutation: PersistenceMutation) {
guard let db else { return }
DiagnosticsService.shared.incrementDatabaseMutation()
let insertSQL = """
INSERT OR REPLACE INTO clipboard_items (
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
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);
"""
switch mutation {
case .upsert(let item):
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
}
bindItem(item, to: statement)
let stepResult = sqlite3_step(statement)
if stepResult != SQLITE_DONE {
shouldRollback = true
return
}
if !execute("COMMIT;") {
shouldRollback = true
}
case .delete(let id):
guard execute("BEGIN IMMEDIATE TRANSACTION;") else { return }
let query = "DELETE FROM clipboard_items WHERE id = ?;"
var statement: OpaquePointer?
guard sqlite3_prepare_v2(db, query, -1, &statement, nil) == SQLITE_OK else {
_ = execute("ROLLBACK;")
return
}
bindText(statement, 1, id.uuidString)
let stepResult = sqlite3_step(statement)
sqlite3_finalize(statement)
if stepResult != SQLITE_DONE {
_ = execute("ROLLBACK;")
return
}
_ = execute("COMMIT;")
case .deleteMany(let ids):
guard !ids.isEmpty else { return }
var placeholders = "?"
if ids.count > 1 {
for _ in 1..<ids.count {
placeholders += ",?"
}
}
let query = "DELETE FROM clipboard_items WHERE id IN (\(placeholders));"
var statement: OpaquePointer?
guard execute("BEGIN IMMEDIATE TRANSACTION;") else { return }
guard sqlite3_prepare_v2(db, query, -1, &statement, nil) == SQLITE_OK else {
_ = execute("ROLLBACK;")
return
}
for (offset, id) in ids.enumerated() {
bindText(statement, Int32(offset + 1), id.uuidString)
}
let stepResult = sqlite3_step(statement)
sqlite3_finalize(statement)
if stepResult != SQLITE_DONE {
_ = execute("ROLLBACK;")
return
}
_ = execute("COMMIT;")
case .deleteAll:
if execute("DELETE FROM clipboard_items;") {
vacuumDatabase()
hardenStoragePermissions()
encryptionService.resetStoredKey()
}
}
}
@discardableResult
private func saveAll(_ items: [ClipboardItem]) -> Bool {
guard let db else { return false }
let deleteSQL = "DELETE FROM clipboard_items;"
let insertSQL = """
INSERT OR REPLACE INTO clipboard_items (
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
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);
"""
guard execute("BEGIN IMMEDIATE TRANSACTION;") else {
return false
}
defer {
if sqlite3_get_autocommit(db) == 0 {
_ = execute("ROLLBACK;")
}
}
guard execute(deleteSQL) else {
_ = execute("ROLLBACK;")
return false
}
var statement: OpaquePointer?
defer { sqlite3_finalize(statement) }
guard sqlite3_prepare_v2(db, insertSQL, -1, &statement, nil) == SQLITE_OK else {
_ = execute("ROLLBACK;")
return false
}
for item in items {
bindItem(item, to: statement)
let stepResult = sqlite3_step(statement)
if stepResult != SQLITE_DONE {
_ = execute("ROLLBACK;")
return false
}
sqlite3_reset(statement)
sqlite3_clear_bindings(statement)
}
if !execute("COMMIT;") {
_ = execute("ROLLBACK;")
return false
}
return true
}
@discardableResult
private func execute(_ sql: String) -> Bool {
guard let db else { return false }
return sqlite3_exec(db, sql, nil, nil, nil) == SQLITE_OK
}
private func vacuumDatabase() {
_ = execute("VACUUM;")
}
private func bindText(_ statement: OpaquePointer?, _ index: Int32, _ value: String?) {
guard let value else {
sqlite3_bind_null(statement, index)
return
}
let destructor: Optional<@convention(c) (UnsafeMutableRawPointer?) -> ()> = unsafeBitCast(-1, to: Optional<@convention(c) (UnsafeMutableRawPointer?) -> ()>.self)
_ = value.withCString { ptr in
sqlite3_bind_text(statement, index, ptr, -1, destructor)
}
}
private func bindItem(_ item: ClipboardItem, to statement: OpaquePointer?) {
bindText(statement, 1, item.id.uuidString)
sqlite3_bind_int(statement, 2, Int32(item.kind.rawValue))
bindText(statement, 3, encryptionService.protect(item.displayText))
bindText(statement, 4, encryptionService.protect(item.payload))
bindText(statement, 5, encryptionService.protect(item.payloadHash))
sqlite3_bind_double(statement, 6, item.createdAt.timeIntervalSince1970)
sqlite3_bind_double(statement, 7, item.lastUsedAt.timeIntervalSince1970)
sqlite3_bind_int(statement, 8, Int32(item.useCount))
bindText(statement, 9, encryptionService.protect(item.sourceApp))
bindText(statement, 10, encryptionService.protect(item.sourceAppBundleId))
bindText(statement, 11, encryptionService.protect(item.imagePath))
bindText(statement, 12, encryptionService.protect(item.thumbnailPath))
sqlite3_bind_int(statement, 13, item.isPinned ? 1 : 0)
bindText(statement, 14, encryptionService.protect(item.ocrText))
bindText(statement, 15, encryptionService.protect(item.collectionName))
}
}

View File

@@ -0,0 +1,61 @@
import Foundation
final class DiagnosticsService {
static let shared = DiagnosticsService()
struct Snapshot: Equatable {
var monitorTicks: Int
var pasteboardChanges: Int
var extractionAttempts: Int
var databaseMutations: Int
var cachePurges: Int
}
private let queue = DispatchQueue(label: "clipboard.diagnostics", qos: .utility)
private var snapshot = Snapshot(
monitorTicks: 0,
pasteboardChanges: 0,
extractionAttempts: 0,
databaseMutations: 0,
cachePurges: 0
)
private init() {}
func incrementMonitorTick() {
queue.async { self.snapshot.monitorTicks += 1 }
}
func incrementPasteboardChange() {
queue.async { self.snapshot.pasteboardChanges += 1 }
}
func incrementExtractionAttempt() {
queue.async { self.snapshot.extractionAttempts += 1 }
}
func incrementDatabaseMutation() {
queue.async { self.snapshot.databaseMutations += 1 }
}
func incrementCachePurge() {
queue.async { self.snapshot.cachePurges += 1 }
}
func currentSnapshot() -> Snapshot {
queue.sync { snapshot }
}
func reset() {
queue.sync {
snapshot = Snapshot(
monitorTicks: 0,
pasteboardChanges: 0,
extractionAttempts: 0,
databaseMutations: 0,
cachePurges: 0
)
}
}
}

View File

@@ -0,0 +1,40 @@
import AppKit
import Vision
enum ImageTextExtractor {
static func recognizedText(in image: NSImage) -> String? {
let boundedImage = image.resized(to: CGSize(
width: AppConfiguration.maxFullImagePixelSize,
height: AppConfiguration.maxFullImagePixelSize
))
guard let cgImage = boundedImage.cgImage(forProposedRect: nil, context: nil, hints: nil) else {
return nil
}
let request = VNRecognizeTextRequest()
request.recognitionLevel = .accurate
request.usesLanguageCorrection = true
request.minimumTextHeight = 0.015
let handler = VNImageRequestHandler(cgImage: cgImage, options: [:])
do {
try handler.perform([request])
} catch {
return nil
}
let lines = request.results?.compactMap { observation in
observation.topCandidates(1).first?.string.clipboardTrimmed
} ?? []
return normalized(lines)
}
private static func normalized(_ lines: [String]) -> String? {
let text = lines
.filter { !$0.isEmpty }
.joined(separator: " ")
.split(whereSeparator: \.isWhitespace)
.joined(separator: " ")
return text.isEmpty ? nil : text
}
}

View File

@@ -0,0 +1,196 @@
import AppKit
import Foundation
final class PasteActionService {
private let cacheService: ClipboardCacheService
private let accessibilityPermissionProvider: () -> Bool
private let targetActivator: (NSRunningApplication) -> Bool
private let keyboardPasteScheduler: (@escaping () -> Void) -> Void
init(
cacheService: ClipboardCacheService = ClipboardCacheService(),
accessibilityPermissionProvider: @escaping () -> Bool = AccessibilityPermissionService.requestPromptIfNeeded,
targetActivator: @escaping (NSRunningApplication) -> Bool = PasteActionService.activateForAutomaticPaste,
keyboardPasteScheduler: @escaping (@escaping () -> Void) -> Void = { action in
DispatchQueue.main.asyncAfter(deadline: .now() + 0.08) {
action()
}
}
) {
self.cacheService = cacheService
self.accessibilityPermissionProvider = accessibilityPermissionProvider
self.targetActivator = targetActivator
self.keyboardPasteScheduler = keyboardPasteScheduler
}
enum PasteActionResult: Equatable {
case pasted
case copied
case copiedNeedsPermission
case failed(String)
var message: String {
switch self {
case .pasted:
return "Pasted"
case .copied:
return "Copied"
case .copiedNeedsPermission:
return "Copied. Grant Accessibility access to paste automatically."
case .failed(let message):
return message
}
}
}
func paste(_ item: ClipboardItem, targetApp: NSRunningApplication?) -> PasteActionResult {
guard writeToPasteboard(item) else {
return .failed("Could not write item to clipboard.")
}
guard let targetApp,
!targetApp.isTerminated else {
return .copied
}
guard accessibilityPermissionProvider() else {
return .copiedNeedsPermission
}
guard targetActivator(targetApp) else {
return .copied
}
keyboardPasteScheduler { [weak self] in
self?.pasteViaKeyboard()
}
return .pasted
}
@discardableResult
func copy(_ item: ClipboardItem) -> PasteActionResult {
writeToPasteboard(item) ? .copied : .failed("Could not write item to clipboard.")
}
@discardableResult
func writeToPasteboard(_ item: ClipboardItem) -> Bool {
let board = NSPasteboard.general
let didWrite: Bool
switch item.kind {
case .image:
guard let imagePath = item.imagePath, let image = cacheService.image(for: imagePath) else { return false }
board.clearContents()
didWrite = board.writeObjects([image])
case .pdf:
guard let data = cacheService.data(for: item.payload) else { return false }
board.clearContents()
didWrite = board.setData(data, forType: .pdf)
case .audio:
guard let data = cacheService.data(for: item.payload) else { return false }
board.clearContents()
didWrite = board.setData(data, forType: .sound)
case .richText:
if let data = cacheService.data(for: item.payload) {
board.clearContents()
let text = richTextPlainString(from: data) ?? item.displayText.clipboardTrimmed
let wroteRTF = board.setData(data, forType: .rtf)
if !text.isEmpty {
_ = board.setString(text, forType: .string)
}
didWrite = wroteRTF
} else {
let fallbackText = richTextFallbackPlainString(for: item)
guard !fallbackText.isEmpty else { return false }
board.clearContents()
didWrite = board.setString(fallbackText, forType: .string)
}
case .file:
let urls = FilePayload.urls(from: item.payload)
guard !urls.isEmpty, urls.allSatisfy({ FileManager.default.fileExists(atPath: $0.path) }) else { return false }
board.clearContents()
didWrite = board.writeObjects(urls.map { $0 as NSURL })
if didWrite {
board.setString(urls.map(\.path).joined(separator: "\n"), forType: .string)
}
case .url:
guard !item.payload.isEmpty else { return false }
board.clearContents()
didWrite = writeURL(item.payload, title: item.displayText, to: board)
case .text, .unknown:
guard !item.payload.isEmpty else { return false }
board.clearContents()
didWrite = board.setString(item.payload, forType: .string)
}
if didWrite {
ClipboardSelfWriteTracker.mark(changeCount: board.changeCount)
}
return didWrite
}
private func writeURL(_ payload: String, title: String?, to board: NSPasteboard) -> Bool {
guard !payload.isEmpty else { return false }
let wroteString = board.setString(payload, forType: .string)
board.setString(payload, forType: .URL)
if let url = URL(string: payload) {
_ = board.writeObjects([url as NSURL])
}
if let title = urlTitleForPasteboard(title, payload: payload) {
board.setString(title, forType: NSPasteboard.PasteboardType(rawValue: "public.url-name"))
}
return wroteString
}
private func urlTitleForPasteboard(_ title: String?, payload: String) -> String? {
guard let title else { return nil }
let normalized = title.split { $0.isWhitespace }.joined(separator: " ").clipboardTrimmed
guard !normalized.isEmpty, normalized != payload else { return nil }
guard !normalized.contains("://") else { return nil }
return normalized
}
private func richTextPlainString(from data: Data) -> String? {
guard let attributed = NSAttributedString(rtf: data, documentAttributes: nil) else {
return nil
}
let text = attributed.string.clipboardTrimmed
return text.isEmpty ? nil : text
}
private func richTextFallbackPlainString(for item: ClipboardItem) -> String {
let payload = item.payload.clipboardTrimmed
if looksLikeRichTextCachePath(payload) {
return item.displayText.clipboardTrimmed
}
if !payload.isEmpty {
return payload
}
return item.displayText.clipboardTrimmed
}
private func looksLikeRichTextCachePath(_ payload: String) -> Bool {
let url = URL(fileURLWithPath: payload)
return payload.contains("/") && url.pathExtension.lowercased() == "rtf"
}
private static func activateForAutomaticPaste(_ targetApp: NSRunningApplication) -> Bool {
if #available(macOS 14, *) {
return targetApp.activate()
} else {
return targetApp.activate(options: [.activateIgnoringOtherApps])
}
}
private func pasteViaKeyboard() {
let keyCode: UInt16 = 9
guard
let srcDown = CGEvent(keyboardEventSource: nil, virtualKey: keyCode, keyDown: true),
let srcUp = CGEvent(keyboardEventSource: nil, virtualKey: keyCode, keyDown: false)
else { return }
srcDown.flags = .maskCommand
srcDown.post(tap: .cghidEventTap)
srcUp.flags = .maskCommand
srcUp.post(tap: .cghidEventTap)
}
}

View File

@@ -0,0 +1,385 @@
import Foundation
enum SensitiveContentDetector {
enum Reason: String {
case privateKey
case bearerToken
case githubToken
case slackToken
case awsAccessKey
case stripeKey
case openAIToken
case googleAPIKey
case jsonWebToken
case creditCard
case highEntropyToken
case oneTimeCode
case keyword
}
static func detect(_ text: String, sourceBundleId: String? = nil, sourceApp: String? = nil) -> Reason? {
let trimmed = text.clipboardTrimmed
guard !trimmed.isEmpty else { return nil }
let bytes = Array(trimmed.utf8)
if containsPrivateKey(trimmed) { return .privateKey }
if containsBearerToken(bytes) { return .bearerToken }
if containsGitHubToken(bytes) { return .githubToken }
if containsSlackToken(bytes) { return .slackToken }
if containsAWSAccessKey(bytes) { return .awsAccessKey }
if containsStripeKey(bytes) { return .stripeKey }
if containsOpenAIToken(bytes) { return .openAIToken }
if containsGoogleAPIKey(bytes) { return .googleAPIKey }
if containsJSONWebToken(bytes) { return .jsonWebToken }
if containsCreditCard(trimmed) { return .creditCard }
if looksLikeOneTimeCode(trimmed, sourceBundleId: sourceBundleId, sourceApp: sourceApp) { return .oneTimeCode }
if looksHighEntropy(trimmed) { return .highEntropyToken }
let lowered = trimmed.lowercased()
if lowered.contains("password") || lowered.contains("secret") || lowered.contains("api_key") || looksLikeSecretAssignment(lowered) {
return .keyword
}
return nil
}
static func isLikelySensitive(_ text: String, sourceBundleId: String? = nil, sourceApp: String? = nil) -> Bool {
detect(text, sourceBundleId: sourceBundleId, sourceApp: sourceApp) != nil
}
private static func containsPrivateKey(_ text: String) -> Bool {
text.contains("-----BEGIN ") && text.contains("PRIVATE KEY-----")
}
private static func looksHighEntropy(_ text: String) -> Bool {
let candidate = text.clipboardTrimmed
guard candidate.count >= 32, candidate.count <= 256 else { return false }
guard !candidate.contains(where: { $0.isWhitespace }) else { return false }
var hasLower = false
var hasUpper = false
var hasDigit = false
var symbolCount = 0
for scalar in candidate.unicodeScalars {
let value = scalar.value
if value >= 48, value <= 57 {
hasDigit = true
} else if value >= 65, value <= 90 {
hasUpper = true
} else if value >= 97, value <= 122 {
hasLower = true
} else if value == 95 || value == 45 || value == 46 || value == 43 || value == 47 || value == 61 {
symbolCount += 1
} else {
return false
}
}
let classCount = (hasLower ? 1 : 0) + (hasUpper ? 1 : 0) + (hasDigit ? 1 : 0)
return classCount >= 2 && symbolCount > 0
}
private static func looksLikeOneTimeCode(_ text: String, sourceBundleId: String?, sourceApp: String?) -> Bool {
let value = text.clipboardTrimmed
guard value.count >= 6, value.count <= 8, value.allSatisfy({ $0.isNumber }) else { return false }
let source = ((sourceBundleId ?? "") + " " + (sourceApp ?? "")).lowercased()
guard !source.isEmpty else { return false }
return source.contains("auth") ||
source.contains("1password") ||
source.contains("bitwarden") ||
source.contains("lastpass") ||
source.contains("keeper") ||
source.contains("dashlane")
}
private static func containsCreditCard(_ text: String) -> Bool {
var digits: [Int] = []
for char in text {
if char.isNumber, let digit = char.wholeNumberValue {
digits.append(digit)
} else {
if isCreditCardGroup(digits) {
return true
}
digits.removeAll(keepingCapacity: true)
}
}
return isCreditCardGroup(digits)
}
private static func isCreditCardGroup(_ digits: [Int]) -> Bool {
guard digits.count >= 13, digits.count <= 19, let first = digits.first else {
return false
}
guard digits.contains(where: { $0 != first }) else {
return false
}
return passesLuhn(digits)
}
private static func passesLuhn(_ digits: [Int]) -> Bool {
var sum = 0
var shouldDouble = false
for digit in digits.reversed() {
var value = digit
if shouldDouble {
value *= 2
if value > 9 {
value -= 9
}
}
sum += value
shouldDouble.toggle()
}
return sum % 10 == 0
}
private static func containsBearerToken(_ bytes: [UInt8]) -> Bool {
guard bytes.count >= 27 else { return false }
for index in 0...(bytes.count - 6) where isWordBoundaryBefore(bytes, index) {
guard matchesBearer(bytes, index) else { continue }
var cursor = index + 6
guard cursor < bytes.count, isWhitespace(bytes[cursor]) else { continue }
while cursor < bytes.count, isWhitespace(bytes[cursor]) {
cursor += 1
}
let start = cursor
while cursor < bytes.count, isBearerByte(bytes[cursor]) {
cursor += 1
}
if cursor - start >= 20, isWordBoundaryAfter(bytes, cursor) {
return true
}
}
return false
}
private static func containsGitHubToken(_ bytes: [UInt8]) -> Bool {
guard bytes.count >= 34 else { return false }
for index in 0..<(bytes.count - 3) where isWordBoundaryBefore(bytes, index) {
let marker = bytes[index + 2]
guard bytes[index] == 103, bytes[index + 1] == 104, (marker == 112 || marker == 111 || marker == 117 || marker == 115 || marker == 114), bytes[index + 3] == 95 else {
continue
}
var cursor = index + 4
while cursor < bytes.count, isAlphaNumeric(bytes[cursor]) || bytes[cursor] == 95 {
cursor += 1
}
if cursor - (index + 4) >= 30, isWordBoundaryAfter(bytes, cursor) {
return true
}
}
return false
}
private static func containsSlackToken(_ bytes: [UInt8]) -> Bool {
guard bytes.count >= 25 else { return false }
for index in 0..<(bytes.count - 4) where isWordBoundaryBefore(bytes, index) {
let marker = bytes[index + 3]
guard bytes[index] == 120, bytes[index + 1] == 111, bytes[index + 2] == 120, (marker == 98 || marker == 97 || marker == 112 || marker == 114 || marker == 115), bytes[index + 4] == 45 else {
continue
}
var cursor = index + 5
while cursor < bytes.count, isAlphaNumeric(bytes[cursor]) || bytes[cursor] == 45 {
cursor += 1
}
if cursor - (index + 5) >= 20, isWordBoundaryAfter(bytes, cursor) {
return true
}
}
return false
}
private static func containsAWSAccessKey(_ bytes: [UInt8]) -> Bool {
guard bytes.count >= 20 else { return false }
for index in 0...(bytes.count - 20) where isWordBoundaryBefore(bytes, index) {
guard bytes[index] == 65, bytes[index + 1] == 75, bytes[index + 2] == 73, bytes[index + 3] == 65 else { continue }
var cursor = index + 4
while cursor < index + 20, isUpperAlphaNumeric(bytes[cursor]) {
cursor += 1
}
if cursor == index + 20, isWordBoundaryAfter(bytes, cursor) {
return true
}
}
return false
}
private static func containsStripeKey(_ bytes: [UInt8]) -> Bool {
guard bytes.count >= 24 else { return false }
for index in 0..<(bytes.count - 8) where isWordBoundaryBefore(bytes, index) {
let prefix = bytes[index]
guard (prefix == 115 || prefix == 114 || prefix == 112), bytes[index + 1] == 107, bytes[index + 2] == 95 else { continue }
let live = bytes[index + 3] == 108 && bytes[index + 4] == 105 && bytes[index + 5] == 118 && bytes[index + 6] == 101 && bytes[index + 7] == 95
let test = bytes[index + 3] == 116 && bytes[index + 4] == 101 && bytes[index + 5] == 115 && bytes[index + 6] == 116 && bytes[index + 7] == 95
guard live || test else { continue }
var cursor = index + 8
while cursor < bytes.count, isAlphaNumeric(bytes[cursor]) {
cursor += 1
}
if cursor - (index + 8) >= 16, isWordBoundaryAfter(bytes, cursor) {
return true
}
}
return false
}
private static func containsOpenAIToken(_ bytes: [UInt8]) -> Bool {
guard bytes.count >= 24 else { return false }
for index in 0..<(bytes.count - 3) where isWordBoundaryBefore(bytes, index) {
guard bytes[index] == 115, bytes[index + 1] == 107, bytes[index + 2] == 45 else { continue }
var cursor = index + 3
if cursor + 5 <= bytes.count,
bytes[cursor] == 112,
bytes[cursor + 1] == 114,
bytes[cursor + 2] == 111,
bytes[cursor + 3] == 106,
bytes[cursor + 4] == 45 {
cursor += 5
}
let tokenStart = cursor
while cursor < bytes.count, isTokenByte(bytes[cursor]) {
cursor += 1
}
if cursor - tokenStart >= 20, isWordBoundaryAfter(bytes, cursor) {
return true
}
}
return false
}
private static func containsGoogleAPIKey(_ bytes: [UInt8]) -> Bool {
guard bytes.count >= 39 else { return false }
for index in 0...(bytes.count - 39) where isWordBoundaryBefore(bytes, index) {
guard bytes[index] == 65, bytes[index + 1] == 73, bytes[index + 2] == 122, bytes[index + 3] == 97 else { continue }
var cursor = index + 4
while cursor < index + 39, isTokenByte(bytes[cursor]) {
cursor += 1
}
if cursor == index + 39, isWordBoundaryAfter(bytes, cursor) {
return true
}
}
return false
}
private static func containsJSONWebToken(_ bytes: [UInt8]) -> Bool {
guard bytes.count >= 32 else { return false }
var index = 0
while index + 3 < bytes.count {
guard isWordBoundaryBefore(bytes, index), bytes[index] == 101, bytes[index + 1] == 121, bytes[index + 2] == 74 else {
index += 1
continue
}
var cursor = index
let firstStart = cursor
while cursor < bytes.count, isBase64URLByte(bytes[cursor]) {
cursor += 1
}
guard cursor - firstStart >= 8, cursor < bytes.count, bytes[cursor] == 46 else {
index += 1
continue
}
cursor += 1
let secondStart = cursor
while cursor < bytes.count, isBase64URLByte(bytes[cursor]) {
cursor += 1
}
guard cursor - secondStart >= 8, cursor < bytes.count, bytes[cursor] == 46 else {
index += 1
continue
}
cursor += 1
let thirdStart = cursor
while cursor < bytes.count, isBase64URLByte(bytes[cursor]) {
cursor += 1
}
if cursor - thirdStart >= 8, isWordBoundaryAfter(bytes, cursor) {
return true
}
index += 1
}
return false
}
private static func looksLikeSecretAssignment(_ lowered: String) -> Bool {
let keys = [
"api_key",
"apikey",
"access_token",
"auth_token",
"client_secret",
"private_token",
"refresh_token",
"secret_key",
"passwd"
]
for key in keys {
guard let range = lowered.range(of: key) else { continue }
let suffix = lowered[range.upperBound...].drop(while: { $0.isWhitespace })
guard let separator = suffix.first, separator == "=" || separator == ":" else { continue }
let value = suffix.dropFirst().drop(while: { $0.isWhitespace || $0 == "\"" || $0 == "'" })
let valueLength = value.prefix { !$0.isWhitespace && $0 != "\"" && $0 != "'" && $0 != "," }.count
if valueLength >= 8 {
return true
}
}
return false
}
private static func matchesBearer(_ bytes: [UInt8], _ index: Int) -> Bool {
(bytes[index] == 98 || bytes[index] == 66) &&
(bytes[index + 1] == 101 || bytes[index + 1] == 69) &&
(bytes[index + 2] == 97 || bytes[index + 2] == 65) &&
(bytes[index + 3] == 114 || bytes[index + 3] == 82) &&
(bytes[index + 4] == 101 || bytes[index + 4] == 69) &&
(bytes[index + 5] == 114 || bytes[index + 5] == 82)
}
private static func isWordBoundaryBefore(_ bytes: [UInt8], _ index: Int) -> Bool {
index == 0 || !isWordByte(bytes[index - 1])
}
private static func isWordBoundaryAfter(_ bytes: [UInt8], _ index: Int) -> Bool {
index >= bytes.count || !isWordByte(bytes[index])
}
private static func isWordByte(_ byte: UInt8) -> Bool {
isAlphaNumeric(byte) || byte == 95
}
private static func isAlphaNumeric(_ byte: UInt8) -> Bool {
(byte >= 48 && byte <= 57) || (byte >= 65 && byte <= 90) || (byte >= 97 && byte <= 122)
}
private static func isUpperAlphaNumeric(_ byte: UInt8) -> Bool {
(byte >= 48 && byte <= 57) || (byte >= 65 && byte <= 90)
}
private static func isBearerByte(_ byte: UInt8) -> Bool {
isAlphaNumeric(byte) || byte == 46 || byte == 95 || byte == 45 || byte == 43 || byte == 47 || byte == 61
}
private static func isTokenByte(_ byte: UInt8) -> Bool {
isAlphaNumeric(byte) || byte == 95 || byte == 45
}
private static func isBase64URLByte(_ byte: UInt8) -> Bool {
isAlphaNumeric(byte) || byte == 95 || byte == 45
}
private static func isWhitespace(_ byte: UInt8) -> Bool {
byte == 32 || byte == 9 || byte == 10 || byte == 13
}
}

View File

@@ -0,0 +1,266 @@
import AppKit
import Carbon
final class ShortcutManager {
enum RegistrationStatus: Equatable {
case registered
case unsupportedShortcut(String)
case conflict(String)
case registrationFailed(String)
var message: String {
switch self {
case .registered:
return ""
case .unsupportedShortcut(let shortcut):
return "Unsupported shortcut: \(shortcut)"
case .conflict(let shortcut):
return "Shortcut is already in use: \(shortcut)"
case .registrationFailed(let message):
return "Shortcut registration failed: \(message)"
}
}
}
private enum HotKeyID: UInt32 {
case openPanel = 1
case openSettings = 2
}
private let onOpenClipboardPanel: () -> Void
private let onOpenSettings: () -> Void
private let onStatusChange: (RegistrationStatus) -> Void
private var openBinding: ShortcutBinding
private var settingsBinding: ShortcutBinding
private var openHotKey: EventHotKeyRef?
private var settingsHotKey: EventHotKeyRef?
private var eventHandler: EventHandlerRef?
init(
onOpenClipboardPanel: @escaping () -> Void,
onOpenSettings: @escaping () -> Void,
onStatusChange: @escaping (RegistrationStatus) -> Void = { _ in },
openShortcut: ShortcutBinding,
settingsShortcut: ShortcutBinding
) {
self.onOpenClipboardPanel = onOpenClipboardPanel
self.onOpenSettings = onOpenSettings
self.onStatusChange = onStatusChange
self.openBinding = openShortcut
self.settingsBinding = settingsShortcut
}
deinit {
stop()
}
@discardableResult
func start() -> RegistrationStatus {
stop()
if let status = validationFailure(for: openBinding) ?? validationFailure(for: settingsBinding) {
onStatusChange(status)
return status
}
if openBinding == settingsBinding {
let status = RegistrationStatus.conflict(openBinding.displayText)
onStatusChange(status)
return status
}
var eventType = EventTypeSpec(eventClass: OSType(kEventClassKeyboard), eventKind: UInt32(kEventHotKeyPressed))
let installStatus = InstallEventHandler(
GetApplicationEventTarget(),
{ _, event, userData in
guard let userData else { return noErr }
let manager = Unmanaged<ShortcutManager>.fromOpaque(userData).takeUnretainedValue()
manager.handle(event: event)
return noErr
},
1,
&eventType,
Unmanaged.passUnretained(self).toOpaque(),
&eventHandler
)
guard installStatus == noErr else {
let status = RegistrationStatus.registrationFailed(osStatusMessage(installStatus))
onStatusChange(status)
return status
}
let openStatus = register(binding: openBinding, id: .openPanel, target: &openHotKey)
guard openStatus == .registered else {
stop()
onStatusChange(openStatus)
return openStatus
}
let settingsStatus = register(binding: settingsBinding, id: .openSettings, target: &settingsHotKey)
guard settingsStatus == .registered else {
stop()
onStatusChange(settingsStatus)
return settingsStatus
}
onStatusChange(.registered)
return .registered
}
@discardableResult
func reconfigure(openShortcut: ShortcutBinding, settingsShortcut: ShortcutBinding) -> RegistrationStatus {
openBinding = openShortcut
settingsBinding = settingsShortcut
return start()
}
func stop() {
if let openHotKey {
UnregisterEventHotKey(openHotKey)
}
if let settingsHotKey {
UnregisterEventHotKey(settingsHotKey)
}
if let eventHandler {
RemoveEventHandler(eventHandler)
}
openHotKey = nil
settingsHotKey = nil
eventHandler = nil
}
private func register(binding: ShortcutBinding, id: HotKeyID, target: inout EventHotKeyRef?) -> RegistrationStatus {
guard let keyCode = Self.virtualKeyCode(for: binding.key) else {
return .unsupportedShortcut(binding.displayText)
}
let hotKeyID = EventHotKeyID(signature: Self.hotKeySignature, id: id.rawValue)
let status = RegisterEventHotKey(
UInt32(keyCode),
Self.carbonModifiers(for: binding.modifierFlags),
hotKeyID,
GetApplicationEventTarget(),
0,
&target
)
if status == noErr {
return .registered
}
if status == eventHotKeyExistsErr {
return .conflict(binding.displayText)
}
return .registrationFailed(osStatusMessage(status))
}
private func validationFailure(for binding: ShortcutBinding) -> RegistrationStatus? {
guard Self.virtualKeyCode(for: binding.key) != nil else {
return .unsupportedShortcut(binding.displayText)
}
let required = NSEvent.ModifierFlags.command.rawValue
| NSEvent.ModifierFlags.option.rawValue
| NSEvent.ModifierFlags.control.rawValue
return binding.modifierFlags & required == 0 ? .unsupportedShortcut(binding.displayText) : nil
}
private func handle(event: EventRef?) {
guard let event else { return }
var hotKeyID = EventHotKeyID()
let status = GetEventParameter(
event,
EventParamName(kEventParamDirectObject),
EventParamType(typeEventHotKeyID),
nil,
MemoryLayout<EventHotKeyID>.size,
nil,
&hotKeyID
)
guard status == noErr, hotKeyID.signature == Self.hotKeySignature else { return }
switch HotKeyID(rawValue: hotKeyID.id) {
case .openPanel:
onOpenClipboardPanel()
case .openSettings:
onOpenSettings()
case nil:
break
}
}
static func virtualKeyCode(for key: String) -> UInt16? {
guard key.utf8.count == 1, var byte = key.utf8.first else { return nil }
if byte >= 65, byte <= 90 {
byte += 32
}
switch byte {
case 97: return UInt16(kVK_ANSI_A)
case 98: return UInt16(kVK_ANSI_B)
case 99: return UInt16(kVK_ANSI_C)
case 100: return UInt16(kVK_ANSI_D)
case 101: return UInt16(kVK_ANSI_E)
case 102: return UInt16(kVK_ANSI_F)
case 103: return UInt16(kVK_ANSI_G)
case 104: return UInt16(kVK_ANSI_H)
case 105: return UInt16(kVK_ANSI_I)
case 106: return UInt16(kVK_ANSI_J)
case 107: return UInt16(kVK_ANSI_K)
case 108: return UInt16(kVK_ANSI_L)
case 109: return UInt16(kVK_ANSI_M)
case 110: return UInt16(kVK_ANSI_N)
case 111: return UInt16(kVK_ANSI_O)
case 112: return UInt16(kVK_ANSI_P)
case 113: return UInt16(kVK_ANSI_Q)
case 114: return UInt16(kVK_ANSI_R)
case 115: return UInt16(kVK_ANSI_S)
case 116: return UInt16(kVK_ANSI_T)
case 117: return UInt16(kVK_ANSI_U)
case 118: return UInt16(kVK_ANSI_V)
case 119: return UInt16(kVK_ANSI_W)
case 120: return UInt16(kVK_ANSI_X)
case 121: return UInt16(kVK_ANSI_Y)
case 122: return UInt16(kVK_ANSI_Z)
case 48: return UInt16(kVK_ANSI_0)
case 49: return UInt16(kVK_ANSI_1)
case 50: return UInt16(kVK_ANSI_2)
case 51: return UInt16(kVK_ANSI_3)
case 52: return UInt16(kVK_ANSI_4)
case 53: return UInt16(kVK_ANSI_5)
case 54: return UInt16(kVK_ANSI_6)
case 55: return UInt16(kVK_ANSI_7)
case 56: return UInt16(kVK_ANSI_8)
case 57: return UInt16(kVK_ANSI_9)
case 44: return UInt16(kVK_ANSI_Comma)
case 46: return UInt16(kVK_ANSI_Period)
case 47: return UInt16(kVK_ANSI_Slash)
case 59: return UInt16(kVK_ANSI_Semicolon)
case 39: return UInt16(kVK_ANSI_Quote)
case 91: return UInt16(kVK_ANSI_LeftBracket)
case 93: return UInt16(kVK_ANSI_RightBracket)
case 45: return UInt16(kVK_ANSI_Minus)
case 61: return UInt16(kVK_ANSI_Equal)
case 96: return UInt16(kVK_ANSI_Grave)
default: return nil
}
}
static func carbonModifiers(for modifierFlags: UInt) -> UInt32 {
var carbonFlags: UInt32 = 0
if modifierFlags & NSEvent.ModifierFlags.command.rawValue != 0 { carbonFlags |= UInt32(cmdKey) }
if modifierFlags & NSEvent.ModifierFlags.option.rawValue != 0 { carbonFlags |= UInt32(optionKey) }
if modifierFlags & NSEvent.ModifierFlags.control.rawValue != 0 { carbonFlags |= UInt32(controlKey) }
if modifierFlags & NSEvent.ModifierFlags.shift.rawValue != 0 { carbonFlags |= UInt32(shiftKey) }
return carbonFlags
}
private static let hotKeySignature: OSType = 0x436C7042
private func osStatusMessage(_ status: OSStatus) -> String {
"OSStatus \(status)"
}
}

View File

@@ -0,0 +1,457 @@
import AppKit
struct ClipboardPanelAnimationProfile {
let showDuration: TimeInterval
let hideDuration: TimeInterval
let reflowDuration: TimeInterval
let easing: CAMediaTimingFunctionName
}
struct ClipboardPanelReflowPlan {
let frame: NSRect
let bottomSafeInset: CGFloat
}
final class ClipboardPanelController: NSObject, NSWindowDelegate {
private enum Animation {
static let showDuration: TimeInterval = 0.16
static let hideDuration: TimeInterval = 0.12
static let reflowDuration: TimeInterval = 0.10
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 minimumBottomInset: CGFloat = 18
static let maximumBottomInset: CGFloat = 20
}
private var panel: NSPanel!
private var panelView: ClipboardPanelView!
private(set) var isVisible = false
private var clickMonitor: Any?
private var keyMonitor: Any?
private var targetApplication: NSRunningApplication?
private var activeScreenSnapshot: (screenFrame: CGRect, visibleFrame: CGRect)?
private let pollClipboardNow: () -> Void
private let preferredScreenProvider: () -> NSScreen?
private let openSettings: () -> Void
private var isAnimating = false
private var screenParametersObserver: NSObjectProtocol?
private static let collectionShortcuts: [UInt16: ClipboardSortMode] = [
18: .mostRecent,
19: .mostUsed,
20: .text,
21: .links,
23: .images,
22: .files,
26: .pinned,
28: .audio
]
private let viewModel: ClipboardPanelViewModel
init(
store: ClipboardStore,
settings: SettingsModel,
cacheService: ClipboardCacheService,
preferredScreen: @escaping () -> NSScreen? = { nil },
pollClipboardNow: @escaping () -> Void = {},
openSettings: @escaping () -> Void = {}
) {
self.viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: cacheService)
self.pollClipboardNow = pollClipboardNow
self.openSettings = openSettings
self.preferredScreenProvider = preferredScreen
super.init()
viewModel.targetApplicationProvider = { [weak self] in
self?.targetApplication
}
viewModel.willPasteToTarget = { [weak self] in
self?.hide(immediate: true)
}
panelView = ClipboardPanelView(
viewModel: viewModel,
onClose: { [weak self] in self?.hide() },
onSettings: { [weak self] in self?.openSettings() }
)
let contentSize = NSSize(width: 1200, height: 420)
panel = KeyablePanel(
contentRect: NSRect(x: 0, y: 0, width: contentSize.width, height: contentSize.height),
styleMask: [.nonactivatingPanel, .fullSizeContentView],
backing: .buffered,
defer: false
)
panel.contentView = panelView
panel.hasShadow = false
panel.level = .statusBar
panel.isFloatingPanel = true
panel.hidesOnDeactivate = true
panel.delegate = self
panel.isOpaque = false
panel.backgroundColor = NSColor.clear
panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
panel.becomesKeyOnlyIfNeeded = false
panel.titlebarAppearsTransparent = true
panel.titleVisibility = .hidden
panel.standardWindowButton(.miniaturizeButton)?.isHidden = true
panel.standardWindowButton(.zoomButton)?.isHidden = true
panel.standardWindowButton(.closeButton)?.isHidden = true
screenParametersObserver = NotificationCenter.default.addObserver(
forName: NSApplication.didChangeScreenParametersNotification,
object: nil,
queue: .main
) { [weak self] _ in
self?.reflowPanelForScreenChange()
}
}
deinit {
removeClickMonitor()
removeKeyMonitor()
if let screenParametersObserver {
NotificationCenter.default.removeObserver(screenParametersObserver)
}
}
func toggle() {
if isVisible {
hide()
} else {
show()
}
}
func show() {
if isVisible || isAnimating { return }
isAnimating = true
isVisible = true
rememberTargetApplication()
pollClipboardNow()
guard let screen = preferredScreen() else {
isVisible = false
isAnimating = false
return
}
activeScreenSnapshot = (screen.frame, screen.visibleFrame)
let frames = Self.panelFrames(
forScreenFrame: screen.frame,
visibleFrame: screen.visibleFrame
)
panelView.setBottomSafeInset(Self.contentBottomInset(forScreenFrame: screen.frame, visibleFrame: screen.visibleFrame))
panel.setFrame(frames.hidden, display: false)
panel.alphaValue = 0.0
NSApp.activate(ignoringOtherApps: true)
panel.makeKeyAndOrderFront(nil)
panelView.prepareForShow()
viewModel.selectFirstItem()
panelView.beginOpeningTransition()
NSAnimationContext.runAnimationGroup { context in
context.duration = Animation.showDuration
context.allowsImplicitAnimation = true
context.timingFunction = CAMediaTimingFunction(name: Animation.easing)
panel.animator().setFrame(frames.shown, display: true)
panel.animator().alphaValue = 1.0
} completionHandler: { [weak self] in
guard let self else { return }
self.isAnimating = false
self.panelView.finishOpeningTransition()
guard self.isVisible else { return }
self.installClickMonitor()
self.panelView.focusSearchField()
}
installKeyMonitor()
}
func hide() {
hide(immediate: false)
}
func hide(immediate: Bool) {
guard isVisible || isAnimating else { return }
if immediate {
isAnimating = false
panelView.finishOpeningTransition()
panel.orderOut(nil)
isVisible = false
removeClickMonitor()
removeKeyMonitor()
activeScreenSnapshot = nil
return
}
let screenFrames = activeScreenSnapshot ?? activeScreenFrames()
let hidden = Self.panelFrames(
forScreenFrame: screenFrames.screenFrame,
visibleFrame: screenFrames.visibleFrame
).hidden
isAnimating = true
NSAnimationContext.runAnimationGroup { context in
context.duration = Animation.hideDuration
context.allowsImplicitAnimation = true
context.timingFunction = CAMediaTimingFunction(name: Animation.easing)
panel.animator().alphaValue = 0.0
panel.animator().setFrame(hidden, display: true)
} completionHandler: { [weak self] in
self?.panel.orderOut(nil)
self?.panel.alphaValue = 1.0
self?.isVisible = false
self?.isAnimating = false
self?.activeScreenSnapshot = nil
self?.panelView.finishOpeningTransition()
self?.removeClickMonitor()
self?.removeKeyMonitor()
}
}
func windowDidResignKey(_ notification: Notification) {
hide()
}
func windowDidBecomeKey(_ notification: Notification) {
panelView.focusSearchField()
}
private func preferredScreen() -> NSScreen? {
if let menuBarScreen = preferredScreenProvider() {
return menuBarScreen
}
let point = NSEvent.mouseLocation
return NSScreen.screens.first { NSMouseInRect(point, $0.frame, false) } ?? NSScreen.screens.first
}
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) {
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)
let shown = NSRect(
x: shownMinX,
y: shownMinY,
width: targetWidth,
height: height
)
let hidden = NSRect(
x: shown.minX,
y: shown.minY - height - 1,
width: shown.width,
height: height
)
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)
}
static func contentBottomInset(forScreenFrame screenFrame: CGRect, visibleFrame: CGRect) -> CGFloat {
let dockInset = max(0, visibleFrame.minY - screenFrame.minY)
return max(Metrics.minimumBottomInset, min(Metrics.maximumBottomInset, dockInset + 2))
}
static var animationProfile: ClipboardPanelAnimationProfile {
ClipboardPanelAnimationProfile(
showDuration: Animation.showDuration,
hideDuration: Animation.hideDuration,
reflowDuration: Animation.reflowDuration,
easing: Animation.easing
)
}
static func reflowPlan(forScreenFrame screenFrame: CGRect, visibleFrame: CGRect) -> ClipboardPanelReflowPlan {
let frames = panelFrames(forScreenFrame: screenFrame, visibleFrame: visibleFrame)
return ClipboardPanelReflowPlan(
frame: frames.shown,
bottomSafeInset: contentBottomInset(forScreenFrame: screenFrame, visibleFrame: visibleFrame)
)
}
private func rememberTargetApplication() {
guard let frontmost = NSWorkspace.shared.frontmostApplication else {
targetApplication = nil
return
}
if frontmost.processIdentifier == NSRunningApplication.current.processIdentifier {
targetApplication = nil
return
}
targetApplication = frontmost
}
private func removeClickMonitor() {
if let clickMonitor {
NSEvent.removeMonitor(clickMonitor)
self.clickMonitor = nil
}
}
private func installKeyMonitor() {
removeKeyMonitor()
keyMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in
guard let self else { return event }
if self.shouldHandlePanelKeyEvent(event, allowSearchFieldEditing: true),
let mode = Self.collectionShortcutMode(forKeyCode: event.keyCode, modifiers: event.modifierFlags) {
self.viewModel.sortMode = mode
return nil
}
guard self.shouldHandlePanelKeyEvent(event) else { return event }
switch event.keyCode {
case 53:
self.hide()
return nil
case 36:
self.viewModel.pasteSelected()
return nil
case 51, 117:
self.viewModel.deleteSelected()
return nil
case 123:
self.viewModel.moveSelection(-1)
return nil
case 124:
self.viewModel.moveSelection(1)
return nil
case 35:
self.viewModel.togglePinSelected()
return nil
default:
return event
}
}
}
private func shouldHandlePanelKeyEvent(_ event: NSEvent) -> Bool {
shouldHandlePanelKeyEvent(event, allowSearchFieldEditing: false)
}
private func shouldHandlePanelKeyEvent(_ event: NSEvent, allowSearchFieldEditing: Bool) -> Bool {
if !allowSearchFieldEditing, self.panelView.isSearchFieldEditing {
return false
}
guard let keyWindow = NSApp.keyWindow,
keyWindow == panel else {
return false
}
// If a key event belongs to this panel while it has lost key status temporarily
// during opening/animations, still allow keyboard shortcuts.
return event.windowNumber == panel.windowNumber
|| NSApp.window(withWindowNumber: event.windowNumber) === panel
}
static func collectionShortcutMode(forKeyCode keyCode: UInt16, modifiers: NSEvent.ModifierFlags) -> ClipboardSortMode? {
let relevantModifiers = modifiers.intersection(.deviceIndependentFlagsMask)
guard relevantModifiers == .command else { return nil }
return collectionShortcuts[keyCode]
}
#if DEBUG
var debugPanelFrame: NSRect {
panel.frame
}
var debugPanelAlpha: CGFloat {
panel.alphaValue
}
var debugIsAnimating: Bool {
isAnimating
}
#endif
private func installClickMonitor() {
removeClickMonitor()
guard isVisible else { return }
let panelWindowNumber = panel.windowNumber
clickMonitor = NSEvent.addGlobalMonitorForEvents(matching: [.leftMouseDown, .rightMouseDown]) { [weak self] event in
guard let self else { return }
guard self.isVisible else { return }
if event.windowNumber == panelWindowNumber {
return
}
if NSApp.window(withWindowNumber: event.windowNumber) === self.panel {
return
}
let point = NSEvent.mouseLocation
if !self.panel.frame.contains(point) {
self.hide()
}
}
}
private func reflowPanelForScreenChange() {
guard isVisible else { return }
guard !isAnimating else { return }
guard let screen = preferredScreen() ?? panel.screen ?? NSScreen.screens.first else { return }
activeScreenSnapshot = (screen.frame, screen.visibleFrame)
let plan = Self.reflowPlan(forScreenFrame: screen.frame, visibleFrame: screen.visibleFrame)
panelView.setBottomSafeInset(plan.bottomSafeInset)
isAnimating = true
NSAnimationContext.runAnimationGroup { context in
context.duration = Animation.reflowDuration
context.allowsImplicitAnimation = true
context.timingFunction = CAMediaTimingFunction(name: Animation.easing)
panel.animator().setFrame(plan.frame, display: true)
} completionHandler: { [weak self] in
self?.isAnimating = false
self?.installClickMonitor()
}
}
private func removeKeyMonitor() {
if let keyMonitor {
NSEvent.removeMonitor(keyMonitor)
self.keyMonitor = nil
}
}
private func activeScreenFrames() -> (screenFrame: CGRect, visibleFrame: CGRect) {
let pointer = NSEvent.mouseLocation
if let screen = NSScreen.screens.first(where: { NSMouseInRect(pointer, $0.frame, false) }) {
return (screen.frame, screen.visibleFrame)
}
let fallback = preferredScreen() ?? NSScreen.screens.first
return (fallback?.frame ?? .zero, fallback?.visibleFrame ?? .zero)
}
}
private final class KeyablePanel: NSPanel {
override var canBecomeKey: Bool { true }
override var canBecomeMain: Bool { true }
}
private extension CGRect {
var center: NSPoint {
NSPoint(x: midX, y: midY)
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,406 @@
import Foundation
import AppKit
final class ClipboardPanelViewModel {
private(set) var visibleItems: [ClipboardItem] = [] {
didSet { notifyMain { self.onVisibleItemsChanged?(self.visibleItems) } }
}
var searchText: String = "" {
didSet {
guard oldValue != searchText else { return }
selectedItemID = selectedItem?.id
recomputeVisibleItems()
}
}
var sortMode: ClipboardSortMode {
didSet {
guard oldValue != sortMode else { return }
selectedCollectionName = nil
settings.defaultSortMode = sortMode
recomputeVisibleItems()
onSortModeChanged?(sortMode)
}
}
private(set) var selectedCollectionName: String? {
didSet {
guard oldValue != selectedCollectionName else { return }
recomputeVisibleItems()
onCollectionsChanged?()
}
}
var selectedIndex: Int = 0 {
didSet {
guard oldValue != selectedIndex else { return }
notifyMain { self.onSelectedIndexChanged?(self.selectedIndex) }
}
}
private(set) var statusMessage: String = "" {
didSet { notifyMain { self.onStatusMessageChanged?(self.statusMessage) } }
}
private var items: [ClipboardItem] = []
private let store: ClipboardStore
private let settings: SettingsModel
private let cacheService: ClipboardCacheService
private let pasteService: PasteActionService
private var selectedItemID: UUID?
var targetApplicationProvider: () -> NSRunningApplication? = { nil }
var willPasteToTarget: () -> Void = {}
var onVisibleItemsChanged: (([ClipboardItem]) -> Void)?
var onSelectedIndexChanged: ((Int) -> Void)?
var onStatusMessageChanged: ((String) -> Void)?
var onSortModeChanged: ((ClipboardSortMode) -> Void)?
var onCollectionsChanged: (() -> Void)?
var onCaptureStatusChanged: (() -> Void)?
init(store: ClipboardStore, settings: SettingsModel, cacheService: ClipboardCacheService) {
self.store = store
self.settings = settings
self.cacheService = cacheService
self.sortMode = settings.defaultSortMode
self.pasteService = PasteActionService(cacheService: cacheService)
store.observeItems { [weak self] list in
self?.notifyMain {
self?.items = list
self?.recomputeVisibleItems()
}
}
settings.observe { [weak self] change in
guard case .captureStatus = change else { return }
self?.notifyMain {
self?.statusMessage = ""
self?.onStatusMessageChanged?("")
self?.onCaptureStatusChanged?()
}
}
}
var selectedItem: ClipboardItem? {
guard selectedIndex >= 0, selectedIndex < visibleItems.count else { return nil }
return visibleItems[selectedIndex]
}
var totalItemCount: Int {
items.count
}
var collectionNames: [String] {
let assignedNames = Set(
items.compactMap { item -> String? in
ClipboardCollectionDefaults.normalizedName(item.collectionName)
}
)
let defaultNames = ClipboardCollectionDefaults.names.filter { assignedNames.contains($0) }
let customNames = assignedNames
.filter { !ClipboardCollectionDefaults.names.contains($0) }
.sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending }
return defaultNames + customNames
}
func collectionCount(for sortMode: ClipboardSortMode) -> Int {
let query = searchText.clipboardTrimmed.lowercased()
return computeVisibleItems(from: items, query: query, sortMode: sortMode).count
}
func collectionCount(named name: String) -> Int {
let query = searchText.clipboardTrimmed.lowercased()
return computeVisibleItems(from: items, query: query, sortMode: sortMode, collectionName: name).count
}
var captureStatusMessage: String {
settings.captureStatusMessage
}
func thumbnail(for item: ClipboardItem) -> NSImage? {
cacheService.previewThumbnail(for: item)
}
func selectItem(at index: Int) {
guard index >= 0 && index < visibleItems.count else { return }
selectedIndex = index
}
func selectFirstItem() {
guard !visibleItems.isEmpty else { return }
selectedItemID = nil
if selectedIndex == 0 {
notifyMain { self.onSelectedIndexChanged?(self.selectedIndex) }
} else {
selectedIndex = 0
}
}
func moveSelection(_ delta: Int) {
let count = visibleItems.count
guard count > 0 else { return }
let target = max(0, min(count - 1, selectedIndex + delta))
selectedIndex = target
}
func pasteSelected() {
guard let item = selectedItem else { return }
let result = pasteService.paste(item, targetApp: targetApplicationProvider())
if case .pasted = result {
willPasteToTarget()
}
if case .failed = result {} else {
store.markUsed(item.id)
selectedItemID = item.id
}
statusMessage = result.message
settings.setPasteStatus(message: result.message)
}
func copySelected() {
guard let item = selectedItem else { return }
let result = pasteService.copy(item)
if case .failed = result {} else {
store.markUsed(item.id)
selectedItemID = item.id
}
statusMessage = result.message
settings.setPasteStatus(message: result.message)
}
func openSelected() {
guard let item = selectedItem else { return }
switch item.kind {
case .url:
guard let url = URL(string: item.payload) else { return }
NSWorkspace.shared.open(url)
case .file:
let urls = FilePayload.urls(from: item.payload)
guard !urls.isEmpty, urls.allSatisfy({ FileManager.default.fileExists(atPath: $0.path) }) else { return }
NSWorkspace.shared.activateFileViewerSelecting(urls)
case .image:
guard let url = cacheService.temporaryReadableURL(for: item) else { return }
NSWorkspace.shared.open(url)
case .pdf:
guard let url = cacheService.temporaryReadableURL(for: item) else { return }
NSWorkspace.shared.open(url)
case .audio:
guard let url = cacheService.temporaryReadableURL(for: item) else { return }
NSWorkspace.shared.open(url)
default:
break
}
}
func revealSelected() {
guard let item = selectedItem else { return }
switch item.kind {
case .image:
guard let url = cacheService.temporaryReadableURL(for: item) else { return }
NSWorkspace.shared.activateFileViewerSelecting([url])
case .file:
let urls = FilePayload.urls(from: item.payload)
guard !urls.isEmpty, urls.allSatisfy({ FileManager.default.fileExists(atPath: $0.path) }) else { return }
NSWorkspace.shared.activateFileViewerSelecting(urls)
case .pdf:
guard let url = cacheService.temporaryReadableURL(for: item) else { return }
NSWorkspace.shared.activateFileViewerSelecting([url])
case .audio:
guard let url = cacheService.temporaryReadableURL(for: item) else { return }
NSWorkspace.shared.activateFileViewerSelecting([url])
default:
break
}
}
func deleteSelected() {
guard let item = selectedItem else { return }
store.remove(item.id)
let next = max(0, min(visibleItems.count - 2, selectedIndex))
selectedIndex = next
}
func togglePinSelected() {
guard let item = selectedItem else { return }
store.togglePin(item.id)
}
func assignSelected(to collectionName: String?) {
guard let item = selectedItem else { return }
selectedItemID = item.id
let normalizedName = ClipboardCollectionDefaults.normalizedName(collectionName)
store.setCollection(item.id, name: normalizedName)
if let normalizedName {
statusMessage = "Added to \(normalizedName)"
} else {
statusMessage = "Removed from collection"
}
}
func selectCollection(named name: String) {
guard let normalizedName = ClipboardCollectionDefaults.normalizedName(name) else { return }
selectedCollectionName = normalizedName
}
func clearSearch() {
searchText = ""
}
func recomputeVisibleItems() {
let previousSelection = selectedItemID
let query = searchText.clipboardTrimmed.lowercased()
visibleItems = computeVisibleItems(from: items, query: query, sortMode: sortMode, collectionName: selectedCollectionName)
if let selectedID = previousSelection, let index = visibleItems.firstIndex(where: { $0.id == selectedID }) {
selectedIndex = index
} else if selectedIndex >= visibleItems.count {
selectedIndex = max(0, visibleItems.count - 1)
} else if selectedIndex < 0 {
selectedIndex = 0
}
if visibleItems.isEmpty {
selectedIndex = 0
}
selectedItemID = selectedItem?.id
onCollectionsChanged?()
}
internal func computeVisibleItems(
from items: [ClipboardItem],
query: String,
sortMode: ClipboardSortMode,
collectionName: String? = nil
) -> [ClipboardItem] {
let tokens = searchTokens(from: query.lowercased())
let filtered = tokens.isEmpty
? items.enumerated().map { ($0.offset, $0.element) }
: items.enumerated().compactMap { index, item in
let text = searchableText(for: item)
return tokens.allSatisfy { text.contains($0) } ? (index, item) : nil
}
let collectionFiltered: [(Int, ClipboardItem)]
if let collectionName = ClipboardCollectionDefaults.normalizedName(collectionName) {
collectionFiltered = filtered.filter {
$0.1.collectionName?.caseInsensitiveCompare(collectionName) == .orderedSame
}
} else {
collectionFiltered = filtered
}
func fallback(_ lhs: (Int, ClipboardItem), _ rhs: (Int, ClipboardItem)) -> Bool {
return lhs.0 < rhs.0
}
func sortByUsage(_ lhs: (Int, ClipboardItem), _ rhs: (Int, ClipboardItem)) -> Bool {
if lhs.1.lastUsedAt == rhs.1.lastUsedAt {
return fallback(lhs, rhs)
}
return lhs.1.lastUsedAt > rhs.1.lastUsedAt
}
if ClipboardCollectionDefaults.normalizedName(collectionName) != nil {
return collectionFiltered
.sorted {
if $0.1.lastUsedAt == $1.1.lastUsedAt {
if $0.1.createdAt == $1.1.createdAt { return fallback($0, $1) }
return $0.1.createdAt > $1.1.createdAt
}
return $0.1.lastUsedAt > $1.1.lastUsedAt
}
.map(\.1)
}
switch sortMode {
case .mostRecent:
return collectionFiltered
.sorted {
if $0.1.lastUsedAt == $1.1.lastUsedAt {
if $0.1.createdAt == $1.1.createdAt { return fallback($0, $1) }
return $0.1.createdAt > $1.1.createdAt
}
return $0.1.lastUsedAt > $1.1.lastUsedAt
}
.map(\.1)
case .mostUsed:
return collectionFiltered
.sorted {
if $0.1.useCount == $1.1.useCount { return sortByUsage($0, $1) }
return $0.1.useCount > $1.1.useCount
}
.map(\.1)
case .images:
return collectionFiltered
.filter { $0.1.kind == .image }
.sorted(by: sortByUsage)
.map(\.1)
case .links:
return collectionFiltered
.filter { $0.1.kind == .url }
.sorted(by: sortByUsage)
.map(\.1)
case .text:
return collectionFiltered
.filter { $0.1.kind == .text || $0.1.kind == .richText }
.sorted(by: sortByUsage)
.map(\.1)
case .files:
return collectionFiltered
.filter { $0.1.kind == .file || $0.1.kind == .pdf }
.sorted(by: sortByUsage)
.map(\.1)
case .audio:
return collectionFiltered
.filter { $0.1.kind == .audio }
.sorted(by: sortByUsage)
.map(\.1)
case .pinned:
return collectionFiltered
.filter { $0.1.isPinned }
.sorted {
if $0.1.lastUsedAt == $1.1.lastUsedAt {
if $0.1.createdAt == $1.1.createdAt {
return fallback($0, $1)
}
return $0.1.createdAt > $1.1.createdAt
}
return $0.1.lastUsedAt > $1.1.lastUsedAt
}
.map(\.1)
@unknown default:
return collectionFiltered
.sorted(by: { lhs, rhs in
return fallback(lhs, rhs)
})
.map(\.1)
}
}
private func searchableText(for item: ClipboardItem) -> String {
var base = item.searchableText
if settings.includeImageTextInSearch, let ocrText = item.ocrText {
base += " \(ocrText.lowercased())"
}
return base
}
private func searchTokens(from query: String) -> [String] {
query
.split { character in
character.isWhitespace || character.isPunctuation
}
.map(String.init)
}
private func notifyMain(_ block: @escaping () -> Void) {
if Thread.isMainThread {
block()
} else {
DispatchQueue.main.async(execute: block)
}
}
}

View File

@@ -0,0 +1,642 @@
import AppKit
final class SettingsWindowController: NSObject, NSTextFieldDelegate, NSTextViewDelegate {
private let settings: SettingsModel
private let store: ClipboardStore
private let cacheService: ClipboardCacheService
private var window: NSWindow?
private let historyLabel = NSTextField(labelWithString: "")
private let historyStepper = NSStepper()
private let pruneDuplicatesButton = NSButton()
private let keepFirstImageButton = NSButton()
private let defaultSortPopup = NSPopUpButton()
private let launchAtLoginButton = NSButton()
private let showMenuBarIconButton = NSButton()
private let launchStatusLabel = NSTextField(labelWithString: "")
private var openShortcutControls: ShortcutControlSet?
private var settingsShortcutControls: ShortcutControlSet?
private let shortcutStatusLabel = NSTextField(labelWithString: "")
private let pauseCaptureButton = NSButton()
private let captureStatusLabel = NSTextField(labelWithString: "")
private let excludeSensitiveButton = NSButton()
private let includeImageTextButton = NSButton()
private var allowedKindButtons: [(ClipboardItemKind, NSButton)] = []
private let ignoredAppsTextView = NSTextView()
private let clearHistoryOnQuitButton = NSButton()
private let accessibilityStatusLabel = NSTextField(labelWithString: "")
private let pasteStatusLabel = NSTextField(labelWithString: "")
private let pollProfilePopup = NSPopUpButton()
private let cacheSlider = NSSlider()
private let cacheLabel = NSTextField(labelWithString: "")
init(settings: SettingsModel, store: ClipboardStore, cacheService: ClipboardCacheService) {
self.settings = settings
self.store = store
self.cacheService = cacheService
super.init()
let windowRect = NSRect(x: 0, y: 0, width: 620, height: 560)
let window = NSWindow(
contentRect: windowRect,
styleMask: [.titled, .closable, .miniaturizable],
backing: .buffered,
defer: false
)
window.title = "ClipBored Settings"
window.contentView = makeContentView()
window.isReleasedWhenClosed = false
window.center()
self.window = window
settings.observe { [weak self] _ in
DispatchQueue.main.async {
self?.refreshFromSettings()
}
}
refreshFromSettings()
}
func show() {
guard let window else { return }
refreshFromSettings()
window.makeKeyAndOrderFront(nil)
NSApp.activate(ignoringOtherApps: true)
}
private func makeContentView() -> NSView {
let tabView = NSTabView()
tabView.translatesAutoresizingMaskIntoConstraints = false
tabView.addTabViewItem(tab("General", generalSettingsView()))
tabView.addTabViewItem(tab("Shortcuts", shortcutSettingsView()))
tabView.addTabViewItem(tab("Capture", captureSettingsView()))
tabView.addTabViewItem(tab("Privacy", privacySettingsView()))
tabView.addTabViewItem(tab("Performance", performanceSettingsView()))
tabView.addTabViewItem(tab("Data", dataSettingsView()))
let container = NSView()
container.addSubview(tabView)
NSLayoutConstraint.activate([
tabView.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 12),
tabView.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -12),
tabView.topAnchor.constraint(equalTo: container.topAnchor, constant: 12),
tabView.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: -12)
])
return container
}
private func tab(_ title: String, _ view: NSView) -> NSTabViewItem {
let item = NSTabViewItem(identifier: title)
item.label = title
item.view = scrollContainer(for: view)
return item
}
private func scrollContainer(for content: NSView) -> NSView {
let scrollView = NSScrollView()
scrollView.hasVerticalScroller = true
scrollView.drawsBackground = false
scrollView.documentView = content
content.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
content.widthAnchor.constraint(equalTo: scrollView.contentView.widthAnchor)
])
return scrollView
}
private func generalSettingsView() -> NSView {
historyStepper.minValue = Double(AppConfiguration.minHistoryLength)
historyStepper.maxValue = Double(AppConfiguration.maxHistoryLength)
historyStepper.increment = 25
historyStepper.target = self
historyStepper.action = #selector(historyLengthChanged)
historyStepper.setAccessibilityLabel("History length")
configureCheckbox(pruneDuplicatesButton, title: "Ignore duplicate items", action: #selector(pruneDuplicatesChanged))
configureCheckbox(keepFirstImageButton, title: "Keep first image copy", action: #selector(keepFirstImageChanged))
configurePopup(defaultSortPopup, action: #selector(defaultSortChanged))
defaultSortPopup.setAccessibilityLabel("Default sort")
for mode in ClipboardSortMode.allCases {
addPopupItem(mode.title, mode.rawValue, to: defaultSortPopup)
}
configureCheckbox(launchAtLoginButton, title: "Launch at login", action: #selector(launchAtLoginChanged))
configureCheckbox(showMenuBarIconButton, title: "Show ClipBored in the menu bar", action: #selector(showMenuBarIconChanged))
configureStatusLabel(launchStatusLabel)
return page([
section("History", [
row([historyLabel, historyStepper]),
pruneDuplicatesButton,
keepFirstImageButton
]),
section("Sort", [
labeledRow("Default sort", defaultSortPopup)
]),
section("Lifecycle", [
launchAtLoginButton,
showMenuBarIconButton,
launchStatusLabel
])
])
}
private func shortcutSettingsView() -> NSView {
let openRow = shortcutRow("Open Clipboard", binding: settings.openShortcut, baseTag: 100)
let settingsRow = shortcutRow("Open Settings", binding: settings.settingsShortcut, baseTag: 200)
configureStatusLabel(shortcutStatusLabel)
return page([
section("Shortcuts", [
openRow,
settingsRow,
shortcutStatusLabel
])
])
}
private func captureSettingsView() -> NSView {
configureCheckbox(pauseCaptureButton, title: "Pause clipboard capture", action: #selector(pauseCaptureChanged))
configureCheckbox(excludeSensitiveButton, title: "Exclude likely secrets", action: #selector(excludeSensitiveChanged))
configureCheckbox(includeImageTextButton, title: "Search in image labels", action: #selector(includeImageTextChanged))
configureStatusLabel(captureStatusLabel)
let allowedRows = [
kindCheckbox("Text", .text),
kindCheckbox("Links", .url),
kindCheckbox("Images", .image),
kindCheckbox("Audio", .audio),
kindCheckbox("Rich text", .richText),
kindCheckbox("PDFs", .pdf),
kindCheckbox("Files", .file)
]
ignoredAppsTextView.delegate = self
ignoredAppsTextView.font = .systemFont(ofSize: NSFont.systemFontSize)
ignoredAppsTextView.setAccessibilityLabel("Ignored source apps")
let ignoredScroll = NSScrollView()
ignoredScroll.hasVerticalScroller = true
ignoredScroll.borderType = .bezelBorder
ignoredScroll.documentView = ignoredAppsTextView
ignoredScroll.heightAnchor.constraint(equalToConstant: 96).isActive = true
return page([
section("Capture", [
pauseCaptureButton,
excludeSensitiveButton,
includeImageTextButton,
captureStatusLabel
]),
section("Allowed content types", allowedRows),
section("Ignored source apps", [
ignoredScroll
])
])
}
private func privacySettingsView() -> NSView {
let storageLabel = caption("Clipboard history is stored locally in Application Support. Text, image cache files, audio clips, and PDF attachments are encrypted with Keychain when available, or an owner-only local fallback key if needed.")
let permissionHelpLabel = caption("Clipboard history capture works without this permission. Grant Accessibility to paste selected items into the previous app.")
configureCheckbox(clearHistoryOnQuitButton, title: "Clear history on quit", action: #selector(clearHistoryOnQuitChanged))
configureStatusLabel(accessibilityStatusLabel)
let requestButton = button("Open Accessibility Settings", #selector(requestAccessibilityAccess))
let refreshButton = button("Refresh Permission Status", #selector(refreshAccessibilityPermissionStatus))
configureStatusLabel(pasteStatusLabel)
return page([
section("Local storage", [
storageLabel,
clearHistoryOnQuitButton
]),
section("Paste permission", [
permissionHelpLabel,
row([NSTextField(labelWithString: "Accessibility"), accessibilityStatusLabel]),
row([requestButton, refreshButton])
]),
section("Paste status", [
pasteStatusLabel
])
])
}
private func performanceSettingsView() -> NSView {
configurePopup(pollProfilePopup, action: #selector(pollProfileChanged))
pollProfilePopup.setAccessibilityLabel("Polling profile")
for profile in AppConfiguration.PollProfile.allCases {
addPopupItem(profile.title, profile.rawValue, to: pollProfilePopup)
}
cacheSlider.minValue = 4
cacheSlider.maxValue = 512
cacheSlider.numberOfTickMarks = 9
cacheSlider.allowsTickMarkValuesOnly = true
cacheSlider.target = self
cacheSlider.action = #selector(cacheLimitChanged)
cacheSlider.setAccessibilityLabel("Image cache cap in megabytes")
configureStatusLabel(cacheLabel)
return page([
section("Polling", [
labeledRow("Polling profile", pollProfilePopup)
]),
section("Cache", [
labeledRow("Image cache cap (MB)", cacheSlider),
cacheLabel
])
])
}
private func dataSettingsView() -> NSView {
page([
section("Data", [
button("Open History Folder", #selector(openHistoryFolder)),
button("Clear Clipboard History", #selector(clearClipboardHistory)),
button("Clear Thumbnail Cache", #selector(clearThumbnailCache))
])
])
}
private func page(_ views: [NSView]) -> NSView {
let stack = NSStackView(views: views)
stack.orientation = .vertical
stack.alignment = .leading
stack.spacing = 18
stack.edgeInsets = NSEdgeInsets(top: 18, left: 18, bottom: 18, right: 18)
return stack
}
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: 150).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 configureCheckbox(_ control: NSButton, title: String, action: Selector) {
control.setButtonType(.switch)
control.title = title
control.target = self
control.action = action
control.setAccessibilityLabel(title)
}
private func configureStatusLabel(_ label: NSTextField) {
label.textColor = .secondaryLabelColor
label.font = .systemFont(ofSize: NSFont.smallSystemFontSize)
label.lineBreakMode = .byTruncatingTail
}
private func configurePopup(_ popup: NSPopUpButton, action: Selector) {
popup.removeAllItems()
popup.target = self
popup.action = action
}
private func addPopupItem(_ title: String, _ rawValue: Int, to popup: NSPopUpButton) {
popup.addItem(withTitle: title)
popup.lastItem?.representedObject = rawValue
}
private func kindCheckbox(_ title: String, _ kind: ClipboardItemKind) -> NSButton {
let control = NSButton()
configureCheckbox(control, title: title, action: #selector(allowedKindChanged(_:)))
control.tag = kind.rawValue
allowedKindButtons.append((kind, control))
return control
}
private func shortcutRow(_ title: String, binding: ShortcutBinding, baseTag: Int) -> NSView {
let label = NSTextField(labelWithString: title)
label.widthAnchor.constraint(equalToConstant: 120).isActive = true
let keyField = NSTextField(string: binding.key.uppercased())
keyField.placeholderString = "Key"
keyField.alignment = .center
keyField.delegate = self
keyField.target = self
keyField.action = #selector(shortcutChanged(_:))
keyField.tag = baseTag
keyField.setAccessibilityLabel("\(title) shortcut key")
keyField.widthAnchor.constraint(equalToConstant: 56).isActive = true
let command = modifierButton("", baseTag + 1)
let option = modifierButton("", baseTag + 2)
let control = modifierButton("", baseTag + 3)
let shift = modifierButton("", baseTag + 4)
let controls = ShortcutControlSet(keyField: keyField, command: command, option: option, control: control, shift: shift)
if baseTag == 100 {
openShortcutControls = controls
} else {
settingsShortcutControls = controls
}
return row([label, keyField, command, option, control, shift])
}
private func modifierButton(_ title: String, _ tag: Int) -> NSButton {
let control = NSButton()
configureCheckbox(control, title: title, action: #selector(shortcutChanged(_:)))
control.toolTip = modifierTooltip(title)
control.tag = tag
return control
}
private func modifierTooltip(_ title: String) -> String {
switch title {
case "": return "Command"
case "": return "Option"
case "": return "Control"
case "": return "Shift"
default: return title
}
}
private func refreshFromSettings() {
historyLabel.stringValue = "History length: \(settings.maxHistoryItems)"
historyStepper.integerValue = settings.maxHistoryItems
pruneDuplicatesButton.state = settings.pruneDuplicates ? .on : .off
keepFirstImageButton.state = settings.keepFirstImage ? .on : .off
select(defaultSortPopup, rawValue: settings.defaultSortMode.rawValue)
launchAtLoginButton.state = settings.launchAtLogin ? .on : .off
showMenuBarIconButton.state = settings.showMenuBarIcon ? .on : .off
launchStatusLabel.stringValue = settings.launchAtLoginErrorMessage
refreshShortcutControls(openShortcutControls, binding: settings.openShortcut)
refreshShortcutControls(settingsShortcutControls, binding: settings.settingsShortcut)
shortcutStatusLabel.stringValue = settings.shortcutStatusMessage.isEmpty ? "Registered" : settings.shortcutStatusMessage
pauseCaptureButton.state = settings.pauseCapture ? .on : .off
captureStatusLabel.stringValue = settings.captureStatusMessage.isEmpty ? "Capture status will appear after the app sees clipboard activity." : settings.captureStatusMessage
excludeSensitiveButton.state = settings.excludeSensitive ? .on : .off
includeImageTextButton.state = settings.includeImageTextInSearch ? .on : .off
for (kind, button) in allowedKindButtons {
button.state = settings.ignoredItemKindsRaw.contains(kind.rawValue) ? .off : .on
}
let ignoredAppsText = settings.ignoredApps.joined(separator: ", ")
if ignoredAppsTextView.string != ignoredAppsText {
ignoredAppsTextView.string = ignoredAppsText
}
clearHistoryOnQuitButton.state = settings.clearHistoryOnQuit ? .on : .off
let hasAccessibilityPermission = AccessibilityPermissionService.isTrusted
let permissionStatus = hasAccessibilityPermission
? "Granted"
: "Not granted (clipboard capture still works; paste falls back to copy)"
accessibilityStatusLabel.stringValue = permissionStatus
accessibilityStatusLabel.textColor = hasAccessibilityPermission ? .systemGreen : .systemOrange
pasteStatusLabel.stringValue = settings.pasteStatusMessage.isEmpty ? "No paste action yet." : settings.pasteStatusMessage
select(pollProfilePopup, rawValue: settings.pollProfileRaw.rawValue)
cacheSlider.doubleValue = Double(settings.imageCacheMaxBytes) / 1024 / 1024
cacheLabel.stringValue = "Current cache cap: \(Int(cacheSlider.doubleValue)) MB"
}
private func refreshShortcutControls(_ controls: ShortcutControlSet?, binding: ShortcutBinding) {
guard let controls else { return }
controls.keyField.stringValue = binding.key.uppercased()
controls.command.state = binding.has(.command) ? .on : .off
controls.option.state = binding.has(.option) ? .on : .off
controls.control.state = binding.has(.control) ? .on : .off
controls.shift.state = binding.has(.shift) ? .on : .off
}
private func select(_ popup: NSPopUpButton, rawValue: Int) {
for item in popup.itemArray where item.representedObject as? Int == rawValue {
popup.select(item)
return
}
}
@objc private func historyLengthChanged() {
settings.maxHistoryItems = historyStepper.integerValue
historyLabel.stringValue = "History length: \(settings.maxHistoryItems)"
}
@objc private func pruneDuplicatesChanged() {
settings.pruneDuplicates = pruneDuplicatesButton.state == .on
}
@objc private func keepFirstImageChanged() {
settings.keepFirstImage = keepFirstImageButton.state == .on
}
@objc private func defaultSortChanged() {
if let rawValue = defaultSortPopup.selectedItem?.representedObject as? Int,
let mode = ClipboardSortMode(rawValue: rawValue) {
settings.defaultSortMode = mode
}
}
@objc private func launchAtLoginChanged() {
settings.launchAtLogin = launchAtLoginButton.state == .on
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in
self?.refreshFromSettings()
}
}
@objc private func showMenuBarIconChanged() {
settings.showMenuBarIcon = showMenuBarIconButton.state == .on
}
@objc private func shortcutChanged(_ sender: NSControl) {
let isOpenShortcut = sender.tag < 200
let controls = isOpenShortcut ? openShortcutControls : settingsShortcutControls
guard let controls else { return }
let current = isOpenShortcut ? settings.openShortcut : settings.settingsShortcut
let parsed = parseShortcut(controls.keyField.stringValue)
var flags = NSEvent.ModifierFlags(rawValue: parsed?.modifierFlags ?? current.modifierFlags)
setFlag(&flags, .command, controls.command.state == .on || parsed?.has(.command) == true)
setFlag(&flags, .option, controls.option.state == .on || parsed?.has(.option) == true)
setFlag(&flags, .control, controls.control.state == .on || parsed?.has(.control) == true)
setFlag(&flags, .shift, controls.shift.state == .on || parsed?.has(.shift) == true)
let key = parsed?.key ?? String(controls.keyField.stringValue.clipboardTrimmed.prefix(1)).lowercased()
guard !key.isEmpty else {
refreshFromSettings()
return
}
let updated = ShortcutBinding(key: key, modifierFlags: flags.rawValue)
if isOpenShortcut {
settings.openShortcut = updated
} else {
settings.settingsShortcut = updated
}
refreshFromSettings()
}
func controlTextDidEndEditing(_ notification: Notification) {
guard let field = notification.object as? NSTextField, field.tag == 100 || field.tag == 200 else { return }
shortcutChanged(field)
}
@objc private func pauseCaptureChanged() {
settings.pauseCapture = pauseCaptureButton.state == .on
}
@objc private func excludeSensitiveChanged() {
settings.excludeSensitive = excludeSensitiveButton.state == .on
}
@objc private func includeImageTextChanged() {
settings.includeImageTextInSearch = includeImageTextButton.state == .on
}
@objc private func allowedKindChanged(_ sender: NSButton) {
guard let kind = ClipboardItemKind(rawValue: sender.tag) else { return }
var ignored = settings.ignoredItemKindsRaw
if sender.state == .on {
ignored.removeAll { $0 == kind.rawValue }
} else if !ignored.contains(kind.rawValue) {
ignored.append(kind.rawValue)
}
settings.ignoredItemKindsRaw = ignored
}
func textDidChange(_ notification: Notification) {
guard notification.object as? NSTextView === ignoredAppsTextView else { return }
settings.ignoredApps = ignoredAppsTextView.string
.split(whereSeparator: { $0 == "," || $0 == "\n" })
.map { $0.clipboardTrimmed }
.filter { !$0.isEmpty }
}
@objc private func clearHistoryOnQuitChanged() {
settings.clearHistoryOnQuit = clearHistoryOnQuitButton.state == .on
}
@objc private func requestAccessibilityAccess() {
_ = AccessibilityPermissionService.requestPromptIfNeeded()
if !AccessibilityPermissionService.isTrusted {
AccessibilityPermissionService.openSystemSettings()
}
settings.setAccessibilityPermissionStatus(
message: AccessibilityPermissionService.isTrusted ? "" : "Accessibility permission not granted."
)
refreshAccessibilityPermissionStatus()
}
@objc private func refreshAccessibilityPermissionStatus() {
settings.setAccessibilityPermissionStatus(
message: AccessibilityPermissionService.isTrusted
? ""
: "Accessibility permission not granted."
)
refreshFromSettings()
}
@objc private func pollProfileChanged() {
if let rawValue = pollProfilePopup.selectedItem?.representedObject as? Int,
let profile = AppConfiguration.PollProfile(rawValue: rawValue) {
settings.pollProfileRaw = profile
}
}
@objc private func cacheLimitChanged() {
settings.imageCacheMaxBytes = Int64(cacheSlider.doubleValue * 1024 * 1024)
cacheLabel.stringValue = "Current cache cap: \(Int(cacheSlider.doubleValue)) MB"
}
@objc private func openHistoryFolder() {
NSWorkspace.shared.open(ClipboardStore.storageDirectory())
}
@objc private func clearClipboardHistory() {
guard confirmDestructiveAction(
title: "Clear Clipboard History?",
message: "This permanently removes saved clipboard items, app-managed attachments, temporary decrypted previews, and the local fallback encryption key when present. The current system clipboard is not changed.",
buttonTitle: "Clear History"
) else { return }
store.removeAll()
cacheService.clearTemporaryPreviews()
}
@objc private func clearThumbnailCache() {
guard confirmDestructiveAction(
title: "Clear Thumbnail Cache?",
message: "This removes cached image previews and temporary decrypted previews. ClipBored will recreate previews as needed.",
buttonTitle: "Clear Cache"
) else { return }
cacheService.clearCache()
}
private func confirmDestructiveAction(title: String, message: String, buttonTitle: String) -> Bool {
let alert = NSAlert()
alert.messageText = title
alert.informativeText = message
alert.alertStyle = .warning
alert.addButton(withTitle: buttonTitle)
alert.addButton(withTitle: "Cancel")
return alert.runModal() == .alertFirstButtonReturn
}
private func setFlag(_ flags: inout NSEvent.ModifierFlags, _ flag: NSEvent.ModifierFlags, _ enabled: Bool) {
if enabled {
flags.insert(flag)
} else {
flags.remove(flag)
}
}
private func parseShortcut(_ text: String) -> ShortcutBinding? {
let cleaned = text.clipboardTrimmed
if cleaned.isEmpty { return nil }
var flags = NSEvent.ModifierFlags()
if cleaned.contains("") { flags.insert(.command) }
if cleaned.contains("") { flags.insert(.option) }
if cleaned.contains("") { flags.insert(.control) }
if cleaned.contains("") { flags.insert(.shift) }
let plain = cleaned.replacingOccurrences(of: "", with: "")
.replacingOccurrences(of: "", with: "")
.replacingOccurrences(of: "", with: "")
.replacingOccurrences(of: "", with: "")
.clipboardTrimmed
guard let key = plain.first else { return nil }
return ShortcutBinding(key: String(key), modifierFlags: flags.rawValue)
}
}
private struct ShortcutControlSet {
let keyField: NSTextField
let command: NSButton
let option: NSButton
let control: NSButton
let shift: NSButton
}