Atoll/DynamicIsland/managers/ClipboardManager.swift
2025-09-02 19:26:56 +05:30

529 lines
19 KiB
Swift

//
// ClipboardManager.swift
// DynamicIsland
//
// Created by Ebullioscopic on 17/07/25.
//
import AppKit
import SwiftUI
import Combine
import Foundation
import UniformTypeIdentifiers
import Defaults
// Clipboard item data structure
struct ClipboardItem: Identifiable, Codable {
let id = UUID()
let type: ClipboardItemType
let timestamp: Date
let preview: String
var isPinned: Bool = false
// Store different types of data - avoid large binary data in UserDefaults
let stringData: String?
let imageFileName: String? // Store filename instead of data
let fileURLs: [String]?
let rtfData: Data? // RTF is typically small, so we can keep this
init(stringData: String, type: ClipboardItemType) {
self.stringData = stringData
self.imageFileName = nil
self.fileURLs = nil
self.rtfData = nil
self.type = type
self.timestamp = Date()
self.preview = ClipboardItem.generatePreview(stringData: stringData, type: type)
}
init(imageData: Data) {
self.stringData = nil
self.fileURLs = nil
self.rtfData = nil
self.type = .image
self.timestamp = Date()
// Save image data to temporary file instead of storing in UserDefaults
let fileName = "clipboard_image_\(UUID().uuidString).png"
let fileURL = ClipboardManager.clipboardDataDirectory.appendingPathComponent(fileName)
do {
try imageData.write(to: fileURL)
self.imageFileName = fileName
self.preview = "Image (\(ByteCountFormatter.string(fromByteCount: Int64(imageData.count), countStyle: .file)))"
} catch {
print("Failed to save image data: \(error)")
self.imageFileName = nil
self.preview = "Image (failed to save)"
}
}
init(fileURLs: [String]) {
self.stringData = nil
self.imageFileName = nil
self.fileURLs = fileURLs
self.rtfData = nil
self.type = .file
self.timestamp = Date()
if fileURLs.count == 1, let url = URL(string: fileURLs.first!) {
self.preview = url.lastPathComponent
} else {
self.preview = "\(fileURLs.count) files"
}
}
init(rtfData: Data, plainText: String) {
// RTF data is typically small, so we can keep it in UserDefaults
self.stringData = plainText
self.imageFileName = nil
self.fileURLs = nil
self.rtfData = rtfData.count > 100000 ? nil : rtfData // Skip very large RTF files
self.type = .rtf
self.timestamp = Date()
self.preview = String(plainText.prefix(50))
}
// Helper to get image data from file
func getImageData() -> Data? {
guard let fileName = imageFileName else { return nil }
let fileURL = ClipboardManager.clipboardDataDirectory.appendingPathComponent(fileName)
return try? Data(contentsOf: fileURL)
}
// Helper to check if this item has the same content as another
func isSameContent(as other: ClipboardItem) -> Bool {
return stringData == other.stringData &&
imageFileName == other.imageFileName &&
fileURLs == other.fileURLs &&
type == other.type
}
static func generatePreview(stringData: String, type: ClipboardItemType) -> String {
switch type {
case .text:
return String(stringData.prefix(50))
case .url:
if let url = URL(string: stringData) {
return url.lastPathComponent.isEmpty ? url.host ?? stringData : url.lastPathComponent
}
return String(stringData.prefix(50))
case .file:
if let url = URL(string: stringData) {
return url.lastPathComponent
}
return "File"
case .image:
return "Image"
case .rtf:
return String(stringData.prefix(50))
case .unknown:
return String(stringData.prefix(50))
}
}
}
enum ClipboardItemType: String, CaseIterable, Codable {
case text = "text"
case url = "url"
case file = "file"
case image = "image"
case rtf = "rtf"
case unknown = "unknown"
var icon: String {
switch self {
case .text: return "doc.text"
case .url: return "link"
case .file: return "doc"
case .image: return "photo"
case .rtf: return "doc.richtext"
case .unknown: return "questionmark.circle"
}
}
var displayName: String {
switch self {
case .text: return "Text"
case .url: return "URL"
case .file: return "File"
case .image: return "Image"
case .rtf: return "Rich Text"
case .unknown: return "Unknown"
}
}
}
class ClipboardManager: ObservableObject {
static let shared = ClipboardManager()
@Published var clipboardHistory: [ClipboardItem] = []
@Published var pinnedItems: [ClipboardItem] = []
@Published var isMonitoring: Bool = false
private var timer: Timer?
private var lastChangeCount: Int = 0
// Use configurable history size from settings
private var maxHistoryItems: Int {
return Defaults[.clipboardHistorySize]
}
// Computed properties for filtered lists
var regularHistory: [ClipboardItem] {
clipboardHistory.filter { !$0.isPinned }
}
var pinnedHistory: [ClipboardItem] {
pinnedItems
}
// Directory for storing clipboard data files
static let clipboardDataDirectory: URL = {
let documentsPath = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
let clipboardDir = documentsPath.appendingPathComponent("ClipboardData")
// Create directory if it doesn't exist
try? FileManager.default.createDirectory(at: clipboardDir, withIntermediateDirectories: true)
return clipboardDir
}()
private init() {
lastChangeCount = NSPasteboard.general.changeCount
loadHistoryFromDefaults()
cleanupOldFiles()
}
deinit {
stopMonitoring()
}
// MARK: - Public Methods
func startMonitoring() {
guard !isMonitoring else { return }
isMonitoring = true
timer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] _ in
self?.checkClipboard()
}
}
func stopMonitoring() {
isMonitoring = false
timer?.invalidate()
timer = nil
}
func copyToClipboard(_ item: ClipboardItem) {
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
switch item.type {
case .text, .url:
if let stringData = item.stringData {
pasteboard.setString(stringData, forType: .string)
}
case .image:
if let imageData = item.getImageData() {
pasteboard.setData(imageData, forType: .png)
}
case .file:
if let fileURLs = item.fileURLs {
let urls = fileURLs.compactMap { URL(string: $0) }
pasteboard.writeObjects(urls as [NSPasteboardWriting])
}
case .rtf:
if let rtfData = item.rtfData {
pasteboard.setData(rtfData, forType: .rtf)
}
// Also set plain text as fallback
if let stringData = item.stringData {
pasteboard.setString(stringData, forType: .string)
}
case .unknown:
if let stringData = item.stringData {
pasteboard.setString(stringData, forType: .string)
}
}
}
func deleteItem(_ item: ClipboardItem) {
// Clean up associated files
if let fileName = item.imageFileName {
let fileURL = ClipboardManager.clipboardDataDirectory.appendingPathComponent(fileName)
try? FileManager.default.removeItem(at: fileURL)
}
clipboardHistory.removeAll { $0.id == item.id }
saveHistoryToDefaults()
}
func clearHistory() {
// Clean up all associated files
for item in clipboardHistory {
if let fileName = item.imageFileName {
let fileURL = ClipboardManager.clipboardDataDirectory.appendingPathComponent(fileName)
try? FileManager.default.removeItem(at: fileURL)
}
}
clipboardHistory.removeAll()
saveHistoryToDefaults()
}
func pinItem(_ item: ClipboardItem) {
// Update the item to be pinned
var pinnedItem = item
pinnedItem.isPinned = true
// Remove from regular history if it exists there
clipboardHistory.removeAll { $0.id == item.id }
// Add to pinned items if not already there
if !pinnedItems.contains(where: { $0.id == item.id }) {
pinnedItems.append(pinnedItem)
}
saveHistoryToDefaults()
savePinnedItemsToDefaults()
}
func unpinItem(_ item: ClipboardItem) {
// Remove from pinned items
pinnedItems.removeAll { $0.id == item.id }
// Update the item to be unpinned and add back to regular history
var unpinnedItem = item
unpinnedItem.isPinned = false
// Add back to regular history at the top
clipboardHistory.insert(unpinnedItem, at: 0)
// Maintain history size limit
if clipboardHistory.count > maxHistoryItems {
let itemsToDelete = Array(clipboardHistory.dropFirst(maxHistoryItems))
for oldItem in itemsToDelete {
if let fileName = oldItem.imageFileName {
let fileURL = ClipboardManager.clipboardDataDirectory.appendingPathComponent(fileName)
try? FileManager.default.removeItem(at: fileURL)
}
}
clipboardHistory = Array(clipboardHistory.prefix(maxHistoryItems))
}
saveHistoryToDefaults()
savePinnedItemsToDefaults()
}
func togglePin(for item: ClipboardItem) {
if item.isPinned || pinnedItems.contains(where: { $0.id == item.id }) {
unpinItem(item)
} else {
pinItem(item)
}
}
// MARK: - Private Methods
private func checkClipboard() {
let currentChangeCount = NSPasteboard.general.changeCount
guard currentChangeCount != lastChangeCount else { return }
lastChangeCount = currentChangeCount
guard let clipboardItem = getCurrentClipboardItem() else { return }
// Don't add duplicate items
if !clipboardHistory.contains(where: { isSameContent($0, clipboardItem) }) {
addToHistory(clipboardItem)
}
}
private func getCurrentClipboardItem() -> ClipboardItem? {
let pasteboard = NSPasteboard.general
// Step 1: Check what types are available
let hasFileURLs = pasteboard.canReadObject(forClasses: [NSURL.self], options: nil)
let hasImageData = pasteboard.data(forType: .png) != nil ||
pasteboard.data(forType: .tiff) != nil ||
pasteboard.data(forType: NSPasteboard.PasteboardType("public.jpeg")) != nil
let hasString = pasteboard.string(forType: .string) != nil
// Step 2: Smart detection based on context
// Priority 1: If there are file URLs AND the files are actual image files, treat as image files (from Finder)
if hasFileURLs {
if let fileURLs = pasteboard.readObjects(forClasses: [NSURL.self], options: nil) as? [URL] {
let realFileURLs = fileURLs.filter { url in
return url.isFileURL &&
!url.path.contains("/.file/id=") &&
!url.path.contains("/tmp/") &&
!url.path.hasPrefix("/private/var/") &&
!url.path.contains("/ClipboardViewer") &&
FileManager.default.fileExists(atPath: url.path)
}
if !realFileURLs.isEmpty {
// Check if these are image files - if so, try to load the actual image data
let imageExtensions = ["jpg", "jpeg", "png", "gif", "bmp", "tiff", "tif", "webp", "heic", "heif"]
let imageFiles = realFileURLs.filter { url in
imageExtensions.contains(url.pathExtension.lowercased())
}
// If we have image files from Finder, load the actual image data
if !imageFiles.isEmpty, let firstImageURL = imageFiles.first {
if let imageData = try? Data(contentsOf: firstImageURL) {
return ClipboardItem(imageData: imageData)
}
}
// Otherwise, treat as file(s)
let urlStrings = realFileURLs.map { $0.absoluteString }
return ClipboardItem(fileURLs: urlStrings)
}
}
}
// Priority 2: If there's ONLY image data without file URLs (screenshots, direct image paste)
if hasImageData && !hasFileURLs {
if let imageData = pasteboard.data(forType: .png) {
return ClipboardItem(imageData: imageData)
} else if let imageData = pasteboard.data(forType: .tiff) {
return ClipboardItem(imageData: imageData)
} else if let imageData = pasteboard.data(forType: NSPasteboard.PasteboardType("public.jpeg")) {
return ClipboardItem(imageData: imageData)
}
}
// Priority 3: Plain text (including copied text)
if let string = pasteboard.string(forType: .string), !string.isEmpty {
// Determine if it's a URL
if string.hasPrefix("http://") || string.hasPrefix("https://") {
return ClipboardItem(stringData: string, type: .url)
}
return ClipboardItem(stringData: string, type: .text)
}
// Priority 4: RTF
if let rtfData = pasteboard.data(forType: .rtf),
let rtfString = NSAttributedString(rtf: rtfData, documentAttributes: nil)?.string, !rtfString.isEmpty {
return ClipboardItem(rtfData: rtfData, plainText: rtfString)
}
// Priority 5: If we have image data WITH file URLs (document thumbnails),
// this is likely a document with a preview - ignore the thumbnail and treat as unknown
if hasImageData && hasFileURLs {
// This is likely a document with a thumbnail preview - we don't want the thumbnail
return nil
}
// Priority 6: URL strings
if let url = pasteboard.string(forType: .URL) {
return ClipboardItem(stringData: url, type: .url)
}
return nil
}
private func addToHistory(_ item: ClipboardItem) {
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
// Remove any existing items with the same data
let itemsToRemove = self.clipboardHistory.filter { existingItem in
return self.isSameContent(existingItem, item)
}
// Clean up files for items being removed
for oldItem in itemsToRemove {
if let fileName = oldItem.imageFileName {
let fileURL = ClipboardManager.clipboardDataDirectory.appendingPathComponent(fileName)
try? FileManager.default.removeItem(at: fileURL)
}
}
self.clipboardHistory.removeAll { existingItem in
return self.isSameContent(existingItem, item)
}
// Add to beginning of array
self.clipboardHistory.insert(item, at: 0)
// Keep only the most recent items and clean up old files
let itemsToDelete = Array(self.clipboardHistory.dropFirst(self.maxHistoryItems))
for oldItem in itemsToDelete {
if let fileName = oldItem.imageFileName {
let fileURL = ClipboardManager.clipboardDataDirectory.appendingPathComponent(fileName)
try? FileManager.default.removeItem(at: fileURL)
}
}
if self.clipboardHistory.count > self.maxHistoryItems {
self.clipboardHistory = Array(self.clipboardHistory.prefix(self.maxHistoryItems))
}
self.saveHistoryToDefaults()
}
}
// Helper to compare clipboard items for duplicates
private func isSameContent(_ item1: ClipboardItem, _ item2: ClipboardItem) -> Bool {
if item1.type != item2.type { return false }
switch item1.type {
case .text, .url, .unknown:
return item1.stringData == item2.stringData
case .image:
// For images, compare the actual data if both are available
let data1 = item1.getImageData()
let data2 = item2.getImageData()
return data1 == data2
case .file:
return item1.fileURLs == item2.fileURLs
case .rtf:
return item1.stringData == item2.stringData && item1.rtfData == item2.rtfData
}
}
// Clean up old image files that are no longer referenced
private func cleanupOldFiles() {
guard let files = try? FileManager.default.contentsOfDirectory(at: ClipboardManager.clipboardDataDirectory, includingPropertiesForKeys: nil) else { return }
let referencedFiles = Set(clipboardHistory.compactMap { $0.imageFileName })
for file in files {
let fileName = file.lastPathComponent
if !referencedFiles.contains(fileName) {
try? FileManager.default.removeItem(at: file)
}
}
}
// MARK: - Persistence
private func saveHistoryToDefaults() {
if let encoded = try? JSONEncoder().encode(clipboardHistory) {
UserDefaults.standard.set(encoded, forKey: "ClipboardHistory")
}
}
func savePinnedItemsToDefaults() {
if let encoded = try? JSONEncoder().encode(pinnedItems) {
UserDefaults.standard.set(encoded, forKey: "ClipboardPinnedItems")
}
}
private func loadHistoryFromDefaults() {
if let data = UserDefaults.standard.data(forKey: "ClipboardHistory"),
let history = try? JSONDecoder().decode([ClipboardItem].self, from: data) {
clipboardHistory = history
}
if let data = UserDefaults.standard.data(forKey: "ClipboardPinnedItems"),
let pinned = try? JSONDecoder().decode([ClipboardItem].self, from: data) {
pinnedItems = pinned
}
}
}