import AppKit import Defaults import Sauce class Clipboard { static let shared = Clipboard() typealias OnNewCopyHook = (HistoryItem) -> Void private var onNewCopyHooks: [OnNewCopyHook] = [] var changeCount: Int private let pasteboard = NSPasteboard.general private var timer: Timer? private let dynamicTypePrefix = "dyn." private let microsoftSourcePrefix = "com.microsoft.ole.source." private let supportedTypes: Set = [ .fileURL, .html, .png, .rtf, .string, .tiff ] private let ignoredTypes: Set = [ .autoGenerated, .concealed, .transient ] private let unpersistableTypes: Set = [ .appleWebArchive, .safariWebArchve ] private var enabledTypes: Set { Defaults[.enabledPasteboardTypes] } private var disabledTypes: Set { supportedTypes.subtracting(enabledTypes) } private var sourceApp: NSRunningApplication? { NSWorkspace.shared.frontmostApplication } init() { changeCount = pasteboard.changeCount } func onNewCopy(_ hook: @escaping OnNewCopyHook) { onNewCopyHooks.append(hook) } func clearHooks() { onNewCopyHooks = [] } func start() { timer = Timer.scheduledTimer( timeInterval: Defaults[.clipboardCheckInterval], target: self, selector: #selector(checkForChangesInPasteboard), userInfo: nil, repeats: true ) } func restart() { timer?.invalidate() start() } func isPersistable(_ type: NSPasteboard.PasteboardType) -> Bool { return !unpersistableTypes.contains(type) && !type.rawValue.hasPrefix(microsoftSourcePrefix) && !type.rawValue.hasPrefix(dynamicTypePrefix) } @MainActor func copyInMaccy(_ string: String) { pasteboard.clearContents() pasteboard.setString(string, forType: .string) pasteboard.setString(NSPasteboard.PasteboardType.fromMaccy.rawValue, forType: .source) sync() checkForChangesInPasteboard() } @MainActor func copy(_ item: HistoryItem?, removeFormatting: Bool = false) { guard let item else { return } pasteboard.clearContents() var contents = item.contents if removeFormatting { contents = clearFormatting(contents) } for content in contents { guard content.type != NSPasteboard.PasteboardType.fileURL.rawValue else { continue } pasteboard.setData(content.value, forType: NSPasteboard.PasteboardType(content.type)) } // Use writeObjects for file URLs so that multiple files that are copied actually work. // Only do this for file URLs because it causes an issue with some other data types (like formatted text) // where the item is pasted more than once. let fileURLItems: [NSPasteboardItem] = contents.compactMap { item in guard item.type == NSPasteboard.PasteboardType.fileURL.rawValue else { return nil } guard let value = item.value else { return nil } let pasteItem = NSPasteboardItem() pasteItem.setData(value, forType: NSPasteboard.PasteboardType(item.type)) return pasteItem } pasteboard.writeObjects(fileURLItems) pasteboard.setString("", forType: .fromMaccy) pasteboard.setString(item.application ?? "", forType: .source) sync() Task { Notifier.notify(body: item.title, sound: .knock) checkForChangesInPasteboard() } } // Based on https://github.com/Clipy/Clipy/blob/develop/Clipy/Sources/Services/PasteService.swift. func paste() { Accessibility.check() // Add flag that left/right modifier key has been pressed. // See https://github.com/TermiT/Flycut/pull/18 for details. let cmdFlag = CGEventFlags(rawValue: UInt64(KeyChord.pasteKeyModifiers.rawValue) | 0x000008) var vCode = Sauce.shared.keyCode(for: KeyChord.pasteKey) // Force QWERTY keycode when keyboard layout switches to // QWERTY upon pressing ⌘ key (e.g. "Dvorak - QWERTY ⌘"). // See https://github.com/p0deje/Maccy/issues/482 for details. if KeyboardLayout.current.commandSwitchesToQWERTY && cmdFlag.contains(.maskCommand) { vCode = KeyChord.pasteKey.QWERTYKeyCode } let source = CGEventSource(stateID: .combinedSessionState) // Disable local keyboard events while pasting source?.setLocalEventsFilterDuringSuppressionState([.permitLocalMouseEvents, .permitSystemDefinedEvents], state: .eventSuppressionStateSuppressionInterval) let keyVDown = CGEvent(keyboardEventSource: source, virtualKey: vCode, keyDown: true) let keyVUp = CGEvent(keyboardEventSource: source, virtualKey: vCode, keyDown: false) keyVDown?.flags = cmdFlag keyVUp?.flags = cmdFlag keyVDown?.post(tap: .cgSessionEventTap) keyVUp?.post(tap: .cgSessionEventTap) } func clear() { guard Defaults[.clearSystemClipboard] else { return } pasteboard.clearContents() } @objc @MainActor func checkForChangesInPasteboard() { // swiftlint:disable:this cyclomatic_complexity guard pasteboard.changeCount != changeCount else { return } changeCount = pasteboard.changeCount if pasteboard.pasteboardItems?.contains(where: { $0.types.contains(.fromMaccy) }) != true { // External copy occurred. Stop the current paste stack. // Maybe queue it into the paste stack? Configurable behaviour? AppState.shared.history.interruptPasteStack() } if Defaults[.ignoreEvents] { if Defaults[.ignoreOnlyNextEvent] { Defaults[.ignoreEvents] = false Defaults[.ignoreOnlyNextEvent] = false } return } // Reading types on NSPasteboard gives all the available // types - even the ones that are not present on the NSPasteboardItem. // See https://github.com/p0deje/Maccy/issues/241. if shouldIgnore(Set(pasteboard.types ?? [])) { return } if let sourceAppBundle = sourceApp?.bundleIdentifier, shouldIgnore(sourceAppBundle) { return } // Some applications (BBEdit, Edge) add 2 items to pasteboard when copying // so it's better to merge all data into a single record. // - https://github.com/p0deje/Maccy/issues/78 // - https://github.com/p0deje/Maccy/issues/472 var contents = [HistoryItemContent]() pasteboard.pasteboardItems?.forEach({ item in var types = Set(item.types) if types.contains(.string) && isEmptyString(item) && !richText(item) { return } if shouldIgnore(item) { return } types = types .subtracting(disabledTypes) .filter(isPersistable) // Avoid reading Microsoft Word links from bookmarks and cross-references. // https://github.com/p0deje/Maccy/issues/613 // https://github.com/p0deje/Maccy/issues/770 if types.isSuperset(of: [.microsoftLinkSource, .microsoftObjectLink]) { types = types.subtracting([.microsoftLinkSource, .microsoftObjectLink, .pdf]) } types.forEach { type in contents.append(HistoryItemContent(type: type.rawValue, value: item.data(forType: type))) } }) guard !contents.isEmpty else { return } let historyItem = HistoryItem(contents: contents) if #unavailable(macOS 15.0) { // On macOS 14 the history item needs to be inserted into storage directly after creating it. try? History.shared.insertIntoStorage(historyItem) } historyItem.application = sourceApp?.bundleIdentifier historyItem.title = historyItem.generateTitle() onNewCopyHooks.forEach({ $0(historyItem) }) } private func shouldIgnore(_ types: Set) -> Bool { let ignoredTypes = self.ignoredTypes .union(Defaults[.ignoredPasteboardTypes].map({ NSPasteboard.PasteboardType($0) })) return types.isDisjoint(with: enabledTypes) || !types.isDisjoint(with: ignoredTypes) } private func shouldIgnore(_ sourceAppBundle: String) -> Bool { if Defaults[.ignoreAllAppsExceptListed] { return !Defaults[.ignoredApps].contains(sourceAppBundle) } else { return Defaults[.ignoredApps].contains(sourceAppBundle) } } private func shouldIgnore(_ item: NSPasteboardItem) -> Bool { for regexp in Defaults[.ignoreRegexp] { if let string = item.string(forType: .string) { do { let regex = try NSRegularExpression(pattern: regexp) if regex.numberOfMatches(in: string, range: NSRange(string.startIndex..., in: string)) > 0 { return true } } catch { return false } } } return false } private func isEmptyString(_ item: NSPasteboardItem) -> Bool { guard let string = item.string(forType: .string) else { return true } return string.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } private func richText(_ item: NSPasteboardItem) -> Bool { if let rtf = item.data(forType: .rtf) { if let attributedString = NSAttributedString(rtf: rtf, documentAttributes: nil) { return !attributedString.string.isEmpty } } if let html = item.data(forType: .html) { if let attributedString = NSAttributedString(html: html, documentAttributes: nil) { return !attributedString.string.isEmpty } } return false } // Some applications requires window be unfocused and focused back to sync the clipboard. // - Chrome Remote Desktop (https://github.com/p0deje/Maccy/issues/948) // - Netbeans (https://github.com/p0deje/Maccy/issues/879) private func sync() { guard let app = sourceApp, app.bundleURL?.lastPathComponent == "Chrome Remote Desktop.app" || app.localizedName?.contains("NetBeans") == true else { return } NSApp.activate(ignoringOtherApps: true) NSApp.hide(self) } private func clearFormatting(_ contents: [HistoryItemContent]) -> [HistoryItemContent] { var newContents: [HistoryItemContent] = contents let stringContents = contents.filter { NSPasteboard.PasteboardType($0.type) == .string } // If there is no string representation of data, // behave like we didn't have to remove formatting. if !stringContents.isEmpty { newContents = stringContents // Preserve file URLs. // https://github.com/p0deje/Maccy/issues/962 let fileURLContents = contents.filter { NSPasteboard.PasteboardType($0.type) == .fileURL } if !fileURLContents.isEmpty { newContents += fileURLContents } } return newContents } }