WIP
This commit is contained in:
512
sources/clipbored/app/AppDelegate.swift
Normal file
512
sources/clipbored/app/AppDelegate.swift
Normal 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
|
||||
}
|
||||
|
||||
}
|
||||
14
sources/clipbored/app/ClipBoredApp.swift
Normal file
14
sources/clipbored/app/ClipBoredApp.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
117
sources/clipbored/config/AppConfiguration.swift
Normal file
117
sources/clipbored/config/AppConfiguration.swift
Normal 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
|
||||
}
|
||||
}
|
||||
34
sources/clipbored/extensions/NSImage+Helpers.swift
Normal file
34
sources/clipbored/extensions/NSImage+Helpers.swift
Normal 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
|
||||
}
|
||||
}
|
||||
19
sources/clipbored/extensions/String+Whitespace.swift
Normal file
19
sources/clipbored/extensions/String+Whitespace.swift
Normal 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])
|
||||
}
|
||||
}
|
||||
181
sources/clipbored/models/ClipboardItem.swift
Normal file
181
sources/clipbored/models/ClipboardItem.swift
Normal 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
|
||||
}
|
||||
}
|
||||
26
sources/clipbored/models/FilePayload.swift
Normal file
26
sources/clipbored/models/FilePayload.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
214
sources/clipbored/models/SettingsModel.swift
Normal file
214
sources/clipbored/models/SettingsModel.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
BIN
sources/clipbored/resources/AppIcon.icns
Normal file
BIN
sources/clipbored/resources/AppIcon.icns
Normal file
Binary file not shown.
BIN
sources/clipbored/resources/AppIcon.png
Normal file
BIN
sources/clipbored/resources/AppIcon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.6 KiB |
28
sources/clipbored/resources/Info.plist
Normal file
28
sources/clipbored/resources/Info.plist
Normal 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>
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
40
sources/clipbored/services/AppLifecycleService.swift
Normal file
40
sources/clipbored/services/AppLifecycleService.swift
Normal 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
|
||||
}
|
||||
}
|
||||
342
sources/clipbored/services/ClipboardCacheService.swift
Normal file
342
sources/clipbored/services/ClipboardCacheService.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
284
sources/clipbored/services/ClipboardEncryptionService.swift
Normal file
284
sources/clipbored/services/ClipboardEncryptionService.swift
Normal 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")
|
||||
}
|
||||
}
|
||||
761
sources/clipbored/services/ClipboardMonitorService.swift
Normal file
761
sources/clipbored/services/ClipboardMonitorService.swift
Normal 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"
|
||||
}
|
||||
}
|
||||
25
sources/clipbored/services/ClipboardSelfWriteTracker.swift
Normal file
25
sources/clipbored/services/ClipboardSelfWriteTracker.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
841
sources/clipbored/services/ClipboardStore.swift
Normal file
841
sources/clipbored/services/ClipboardStore.swift
Normal 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))
|
||||
}
|
||||
}
|
||||
61
sources/clipbored/services/DiagnosticsService.swift
Normal file
61
sources/clipbored/services/DiagnosticsService.swift
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
40
sources/clipbored/services/ImageTextExtractor.swift
Normal file
40
sources/clipbored/services/ImageTextExtractor.swift
Normal 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
|
||||
}
|
||||
}
|
||||
196
sources/clipbored/services/PasteActionService.swift
Normal file
196
sources/clipbored/services/PasteActionService.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
385
sources/clipbored/services/SensitiveContentDetector.swift
Normal file
385
sources/clipbored/services/SensitiveContentDetector.swift
Normal 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
|
||||
}
|
||||
}
|
||||
266
sources/clipbored/services/ShortcutManager.swift
Normal file
266
sources/clipbored/services/ShortcutManager.swift
Normal 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)"
|
||||
}
|
||||
}
|
||||
457
sources/clipbored/views/ClipboardPanelController.swift
Normal file
457
sources/clipbored/views/ClipboardPanelController.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
2507
sources/clipbored/views/ClipboardPanelView.swift
Normal file
2507
sources/clipbored/views/ClipboardPanelView.swift
Normal file
File diff suppressed because it is too large
Load Diff
406
sources/clipbored/views/ClipboardPanelViewModel.swift
Normal file
406
sources/clipbored/views/ClipboardPanelViewModel.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
642
sources/clipbored/views/SettingsWindowController.swift
Normal file
642
sources/clipbored/views/SettingsWindowController.swift
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user