Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 23 additions & 4 deletions ClipboardManager/App/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,22 +10,41 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
private var launchAtLogin: LaunchAtLoginManaging?

func applicationDidFinishLaunching(_ notification: Notification) {
let store: ClipboardStoring = UserDefaultsStore()
let imageFileStore = ImageFileStore()
let store: ClipboardStoring = UserDefaultsStore(imageFileStore: imageFileStore)
let monitor: ClipboardMonitoring = ClipboardMonitor()
let hotkey: HotkeyRegistering = HotkeyManager()
let launchAtLogin: LaunchAtLoginManaging = LaunchAtLoginService()

let viewModel = PopoverViewModel(
fetchUseCase: FetchHistoryUseCase(store: store),
copyUseCase: CopyItemUseCase(),
copyUseCase: CopyItemUseCase(imageFileStore: imageFileStore),
deleteUseCase: DeleteItemUseCase(store: store),
clearUseCase: ClearHistoryUseCase(store: store)
)

let statusBar = StatusBarController(viewModel: viewModel)

monitor.onNewItem = { [weak viewModel] raw in
let item = ClipboardItem.from(pasteboardString: raw)
monitor.onNewItem = { [weak viewModel] capture in
let item: ClipboardItem
switch capture {
case .text(let raw):
item = ClipboardItem.from(pasteboardString: raw)
case .image(let data, let pasteboardType):
guard let stored = imageFileStore.store(data: data, pasteboardType: pasteboardType) else {
return
}
item = ClipboardItem(
content: "Image",
type: .image,
imagePasteboardType: pasteboardType
).storedImage(
imageFileName: stored.imageFileName,
imageDigest: stored.digest,
thumbnailFileName: stored.thumbnailFileName,
thumbnailFileURL: imageFileStore.thumbnailURL(fileName: stored.thumbnailFileName)
)
}
store.save(item)
Task { @MainActor in viewModel?.refresh() }
}
Expand Down
6 changes: 6 additions & 0 deletions ClipboardManager/Core/Models/ClipboardCapture.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import Foundation

enum ClipboardCapture: Equatable {
case text(String)
case image(data: Data, pasteboardType: String)
}
99 changes: 98 additions & 1 deletion ClipboardManager/Core/Models/ClipboardItem.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,114 @@ struct ClipboardItem: Identifiable, Codable, Equatable {
let content: String
let type: ClipboardItemType
let createdAt: Date
let imagePasteboardType: String?
let imageFileName: String?
let imageDigest: String?
let thumbnailFileName: String?
// Resolved by the store for presentation and never JSON-encoded.
let thumbnailFileURL: URL?
// Decoded only from the short-lived legacy format for one-time migration.
let legacyImageData: Data?

init(
id: UUID = UUID(),
content: String,
type: ClipboardItemType,
createdAt: Date = Date()
createdAt: Date = Date(),
imagePasteboardType: String? = nil,
imageFileName: String? = nil,
imageDigest: String? = nil,
thumbnailFileName: String? = nil,
thumbnailFileURL: URL? = nil,
legacyImageData: Data? = nil
) {
self.id = id
self.content = content
self.type = type
self.createdAt = createdAt
self.imagePasteboardType = imagePasteboardType
self.imageFileName = imageFileName
self.imageDigest = imageDigest
self.thumbnailFileName = thumbnailFileName
self.thumbnailFileURL = thumbnailFileURL
self.legacyImageData = legacyImageData
}

func hasSamePayload(as other: ClipboardItem) -> Bool {
switch (type, other.type) {
case (.image, .image):
if let imageDigest, let otherDigest = other.imageDigest {
return imageDigest == otherDigest
}
return legacyImageData == other.legacyImageData
default:
return content == other.content
}
}

func storedImage(
imageFileName: String,
imageDigest: String,
thumbnailFileName: String?,
thumbnailFileURL: URL?
) -> ClipboardItem {
ClipboardItem(
id: id,
content: content,
type: type,
createdAt: createdAt,
imagePasteboardType: imagePasteboardType,
imageFileName: imageFileName,
imageDigest: imageDigest,
thumbnailFileName: thumbnailFileName,
thumbnailFileURL: thumbnailFileURL
)
}

func resolvingThumbnail(at url: URL?) -> ClipboardItem {
ClipboardItem(
id: id,
content: content,
type: type,
createdAt: createdAt,
imagePasteboardType: imagePasteboardType,
imageFileName: imageFileName,
imageDigest: imageDigest,
thumbnailFileName: thumbnailFileName,
thumbnailFileURL: url
)
}

private enum CodingKeys: String, CodingKey {
case id, content, type, createdAt
case imageData // Legacy field: decoded for one-time migration only.
case imagePasteboardType, imageFileName, imageDigest, thumbnailFileName
}

init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decode(UUID.self, forKey: .id)
content = try container.decode(String.self, forKey: .content)
type = try container.decode(ClipboardItemType.self, forKey: .type)
createdAt = try container.decode(Date.self, forKey: .createdAt)
legacyImageData = try container.decodeIfPresent(Data.self, forKey: .imageData)
imagePasteboardType = try container.decodeIfPresent(String.self, forKey: .imagePasteboardType)
imageFileName = try container.decodeIfPresent(String.self, forKey: .imageFileName)
imageDigest = try container.decodeIfPresent(String.self, forKey: .imageDigest)
thumbnailFileName = try container.decodeIfPresent(String.self, forKey: .thumbnailFileName)
thumbnailFileURL = nil
}

func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(id, forKey: .id)
try container.encode(content, forKey: .content)
try container.encode(type, forKey: .type)
try container.encode(createdAt, forKey: .createdAt)
try container.encodeIfPresent(imagePasteboardType, forKey: .imagePasteboardType)
try container.encodeIfPresent(imageFileName, forKey: .imageFileName)
try container.encodeIfPresent(imageDigest, forKey: .imageDigest)
try container.encodeIfPresent(thumbnailFileName, forKey: .thumbnailFileName)
}

static func from(pasteboardString: String, now: Date = Date()) -> ClipboardItem {
Expand Down
1 change: 1 addition & 0 deletions ClipboardManager/Core/Models/ClipboardItemType.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,6 @@ import Foundation
enum ClipboardItemType: String, Codable, Equatable {
case text
case url
case image
case unknown
}
2 changes: 1 addition & 1 deletion ClipboardManager/Core/Protocols/ClipboardMonitoring.swift
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import Foundation

protocol ClipboardMonitoring: AnyObject {
var onNewItem: ((String) -> Void)? { get set }
var onNewItem: ((ClipboardCapture) -> Void)? { get set }
func startMonitoring()
func stopMonitoring()
}
29 changes: 27 additions & 2 deletions ClipboardManager/Core/Services/ClipboardMonitor.swift
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import AppKit
import Foundation
import UniformTypeIdentifiers

final class ClipboardMonitor: ClipboardMonitoring {
var onNewItem: ((String) -> Void)?
var onNewItem: ((ClipboardCapture) -> Void)?

private let pollInterval: TimeInterval
private let pasteboard: NSPasteboard
Expand Down Expand Up @@ -36,9 +37,33 @@ final class ClipboardMonitor: ClipboardMonitoring {
let current = pasteboard.changeCount
guard current != lastChangeCount else { return }
lastChangeCount = current

if let imageCapture = makeImageCapture() {
onNewItem?(imageCapture)
return
}

guard let string = pasteboard.string(forType: .string) else { return }
let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
onNewItem?(string)
onNewItem?(.text(string))
}

private func makeImageCapture() -> ClipboardCapture? {
let preferredTypes: [NSPasteboard.PasteboardType] = [.png, .tiff]
let otherImageTypes = (pasteboard.types ?? []).filter { type in
!preferredTypes.contains(type) && UTType(type.rawValue)?.conforms(to: .image) == true
}

for type in preferredTypes + otherImageTypes {
if let data = pasteboard.data(forType: type), !data.isEmpty {
guard data.count <= ImageFileStore.maxItemBytes else {
AppLogger.monitor.warning("Skipped image larger than the 10 MB item limit")
return nil
}
return .image(data: data, pasteboardType: type.rawValue)
}
}
return nil
}
}
15 changes: 13 additions & 2 deletions ClipboardManager/Core/UseCases/CopyItemUseCase.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,24 @@ import Foundation

final class CopyItemUseCase {
private let pasteboard: NSPasteboard
private let imageFileStore: ImageFileStore

init(pasteboard: NSPasteboard = .general) {
init(
pasteboard: NSPasteboard = .general,
imageFileStore: ImageFileStore = ImageFileStore()
) {
self.pasteboard = pasteboard
self.imageFileStore = imageFileStore
}

func execute(item: ClipboardItem) {
pasteboard.clearContents()
pasteboard.setString(item.content, forType: .string)
if item.type == .image,
let data = item.imageFileName.flatMap(imageFileStore.load),
let rawType = item.imagePasteboardType {
pasteboard.setData(data, forType: NSPasteboard.PasteboardType(rawType))
} else {
pasteboard.setString(item.content, forType: .string)
}
}
}
126 changes: 126 additions & 0 deletions ClipboardManager/Infrastructure/Persistence/ImageFileStore.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import CryptoKit
import Foundation
import ImageIO
import UniformTypeIdentifiers

final class ImageFileStore {
struct StoredImage {
let digest: String
let imageFileName: String
let thumbnailFileName: String?
}

static let maxItemBytes = 10 * 1_024 * 1_024
static let maxTotalBytes = 100 * 1_024 * 1_024

private let fileManager: FileManager
private let directoryURL: URL

init(
fileManager: FileManager = .default,
directoryURL: URL? = nil
) {
self.fileManager = fileManager
self.directoryURL = directoryURL ?? Self.defaultDirectoryURL(fileManager: fileManager)
}

func store(data: Data, pasteboardType: String) -> StoredImage? {
guard !data.isEmpty, data.count <= Self.maxItemBytes else { return nil }
guard ensureDirectoryExists() else { return nil }

let digest = digest(for: data)
let fileExtension = UTType(pasteboardType)?.preferredFilenameExtension ?? "data"
let imageFileName = "\(digest).\(fileExtension)"
let imageURL = directoryURL.appendingPathComponent(imageFileName)

do {
if !fileManager.fileExists(atPath: imageURL.path) {
try data.write(to: imageURL, options: .atomic)
}

let thumbnailFileName = storeThumbnail(from: data, digest: digest)
return StoredImage(
digest: digest,
imageFileName: imageFileName,
thumbnailFileName: thumbnailFileName
)
} catch {
AppLogger.store.error("Failed to store clipboard image: \(error.localizedDescription, privacy: .public)")
return nil
}
}

func load(fileName: String) -> Data? {
try? Data(contentsOf: fileURL(for: fileName), options: .mappedIfSafe)
}

func fileSize(fileName: String) -> Int {
let attributes = try? fileManager.attributesOfItem(atPath: fileURL(for: fileName).path)
return (attributes?[.size] as? NSNumber)?.intValue ?? 0
}

func thumbnailURL(fileName: String?) -> URL? {
guard let fileName else { return nil }
let url = fileURL(for: fileName)
return fileManager.fileExists(atPath: url.path) ? url : nil
}

func delete(imageFileName: String?, thumbnailFileName: String?) {
for fileName in [imageFileName, thumbnailFileName].compactMap({ $0 }) {
try? fileManager.removeItem(at: fileURL(for: fileName))
}
}

func clear() {
try? fileManager.removeItem(at: directoryURL)
}

private func digest(for data: Data) -> String {
SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined()
}

private func storeThumbnail(from data: Data, digest: String) -> String? {
guard let source = CGImageSourceCreateWithData(data as CFData, nil) else { return nil }
let options: [CFString: Any] = [
kCGImageSourceCreateThumbnailFromImageAlways: true,
kCGImageSourceCreateThumbnailWithTransform: true,
kCGImageSourceThumbnailMaxPixelSize: 96
]
guard let thumbnail = CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary) else {
return nil
}

let fileName = "\(digest)-thumbnail.png"
let url = fileURL(for: fileName)
guard let destination = CGImageDestinationCreateWithURL(
url as CFURL,
UTType.png.identifier as CFString,
1,
nil
) else { return nil }

CGImageDestinationAddImage(destination, thumbnail, nil)
return CGImageDestinationFinalize(destination) ? fileName : nil
}

private func ensureDirectoryExists() -> Bool {
do {
try fileManager.createDirectory(at: directoryURL, withIntermediateDirectories: true)
return true
} catch {
AppLogger.store.error("Failed to create image directory: \(error.localizedDescription, privacy: .public)")
return false
}
}

private func fileURL(for fileName: String) -> URL {
directoryURL.appendingPathComponent(fileName)
}

private static func defaultDirectoryURL(fileManager: FileManager) -> URL {
let applicationSupport = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
return applicationSupport
.appendingPathComponent("CopyIT", isDirectory: true)
.appendingPathComponent("ClipboardImages", isDirectory: true)
}
}
Loading
Loading