Compare commits

...

10 Commits

Author SHA1 Message Date
Akshay Kolli
c7316105c7 WIP: add stack text batch actions
Some checks failed
CI / Test And Build (push) Has been cancelled
2026-07-01 15:58:30 -07:00
Akshay Kolli
e1557f42b2 WIP: add video thumbnails 2026-07-01 15:47:45 -07:00
Akshay Kolli
23788fd136 WIP: add video clip support 2026-07-01 15:39:43 -07:00
Akshay Kolli
26e4b15093 WIP: add visible clips to stack 2026-07-01 15:20:13 -07:00
Akshay Kolli
ca3bdfbc70 WIP: add code snippet cards 2026-07-01 15:12:49 -07:00
Akshay Kolli
c5bafa3a83 WIP: enrich link card previews 2026-07-01 14:59:52 -07:00
Akshay Kolli
c9ef1d5e84 WIP: add color clip support 2026-07-01 14:52:54 -07:00
Akshay Kolli
d22c0c23ec WIP: compact shelf chrome 2026-07-01 14:39:36 -07:00
Akshay Kolli
fdf9c6f326 WIP: add multi-file card stack preview 2026-07-01 14:33:04 -07:00
Akshay Kolli
6dcf0d523e WIP: show source initials in card badge 2026-06-30 16:56:04 -07:00
22 changed files with 2224 additions and 146 deletions

View File

@@ -16,6 +16,7 @@ let package = Package(
exclude: ["resources"],
linkerSettings: [
.linkedFramework("AppKit"),
.linkedFramework("AVFoundation"),
.linkedFramework("Carbon"),
.linkedFramework("LocalAuthentication"),
.linkedFramework("QuickLookUI"),

View File

@@ -15,7 +15,7 @@ The project is intentionally dependency-light: Swift Package Manager, AppKit, Ca
- `Command + G` shows a filtered result back in the full clipboard history
- `Shift + Command + N` creates a new collection
- `Space` previews the selected card when the focused search field is empty
- Clipboard history for text, URLs with local preview thumbnails when available, images, audio, RTF/HTML rich text, PDFs, and file references
- Clipboard history for text, URLs with local preview thumbnails when available, images, audio, video with movie thumbnails when available, RTF/HTML rich text, PDFs, and file references
- Keyboard-focusable cards and collection chips with type-to-search, Return-to-paste/select, Space-to-preview for text, links, files, and media, vertical wheel/trackpad panning and overflow edge fades in horizontal rails, visible focus chrome, and VoiceOver action hints
- Shelf navigation keys for focused cards: Left/Right, Page Up/Page Down, Home, and End
- Shelf navigation keys for focused collection chips: Left/Right, Home, and End
@@ -24,7 +24,7 @@ The project is intentionally dependency-light: Swift Package Manager, AppKit, Ca
- Sort modes for recent, most used, images, links, text, files, audio, and pinned items
- Custom named collections, including empty color-coded collections, for organizing clips from the card Collect control, context menu, keyboard-focusable collection rail, or by dragging cards onto collection chips; collection chips can be edited or deleted from their context menu
- Searchable custom titles for clips, so media, files, links, PDFs, audio, and text can be renamed without changing the copied payload
- Copy and paste actions with Accessibility permission fallback
- Copy and paste actions with Accessibility permission fallback, including Stack next-item actions and Stack-as-text batch copy/paste
- Image thumbnail cache with byte and file-count pruning
- Configurable history length, cache limit, polling profile, ignored apps, content kinds, launch-at-login, Dock/menu-bar presence, and clear-on-quit behavior, with card-level capture rules for ignoring a source app or content type
- Local-only storage, with optional sensitive-content exclusion for common secrets
@@ -80,7 +80,7 @@ Project layout:
ClipBored does not use network APIs or telemetry. Clipboard history is stored locally under Application Support.
Textual SQLite fields, image cache files, audio clips, rich text sidecars, and PDF attachments are encrypted with AES-GCM using a Keychain-held key when Keychain access is available. If Keychain access blocks or fails, ClipBored uses an owner-only app-local fallback key so capture does not stall. Full history clears remove the local fallback key when present and reset cached key state for future captures. Temporary decrypted preview files may be created when opening or revealing encrypted media; stale previews are cleared on launch, cache/history clear, and quit. Use sensitive-content exclusion and ignored app settings for high-risk sources. See [docs/SECURITY.md](docs/SECURITY.md) for details and responsible disclosure.
Textual SQLite fields, image cache files, audio clips, video clips, rich text sidecars, and PDF attachments are encrypted with AES-GCM using a Keychain-held key when Keychain access is available. If Keychain access blocks or fails, ClipBored uses an owner-only app-local fallback key so capture does not stall. Full history clears remove the local fallback key when present and reset cached key state for future captures. Temporary decrypted preview files may be created when thumbnailing, opening, or revealing encrypted media; stale previews are cleared on launch, cache/history clear, and quit. Use sensitive-content exclusion and ignored app settings for high-risk sources. See [docs/SECURITY.md](docs/SECURITY.md) for details and responsible disclosure.
## Contributing

View File

@@ -10,7 +10,7 @@ ClipBored is designed as a local macOS utility. Its primary privacy promise is t
- Hardened runtime is applied by the local build script, and the release script supports Developer ID signing plus notarization when credentials are configured.
- Clipboard persistence uses prepared SQLite statements and bound values.
- Textual SQLite fields, including optional local image OCR text, are encrypted with AES-GCM using a Keychain-held key when Keychain access is available.
- App-managed image cache files, audio clips, rich text sidecars, and PDF attachments are encrypted with the same encryption service.
- App-managed image cache files, audio clips, video clips, rich text sidecars, and PDF attachments are encrypted with the same encryption service.
- If Keychain access blocks or fails, ClipBored uses an owner-only app-local fallback key so clipboard capture and persistence continue without a Keychain UI stall.
- Full history clears remove the app-local fallback key when present and reset cached key state after the database clear succeeds.
- App-owned storage directories are restricted to the current user, and saved history/cache files are written with owner-only permissions where the filesystem supports POSIX modes.
@@ -35,7 +35,7 @@ ClipBored is designed as a local macOS utility. Its primary privacy promise is t
- SQLite item metadata such as identifiers, kinds, timestamps, pin state, and use counts is not encrypted.
- The app-local fallback key prevents plaintext app-managed history/media files, but it does not protect against a process or user account that can read the full ClipBored Application Support directory before history is cleared.
- Opening or revealing encrypted images, audio clips, or PDFs creates temporary decrypted preview files so macOS can hand them to other apps. ClipBored clears stale preview files on launch, cache/history clear, and quit.
- Thumbnailing, opening, or revealing encrypted images, audio clips, video clips, or PDFs creates temporary decrypted preview files so macOS can hand them to system media APIs or other apps. ClipBored clears stale preview files on launch, cache/history clear, and quit.
- Existing plaintext SQLite rows and legacy sidecar files are migrated when encryption becomes available, but system snapshots, backups, live temporary previews, or filesystem remnants may retain older plaintext copies.
- The local development build is ad-hoc signed; use `scripts/release-macos-app.sh` with Developer ID credentials for notarized distribution builds.
- Accessibility permission is required for automatic paste simulation.

View File

@@ -23,11 +23,12 @@ Use this checklist before a release or after changes to panel, pasteboard, setti
5. Copy an image and confirm it appears as an Image with a thumbnail.
6. Enable `Search in image labels`, copy an image containing readable text, and confirm searching for that text finds the Image.
7. Copy a sound clip and confirm it appears as Audio.
8. Copy a PDF or PDF selection and confirm it appears as a PDF.
9. Copy one Finder file and confirm it appears as a File.
10. Copy multiple Finder files at once and confirm they appear as one grouped File item with the file count.
11. Copy formatted text from a browser or Mail message and confirm it appears as Rich Text rather than flattened plain text.
12. Disable Images, Audio, Rich Text, PDFs, or Files in Settings > Capture, copy that type again, and confirm it is not captured.
8. Copy a movie or video clip and confirm it appears as Video.
9. Copy a PDF or PDF selection and confirm it appears as a PDF.
10. Copy one Finder file and confirm it appears as a File.
11. Copy multiple Finder files at once and confirm they appear as one grouped File item with the file count.
12. Copy formatted text from a browser or Mail message and confirm it appears as Rich Text rather than flattened plain text.
13. Disable Images, Audio, Video, Rich Text, PDFs, or Files in Settings > Capture, copy that type again, and confirm it is not captured.
## Panel
@@ -63,6 +64,14 @@ Use this checklist before a release or after changes to panel, pasteboard, setti
30. Confirm the Pinned empty state points to the Pin action instead of a plain-key shortcut.
31. Confirm each card's source or type badge reads as an attached header-corner tile instead of a small floating icon.
32. Confirm built-in collection chips use recognizable glyphs, while custom collection chips keep color-dot swatches.
33. Confirm cards from known apps show app identity in the header tile, falling back to source initials when an app icon is unavailable.
34. Confirm multi-file cards show a stacked file preview, while single-file cards keep the regular file layout.
35. Confirm the shelf chrome uses one row with compact search, collection chips, and utility buttons; typing a search expands the search field without pushing cards out of view.
36. Copy a color swatch from a design tool and confirm it appears as a Color card, can be filtered with the Colors chip, and copies back as both a color and hex text.
37. Copy a code snippet from an editor and confirm it appears as a Code card, remains visible in the Text chip, can be isolated with the Code chip or `type:code`, and copies back as plain text.
38. Copy a video/movie clip and confirm it appears as a Video card, uses a movie-frame thumbnail when available, filters with the Videos chip or `type:video`, `type:movie`, and `mp4`, previews/opens as a temp movie, and copies back as movie data.
39. Filter to a few clips, right-click a card or the Stack chip, choose Add Visible Clips to Stack, and confirm only the visible clips are queued once in shelf order.
40. With multiple text-like clips in Stack, choose Copy Stack as Text or Paste Stack as Text and confirm the queued text is written in stack order with blank lines between clips and consumed from Stack.
## Copy And Paste
@@ -70,11 +79,12 @@ Use this checklist before a release or after changes to panel, pasteboard, setti
2. Select a URL item and confirm the system clipboard contains both string and URL data by pasting into a browser address bar.
3. Select one-file and multi-file File items and paste into Finder or an app that accepts file references. Confirm all files are preserved for the multi-file item.
4. Select an audio item and paste into an app that accepts sound pasteboard data.
5. Select a PDF item and paste into Preview, Finder, or an app that accepts PDF pasteboard data.
6. Select a rich text item and paste into TextEdit rich text mode or Mail. Confirm basic formatting is preserved and plain-text paste still works in a text-only field.
7. Press `Command + 1` through `Command + 9` on visible numbered cards and confirm the matching card is pasted or copied; add `Shift` and confirm URL/rich items paste as plain text only.
8. Without Accessibility permission, confirm paste actions copy and show the permission fallback status.
9. With Accessibility permission granted, confirm paste returns focus to the previous app and inserts the selected item.
5. Select a Video item and paste into an app that accepts movie pasteboard data.
6. Select a PDF item and paste into Preview, Finder, or an app that accepts PDF pasteboard data.
7. Select a rich text item and paste into TextEdit rich text mode or Mail. Confirm basic formatting is preserved and plain-text paste still works in a text-only field.
8. Press `Command + 1` through `Command + 9` on visible numbered cards and confirm the matching card is pasted or copied; add `Shift` and confirm URL/rich items paste as plain text only.
9. Without Accessibility permission, confirm paste actions copy and show the permission fallback status.
10. With Accessibility permission granted, confirm paste returns focus to the previous app and inserts the selected item.
## Settings
@@ -91,11 +101,11 @@ Use this checklist before a release or after changes to panel, pasteboard, setti
1. Open the data folder from Settings > Data.
2. Confirm `history.sqlite` exists after capture.
3. Copy unique text and confirm `strings ~/Library/Application\ Support/ClipBored/history.sqlite | grep "unique text"` does not find it.
4. Copy uniquely identifiable rich text/audio/PDF data and confirm `strings ~/Library/Application\ Support/ClipBored/attachments/* | grep "unique text"` does not find it.
4. Copy uniquely identifiable rich text/audio/video/PDF data and confirm `strings ~/Library/Application\ Support/ClipBored/attachments/* | grep "unique text"` does not find it.
5. If `history-encryption.key` exists, confirm it is readable only by the current user.
6. Confirm image files are under `images/` and rich text/audio/PDF attachments are under `attachments/`.
6. Confirm image files are under `images/` and rich text/audio/video/PDF attachments are under `attachments/`.
7. Confirm app storage is local to `~/Library/Application Support/ClipBored`.
8. Open or reveal an encrypted image/audio/PDF, then quit ClipBored and confirm `/tmp/ClipBored/Previews` is removed.
8. Open or reveal an encrypted image/audio/video/PDF, then quit ClipBored and confirm `/tmp/ClipBored/Previews` is removed.
9. Use `Clear Clipboard History` and confirm saved history, app-managed attachments, temporary previews, and `history-encryption.key` are removed when that fallback key exists.
10. Confirm quitting with `Clear history on quit` enabled removes history and app-managed cache/attachment files.

View File

@@ -9,6 +9,9 @@ enum ClipboardItemKind: Int {
case unknown
case pdf
case audio
case color
case code
case video
var displayName: String {
switch self {
@@ -20,6 +23,9 @@ enum ClipboardItemKind: Int {
case .unknown: return "item"
case .pdf: return "PDF"
case .audio: return "audio"
case .color: return "color"
case .code: return "code"
case .video: return "video"
}
}
}
@@ -27,27 +33,27 @@ enum ClipboardItemKind: Int {
extension ClipboardItemKind {
var canOpen: Bool {
switch self {
case .url, .file, .image, .pdf, .audio:
case .url, .file, .image, .pdf, .audio, .video:
return true
case .text, .richText, .unknown:
case .text, .richText, .unknown, .color, .code:
return false
}
}
var canReveal: Bool {
switch self {
case .file, .image, .pdf, .audio:
case .file, .image, .pdf, .audio, .video:
return true
case .text, .richText, .unknown, .url:
case .text, .richText, .unknown, .url, .color, .code:
return false
}
}
var hasManagedCacheReference: Bool {
switch self {
case .url, .image, .pdf, .audio, .richText:
case .url, .image, .pdf, .audio, .richText, .video:
return true
case .text, .file, .unknown:
case .text, .file, .unknown, .color, .code:
return false
}
}
@@ -62,8 +68,11 @@ enum ClipboardSortMode: Int {
case pinned
case files
case audio
case colors
case code
case videos
static let allCases: [ClipboardSortMode] = [.mostRecent, .mostUsed, .text, .links, .images, .audio, .files, .pinned]
static let allCases: [ClipboardSortMode] = [.mostRecent, .mostUsed, .text, .links, .images, .colors, .audio, .videos, .files, .pinned, .code]
var title: String {
switch self {
@@ -75,6 +84,9 @@ enum ClipboardSortMode: Int {
case .pinned: return "Pinned"
case .files: return "Files"
case .audio: return "Audio"
case .colors: return "Colors"
case .code: return "Code"
case .videos: return "Videos"
}
}
}
@@ -146,6 +158,9 @@ struct ClipboardItem {
case .unknown: return "unknown"
case .pdf: return "pdf document"
case .audio: return "audio sound"
case .color: return "color swatch hex"
case .code: return "code snippet source programming"
case .video: return "video movie mp4 quicktime"
}
}

View File

@@ -0,0 +1,106 @@
import Foundation
enum CodeSnippetPayload {
static func isLikelyCode(_ value: String) -> Bool {
let text = value.clipboardTrimmed
guard text.count >= 12 else { return false }
if text.hasPrefix("```") { return true }
if languageLabel(from: text) != "Code" { return true }
let lines = text.components(separatedBy: .newlines)
let nonEmptyLines = lines.map(\.clipboardTrimmed).filter { !$0.isEmpty }
guard !nonEmptyLines.isEmpty else { return false }
var score = 0
if nonEmptyLines.count >= 2 && lines.contains(where: { $0.hasPrefix(" ") || $0.hasPrefix("\t") }) {
score += 2
}
if text.contains("{") && text.contains("}") { score += 2 }
if text.contains(";") { score += 1 }
if text.contains("=>") || text.contains("->") || text.contains("==") || text.contains("!=") || text.contains("&&") || text.contains("||") {
score += 1
}
if nonEmptyLines.filter({ $0.hasSuffix("{") || $0.hasSuffix("}") || $0.hasSuffix(";") }).count >= 2 {
score += 2
}
if containsCodeKeyword(text) { score += 2 }
if containsAssignment(text) { score += 1 }
return score >= 4
}
static func languageLabel(from value: String) -> String {
let text = value.clipboardTrimmed
let lower = text.lowercased()
if isJSON(text) { return "JSON" }
if lower.contains("<html") || lower.contains("</") && lower.contains(">") {
return "HTML"
}
if lower.contains("#include") { return "C/C++" }
if lower.contains("func ") || lower.contains("let ") || lower.contains("var ") && lower.contains("->") {
return "Swift"
}
if lower.contains("function ") || lower.contains("const ") || lower.contains("let ") && lower.contains("=>") {
return "JavaScript"
}
if lower.contains("def ") || lower.contains("import ") && lower.contains(":") {
return "Python"
}
if lower.range(of: #"\b(select|insert|update|delete|create)\b[\s\S]+\b(from|into|table|set)\b"#, options: .regularExpression) != nil {
return "SQL"
}
if lower.range(of: #"^\s*(git|npm|yarn|pnpm|curl|ssh|docker|kubectl|brew|swift|python|node)\b"#, options: .regularExpression) != nil {
return "Shell"
}
if lower.contains("{") && lower.contains(":") && lower.contains(";") {
return "CSS"
}
return "Code"
}
static func title(from value: String) -> String {
let language = languageLabel(from: value)
guard language != "Code" else { return "Code Snippet" }
return "\(language) Snippet"
}
static func previewText(from value: String, maxLines: Int = 4) -> String {
let lines = value
.components(separatedBy: .newlines)
.map { $0.clipboardTrimmed }
.filter { !$0.isEmpty && $0 != "```" }
let preview = lines.prefix(maxLines).joined(separator: " ")
return preview.isEmpty ? "Code snippet" : String(preview.prefix(180))
}
static func previewLines(from value: String, maxLines: Int = 5) -> [String] {
let lines = value
.components(separatedBy: .newlines)
.map { line in
line.replacingOccurrences(of: "\t", with: " ")
}
.filter { !$0.clipboardTrimmed.isEmpty && $0.clipboardTrimmed != "```" }
return Array(lines.prefix(maxLines))
}
private static func containsCodeKeyword(_ value: String) -> Bool {
value.range(
of: #"\b(import|func|function|class|struct|enum|interface|return|guard|if|else|for|while|switch|case|try|catch|throw|async|await|public|private|static|const|let|var|def)\b"#,
options: [.regularExpression, .caseInsensitive]
) != nil
}
private static func containsAssignment(_ value: String) -> Bool {
value.range(
of: #"\b[A-Za-z_][A-Za-z0-9_]*\s*(=|:=)\s*[^=\n]"#,
options: .regularExpression
) != nil
}
private static func isJSON(_ value: String) -> Bool {
guard let data = value.data(using: .utf8) else { return false }
let trimmed = value.clipboardTrimmed
guard trimmed.hasPrefix("{") || trimmed.hasPrefix("[") else { return false }
return (try? JSONSerialization.jsonObject(with: data)) != nil
}
}

View File

@@ -0,0 +1,75 @@
import AppKit
import Foundation
enum ColorPayload {
static func hexString(from color: NSColor) -> String? {
guard let rgb = color.usingColorSpace(.sRGB) ?? color.usingColorSpace(.deviceRGB) else {
return nil
}
let red = clampedByte(rgb.redComponent)
let green = clampedByte(rgb.greenComponent)
let blue = clampedByte(rgb.blueComponent)
let alpha = clampedByte(rgb.alphaComponent)
if alpha >= 255 {
return String(format: "#%02X%02X%02X", red, green, blue)
}
return String(format: "#%02X%02X%02X%02X", red, green, blue, alpha)
}
static func color(from payload: String) -> NSColor? {
var value = payload.clipboardTrimmed
if value.hasPrefix("#") {
value.removeFirst()
}
guard value.count == 6 || value.count == 8,
let raw = UInt32(value, radix: 16) else {
return nil
}
let hasAlpha = value.count == 8
let red = CGFloat((raw >> (hasAlpha ? 24 : 16)) & 0xFF) / 255
let green = CGFloat((raw >> (hasAlpha ? 16 : 8)) & 0xFF) / 255
let blue = CGFloat((raw >> (hasAlpha ? 8 : 0)) & 0xFF) / 255
let alpha = hasAlpha ? CGFloat(raw & 0xFF) / 255 : 1
return NSColor(deviceRed: red, green: green, blue: blue, alpha: alpha)
}
static func displayHex(from payload: String) -> String {
if let color = color(from: payload), let hex = hexString(from: color) {
return hex
}
let normalized = payload.clipboardTrimmed
return normalized.hasPrefix("#") ? normalized.uppercased() : "#\(normalized.uppercased())"
}
static func componentSummary(from payload: String) -> String {
guard let color = color(from: payload),
let rgb = color.usingColorSpace(.sRGB) ?? color.usingColorSpace(.deviceRGB) else {
return "Color"
}
let red = clampedByte(rgb.redComponent)
let green = clampedByte(rgb.greenComponent)
let blue = clampedByte(rgb.blueComponent)
let alpha = clampedByte(rgb.alphaComponent)
if alpha >= 255 {
return "RGB \(red) \(green) \(blue)"
}
return "RGBA \(red) \(green) \(blue) \(alpha)"
}
static func previewText(from payload: String) -> String {
"\(displayHex(from: payload))\n\(componentSummary(from: payload))"
}
static func contrastingTextColor(for color: NSColor) -> NSColor {
guard let rgb = color.usingColorSpace(.sRGB) ?? color.usingColorSpace(.deviceRGB) else {
return .labelColor
}
let luminance = (0.299 * rgb.redComponent) + (0.587 * rgb.greenComponent) + (0.114 * rgb.blueComponent)
return luminance > 0.62 ? NSColor.black.withAlphaComponent(0.82) : .white
}
private static func clampedByte(_ value: CGFloat) -> Int {
Int((min(1, max(0, value)) * 255).rounded())
}
}

View File

@@ -0,0 +1,62 @@
import AppKit
import Foundation
import UniformTypeIdentifiers
enum VideoPayload {
static let pasteboardTypes: [NSPasteboard.PasteboardType] = [
NSPasteboard.PasteboardType(rawValue: UTType.mpeg4Movie.identifier),
NSPasteboard.PasteboardType(rawValue: UTType.quickTimeMovie.identifier),
NSPasteboard.PasteboardType(rawValue: UTType.movie.identifier),
NSPasteboard.PasteboardType(rawValue: UTType.video.identifier),
NSPasteboard.PasteboardType(rawValue: "com.apple.m4v-video")
]
static func data(from pasteboard: NSPasteboard) -> (data: Data, type: NSPasteboard.PasteboardType)? {
for type in pasteboardTypes {
if let data = pasteboard.data(forType: type), !data.isEmpty {
return (data, type)
}
}
return nil
}
static func fileExtension(for type: NSPasteboard.PasteboardType) -> String {
switch type.rawValue {
case UTType.mpeg4Movie.identifier:
return "mp4"
case UTType.quickTimeMovie.identifier, UTType.movie.identifier, UTType.video.identifier:
return "mov"
case "com.apple.m4v-video":
return "m4v"
default:
return "mov"
}
}
static func pasteboardType(forPath path: String) -> NSPasteboard.PasteboardType {
switch URL(fileURLWithPath: path).pathExtension.lowercased() {
case "mp4":
return NSPasteboard.PasteboardType(rawValue: UTType.mpeg4Movie.identifier)
case "m4v":
return NSPasteboard.PasteboardType(rawValue: "com.apple.m4v-video")
case "mov", "qt":
return NSPasteboard.PasteboardType(rawValue: UTType.quickTimeMovie.identifier)
default:
return NSPasteboard.PasteboardType(rawValue: UTType.movie.identifier)
}
}
static func displayTitle(byteCount: Int) -> String {
"Video (\(ByteCountFormatter.string(fromByteCount: Int64(byteCount), countStyle: .file)))"
}
static func fileExtension(from path: String) -> String {
let value = URL(fileURLWithPath: path).pathExtension.clipboardTrimmed
return value.isEmpty ? "mov" : value.lowercased()
}
static func kindText(from path: String) -> String {
let value = fileExtension(from: path)
return value.isEmpty ? "Video" : value.uppercased()
}
}

View File

@@ -1,7 +1,10 @@
import AppKit
import AVFoundation
import Foundation
final class ClipboardCacheService {
typealias VideoThumbnailProvider = (URL) -> NSImage?
private let thumbnailCache = NSCache<NSString, NSImage>()
private let fileManager = FileManager.default
private let queue = DispatchQueue(label: "clipboard.cache.service", qos: .utility)
@@ -9,8 +12,13 @@ final class ClipboardCacheService {
private let attachmentDirectory: URL
private let temporaryPreviewDirectory: URL
private let encryptionService: ClipboardEncryptionService
private let videoThumbnailProvider: VideoThumbnailProvider
init(baseURL: URL? = nil, encryptionService: ClipboardEncryptionService = ClipboardEncryptionService()) {
init(
baseURL: URL? = nil,
encryptionService: ClipboardEncryptionService = ClipboardEncryptionService(),
videoThumbnailProvider: VideoThumbnailProvider? = nil
) {
let base = baseURL ?? ClipboardStore.storageDirectory()
imageDirectory = base.appendingPathComponent("images", isDirectory: true)
attachmentDirectory = base.appendingPathComponent("attachments", isDirectory: true)
@@ -18,12 +26,13 @@ final class ClipboardCacheService {
.appendingPathComponent(AppConfiguration.appName, isDirectory: true)
.appendingPathComponent("Previews", isDirectory: true)
self.encryptionService = encryptionService
self.videoThumbnailProvider = videoThumbnailProvider ?? Self.makeVideoThumbnail
thumbnailCache.countLimit = 128
try? fileManager.createDirectory(at: imageDirectory, withIntermediateDirectories: true)
try? fileManager.createDirectory(at: attachmentDirectory, withIntermediateDirectories: true)
hardenDirectory(imageDirectory)
hardenDirectory(attachmentDirectory)
clearTemporaryPreviews()
clearTemporaryPreviews(wait: true)
}
func cacheImage(_ image: NSImage, id: UUID) -> (full: String, thumb: String)? {
@@ -55,6 +64,10 @@ final class ClipboardCacheService {
cacheAttachment(data, id: id, fileExtension: "sound")
}
func cacheVideo(_ data: Data, id: UUID, fileExtension: String) -> String? {
cacheAttachment(data, id: id, fileExtension: fileExtension)
}
func cacheRichText(_ data: Data, id: UUID) -> String? {
cacheAttachment(data, id: id, fileExtension: "rtf")
}
@@ -105,7 +118,10 @@ final class ClipboardCacheService {
case .file:
return filePreviewThumbnail(for: item.payload)
case .text, .unknown, .audio, .richText:
case .video:
return videoPreviewThumbnail(for: item)
case .text, .unknown, .audio, .richText, .color, .code:
return nil
}
}
@@ -150,6 +166,51 @@ final class ClipboardCacheService {
return image
}
private func videoPreviewThumbnail(for item: ClipboardItem) -> NSImage? {
let key = NSString(string: "video-preview:\(item.id.uuidString):\(item.payload)")
if let cached = thumbnailCache.object(forKey: key) {
return cached
}
guard
let data = data(for: item.payload),
let temporaryURL = writeTemporaryCopy(
data: data,
id: item.id,
fileExtension: VideoPayload.fileExtension(from: item.payload)
)
else {
return nil
}
defer { removeTemporaryCopyIfPossible(temporaryURL) }
guard let image = videoThumbnailProvider(temporaryURL), hasDrawableSize(image) else {
return nil
}
let thumbnail = image.resized(to: CGSize(width: 260, height: 132))
thumbnailCache.setObject(thumbnail, forKey: key)
return thumbnail
}
private static func makeVideoThumbnail(from url: URL) -> NSImage? {
let asset = AVURLAsset(url: url)
let generator = AVAssetImageGenerator(asset: asset)
generator.appliesPreferredTrackTransform = true
generator.maximumSize = CGSize(width: 520, height: 264)
generator.requestedTimeToleranceBefore = .zero
generator.requestedTimeToleranceAfter = CMTime(value: 1, timescale: 30)
for time in [CMTime(seconds: 0.08, preferredTimescale: 600), .zero] {
if let cgImage = try? generator.copyCGImage(at: time, actualTime: nil) {
return NSImage(
cgImage: cgImage,
size: NSSize(width: CGFloat(cgImage.width), height: CGFloat(cgImage.height))
)
}
}
return nil
}
private func hasDrawableSize(_ image: NSImage) -> Bool {
image.size.width > 0 && image.size.height > 0
}
@@ -174,6 +235,9 @@ final class ClipboardCacheService {
case .audio:
guard let data = data(for: item.payload) else { return nil }
return writeTemporaryCopy(data: data, id: item.id, fileExtension: "sound")
case .video:
guard let data = data(for: item.payload) else { return nil }
return writeTemporaryCopy(data: data, id: item.id, fileExtension: VideoPayload.fileExtension(from: item.payload))
case .richText:
guard let data = data(for: item.payload) else { return nil }
return writeTemporaryCopy(data: data, id: item.id, fileExtension: "rtf")
@@ -187,14 +251,17 @@ final class ClipboardCacheService {
case .file:
let urls = FilePayload.urls(from: item.payload)
return urls.first { fileManager.fileExists(atPath: $0.path) }
case .text, .unknown:
case .text, .code, .unknown:
let text = item.payload.clipboardTrimmed.isEmpty ? item.displayText : item.payload
guard !text.clipboardTrimmed.isEmpty else { return nil }
return writeTemporaryCopy(data: Data(text.utf8), id: item.id, fileExtension: "txt")
case .color:
let text = ColorPayload.previewText(from: item.payload)
return writeTemporaryCopy(data: Data(text.utf8), id: item.id, fileExtension: "txt")
case .url:
guard let data = webLocationData(for: item.payload) else { return nil }
return writeTemporaryCopy(data: data, id: item.id, fileExtension: "webloc")
case .image, .pdf, .audio, .richText:
case .image, .pdf, .audio, .richText, .video:
return temporaryReadableURL(for: item)
}
}
@@ -209,7 +276,7 @@ final class ClipboardCacheService {
if let thumbnailPath = item.thumbnailPath {
_ = self.data(for: thumbnailPath)
}
if (item.kind == .pdf || item.kind == .audio || item.kind == .richText), self.isManagedAttachment(path: item.payload) {
if (item.kind == .pdf || item.kind == .audio || item.kind == .richText || item.kind == .video), self.isManagedAttachment(path: item.payload) {
_ = self.data(for: item.payload)
}
}
@@ -227,7 +294,7 @@ final class ClipboardCacheService {
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) {
if (item.kind == .pdf || item.kind == .audio || item.kind == .richText || item.kind == .video), self.isManagedAttachment(path: item.payload) {
try? self.fileManager.removeItem(atPath: item.payload)
}
}
@@ -337,6 +404,13 @@ final class ClipboardCacheService {
}
}
private func removeTemporaryCopyIfPossible(_ url: URL) {
try? fileManager.removeItem(at: url)
if ((try? fileManager.contentsOfDirectory(at: temporaryPreviewDirectory, includingPropertiesForKeys: nil)) ?? []).isEmpty {
try? fileManager.removeItem(at: temporaryPreviewDirectory)
}
}
private func webLocationData(for value: String) -> Data? {
let trimmed = value.clipboardTrimmed
guard !trimmed.isEmpty else { return nil }

View File

@@ -195,6 +195,15 @@ final class ClipboardMonitorService {
return itemFromURL(url.url, title: url.title, sourceApp: source.name, sourceBundleId: source.bundleId, previewPasteboard: pasteboard)
}
if isIgnored(.color), hasColor(on: pasteboard) {
reportReadFailureStatus(ignoredKindMessage(.color))
return nil
}
if let colorItem = itemFromColor(pasteboard, sourceApp: source.name, sourceBundleId: source.bundleId) {
return colorItem
}
if isIgnored(.image), hasImage(on: pasteboard) {
reportReadFailureStatus(ignoredKindMessage(.image))
return nil
@@ -213,6 +222,15 @@ final class ClipboardMonitorService {
return pdfItem
}
if isIgnored(.video), hasVideo(on: pasteboard) {
reportReadFailureStatus(ignoredKindMessage(.video))
return nil
}
if let videoItem = itemFromVideo(pasteboard, sourceApp: source.name, sourceBundleId: source.bundleId) {
return videoItem
}
if isIgnored(.audio), hasAudio(on: pasteboard) {
reportReadFailureStatus(ignoredKindMessage(.audio))
return nil
@@ -245,17 +263,23 @@ final class ClipboardMonitorService {
return htmlPayload
}
if isIgnored(.text), let string = pasteboard.string(forType: .string) {
if let string = pasteboard.string(forType: .string) {
let trimmed = string.clipboardTrimmed
if !trimmed.isEmpty {
reportReadFailureStatus(ignoredKindMessage(.text))
return nil
if CodeSnippetPayload.isLikelyCode(trimmed), isIgnored(.code) {
reportReadFailureStatus(ignoredKindMessage(.code))
return nil
}
if !CodeSnippetPayload.isLikelyCode(trimmed), isIgnored(.text) {
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 {
if (item.kind == .text || item.kind == .code), item.payload.isEmpty {
reportReadFailureStatus("Clipboard contains no readable text.")
return nil
}
@@ -296,6 +320,24 @@ final class ClipboardMonitorService {
)
}
if CodeSnippetPayload.isLikelyCode(trimmed) {
return ClipboardItem(
id: UUID(),
kind: .code,
displayText: CodeSnippetPayload.title(from: trimmed),
payload: trimmed,
payloadHash: store.hashString(trimmed),
createdAt: Date(),
lastUsedAt: Date(),
useCount: 1,
sourceApp: sourceApp,
imagePath: nil,
thumbnailPath: nil,
isPinned: false,
sourceAppBundleId: sourceBundleId
)
}
return ClipboardItem(
id: UUID(),
kind: .text,
@@ -407,6 +449,14 @@ final class ClipboardMonitorService {
pasteboard.data(forType: .sound) != nil
}
private func hasVideo(on pasteboard: NSPasteboard) -> Bool {
VideoPayload.data(from: pasteboard) != nil
}
private func hasColor(on pasteboard: NSPasteboard) -> Bool {
NSColor(from: pasteboard) != 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
@@ -474,6 +524,56 @@ final class ClipboardMonitorService {
)
}
private func itemFromVideo(_ pasteboard: NSPasteboard, sourceApp: String?, sourceBundleId: String?) -> ClipboardItem? {
guard let video = VideoPayload.data(from: pasteboard) else { return nil }
let id = UUID()
let hash = store.hashString(video.data.base64EncodedString())
guard let path = cacheService.cacheVideo(video.data, id: id, fileExtension: VideoPayload.fileExtension(for: video.type)) else {
reportReadFailureStatus("Failed to cache video for clipboard history.")
return nil
}
return ClipboardItem(
id: id,
kind: .video,
displayText: VideoPayload.displayTitle(byteCount: video.data.count),
payload: path,
payloadHash: hash,
createdAt: Date(),
lastUsedAt: Date(),
useCount: 1,
sourceApp: sourceApp,
imagePath: nil,
thumbnailPath: nil,
isPinned: false,
sourceAppBundleId: sourceBundleId
)
}
private func itemFromColor(_ pasteboard: NSPasteboard, sourceApp: String?, sourceBundleId: String?) -> ClipboardItem? {
guard let color = NSColor(from: pasteboard) else { return nil }
guard let hex = ColorPayload.hexString(from: color) else {
reportReadFailureStatus("Clipboard color is present but could not be decoded.")
return nil
}
return ClipboardItem(
id: UUID(),
kind: .color,
displayText: hex,
payload: hex,
payloadHash: store.hashString(hex),
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)

View File

@@ -111,7 +111,7 @@ final class ClipboardStore {
func updateText(_ id: UUID, text: String) -> Bool {
guard !text.isEmpty,
let index = items.firstIndex(where: { $0.id == id }),
items[index].kind == .text else {
items[index].kind == .text || items[index].kind == .code else {
return false
}

View File

@@ -77,27 +77,19 @@ final class PasteActionService {
}
func pastePlainText(_ item: ClipboardItem, targetApp: NSRunningApplication?) -> PasteActionResult {
guard writePlainTextToPasteboard(item) else {
guard let text = plainText(for: item), writePlainTextToPasteboard(text) else {
return .failed("Could not write plain text to clipboard.")
}
guard let targetApp,
!targetApp.isTerminated else {
return .copiedPlainText
return completePlainTextPaste(targetApp: targetApp)
}
func pastePlainText(_ value: String, targetApp: NSRunningApplication?) -> PasteActionResult {
guard writePlainTextToPasteboard(value) else {
return .failed("Could not write plain text to clipboard.")
}
guard accessibilityPermissionProvider() else {
return .copiedPlainTextNeedsPermission
}
guard targetActivator(targetApp) else {
return .copiedPlainText
}
keyboardPasteScheduler { [weak self] in
self?.pasteViaKeyboard()
}
return .pastedPlainText
return completePlainTextPaste(targetApp: targetApp)
}
@discardableResult
@@ -110,6 +102,11 @@ final class PasteActionService {
writePlainTextToPasteboard(item) ? .copiedPlainText : .failed("Could not write plain text to clipboard.")
}
@discardableResult
func copyPlainText(_ value: String) -> PasteActionResult {
writePlainTextToPasteboard(value) ? .copiedPlainText : .failed("Could not write plain text to clipboard.")
}
func pasteboardWriters(for item: ClipboardItem) -> [NSPasteboardWriting] {
switch item.kind {
case .image:
@@ -130,6 +127,17 @@ final class PasteActionService {
pasteboardItem.setString(dragLabel(for: item), forType: .string)
return [pasteboardItem]
case .video:
guard let data = cacheService.data(for: item.payload) else { return [] }
let pasteboardItem = NSPasteboardItem()
pasteboardItem.setData(data, forType: VideoPayload.pasteboardType(forPath: item.payload))
pasteboardItem.setString(dragLabel(for: item), forType: .string)
return [pasteboardItem]
case .color:
guard let color = ColorPayload.color(from: item.payload) else { return [] }
return [color]
case .richText:
if let data = cacheService.data(for: item.payload) {
let pasteboardItem = NSPasteboardItem()
@@ -161,7 +169,7 @@ final class PasteActionService {
}
return [pasteboardItem]
case .text, .unknown:
case .text, .code, .unknown:
guard !item.payload.isEmpty else { return [] }
return [stringPasteboardItem(item.payload)]
}
@@ -184,6 +192,17 @@ final class PasteActionService {
guard let data = cacheService.data(for: item.payload) else { return false }
board.clearContents()
didWrite = board.setData(data, forType: .sound)
case .video:
guard let data = cacheService.data(for: item.payload) else { return false }
board.clearContents()
didWrite = board.setData(data, forType: VideoPayload.pasteboardType(forPath: item.payload))
case .color:
guard let color = ColorPayload.color(from: item.payload) else { return false }
board.clearContents()
didWrite = board.writeObjects([color])
if didWrite {
board.setString(ColorPayload.displayHex(from: item.payload), forType: .string)
}
case .richText:
if let data = cacheService.data(for: item.payload) {
board.clearContents()
@@ -211,7 +230,7 @@ final class PasteActionService {
guard !item.payload.isEmpty else { return false }
board.clearContents()
didWrite = writeURL(item.payload, title: item.displayText, to: board)
case .text, .unknown:
case .text, .code, .unknown:
guard !item.payload.isEmpty else { return false }
board.clearContents()
didWrite = board.setString(item.payload, forType: .string)
@@ -225,7 +244,7 @@ final class PasteActionService {
func plainText(for item: ClipboardItem) -> String? {
switch item.kind {
case .text, .unknown:
case .text, .code, .unknown:
return nonEmptyPlainText(item.payload) ?? nonEmptyPlainText(item.displayText)
case .url, .file:
return nonEmptyPlainText(item.payload) ?? nonEmptyPlainText(item.displayText)
@@ -237,14 +256,22 @@ final class PasteActionService {
return nonEmptyPlainText(richTextFallbackPlainString(for: item))
case .image:
return nonEmptyPlainText(item.ocrText) ?? nonEmptyPlainText(item.displayText)
case .pdf, .audio:
case .pdf, .audio, .video:
return nonEmptyPlainText(item.displayText)
case .color:
return nonEmptyPlainText(ColorPayload.displayHex(from: item.payload)) ?? nonEmptyPlainText(item.displayText)
}
}
@discardableResult
func writePlainTextToPasteboard(_ item: ClipboardItem) -> Bool {
guard let text = plainText(for: item) else { return false }
return writePlainTextToPasteboard(text)
}
@discardableResult
private func writePlainTextToPasteboard(_ text: String) -> Bool {
guard !text.clipboardTrimmed.isEmpty else { return false }
let board = NSPasteboard.general
board.clearContents()
let didWrite = board.setString(text, forType: .string)
@@ -254,6 +281,26 @@ final class PasteActionService {
return didWrite
}
private func completePlainTextPaste(targetApp: NSRunningApplication?) -> PasteActionResult {
guard let targetApp,
!targetApp.isTerminated else {
return .copiedPlainText
}
guard accessibilityPermissionProvider() else {
return .copiedPlainTextNeedsPermission
}
guard targetActivator(targetApp) else {
return .copiedPlainText
}
keyboardPasteScheduler { [weak self] in
self?.pasteViaKeyboard()
}
return .pastedPlainText
}
private func stringPasteboardItem(_ value: String) -> NSPasteboardItem {
let pasteboardItem = NSPasteboardItem()
pasteboardItem.setString(value, forType: .string)

View File

@@ -82,7 +82,9 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel
23: .images,
22: .files,
26: .pinned,
28: .audio
28: .audio,
25: .colors,
29: .code
]
private let viewModel: ClipboardPanelViewModel

File diff suppressed because it is too large Load Diff

View File

@@ -308,6 +308,27 @@ final class ClipboardPanelViewModel {
statusMessage = "Added to Stack"
}
func addVisibleItemsToStack() {
pruneStackItems()
guard !visibleItems.isEmpty else {
statusMessage = "No visible clips to stack"
return
}
let existingIDs = Set(stackItemIDs)
let newIDs = visibleItems
.map(\.id)
.filter { !existingIDs.contains($0) }
guard !newIDs.isEmpty else {
statusMessage = "Visible clips are already in Stack"
return
}
stackItemIDs.append(contentsOf: newIDs)
let noun = newIDs.count == 1 ? "clip" : "clips"
statusMessage = "Added \(newIDs.count) \(noun) to Stack"
}
func selectStack() {
guard !stackItemIDs.isEmpty else { return }
selectedCollectionName = nil
@@ -352,20 +373,43 @@ final class ClipboardPanelViewModel {
handleStackActionResult(result, item: item)
}
func copyStackAsText() {
guard let package = stackPlainTextPackage() else {
statusMessage = "Stack has no text to copy"
return
}
let result = pasteService.copyPlainText(package.text)
handleStackPlainTextActionResult(result, items: package.items)
}
func pasteStackAsText() {
guard let package = stackPlainTextPackage() else {
statusMessage = "Stack has no text to paste"
return
}
let result = pasteService.pastePlainText(package.text, targetApp: targetApplicationProvider())
if case .pastedPlainText = result {
willPasteToTarget()
}
handleStackPlainTextActionResult(result, items: package.items)
}
func pasteboardWriters(forItemAt index: Int) -> [NSPasteboardWriting] {
guard index >= 0 && index < visibleItems.count else { return [] }
return pasteService.pasteboardWriters(for: visibleItems[index])
}
func editableTextForSelected() -> String? {
guard let item = selectedItem, item.kind == .text else { return nil }
guard let item = selectedItem, item.kind == .text || item.kind == .code else { return nil }
return item.payload
}
func editableTextForItem(at index: Int) -> String? {
guard index >= 0 && index < visibleItems.count else { return nil }
let item = visibleItems[index]
guard item.kind == .text else { return nil }
guard item.kind == .text || item.kind == .code else { return nil }
return item.payload
}
@@ -388,7 +432,7 @@ final class ClipboardPanelViewModel {
}
func updateSelectedText(to text: String) {
guard let item = selectedItem, item.kind == .text else { return }
guard let item = selectedItem, item.kind == .text || item.kind == .code else { return }
let trimmed = text.clipboardTrimmed
guard !trimmed.isEmpty else {
statusMessage = "Text clip cannot be empty"
@@ -401,7 +445,7 @@ final class ClipboardPanelViewModel {
selectedItemID = item.id
if store.updateText(item.id, text: text) {
statusMessage = "Updated text clip"
statusMessage = item.kind == .code ? "Updated code clip" : "Updated text clip"
}
}
@@ -433,6 +477,11 @@ final class ClipboardPanelViewModel {
case .audio:
guard let url = cacheService.temporaryReadableURL(for: item) else { return }
NSWorkspace.shared.open(url)
case .video:
guard let url = cacheService.temporaryReadableURL(for: item) else { return }
NSWorkspace.shared.open(url)
case .color:
break
default:
break
}
@@ -454,6 +503,11 @@ final class ClipboardPanelViewModel {
case .audio:
guard let url = cacheService.temporaryReadableURL(for: item) else { return }
NSWorkspace.shared.activateFileViewerSelecting([url])
case .video:
guard let url = cacheService.temporaryReadableURL(for: item) else { return }
NSWorkspace.shared.activateFileViewerSelecting([url])
case .color:
break
default:
break
}
@@ -628,6 +682,20 @@ final class ClipboardPanelViewModel {
return items.first { $0.id == id }
}
private func stackPlainTextPackage() -> (text: String, items: [ClipboardItem])? {
pruneStackItems()
let pairs: [(item: ClipboardItem, text: String)] = stackItemIDs.compactMap { id in
guard let item = items.first(where: { $0.id == id }),
let text = pasteService.plainText(for: item)?.clipboardTrimmed,
!text.isEmpty else {
return nil
}
return (item, text)
}
guard !pairs.isEmpty else { return nil }
return (pairs.map(\.text).joined(separator: "\n\n"), pairs.map(\.item))
}
private func handleStackActionResult(_ result: PasteActionService.PasteActionResult, item: ClipboardItem) {
if case .failed(let message) = result {
statusMessage = message
@@ -650,6 +718,34 @@ final class ClipboardPanelViewModel {
settings.setPasteStatus(message: statusMessage)
}
private func handleStackPlainTextActionResult(_ result: PasteActionService.PasteActionResult, items: [ClipboardItem]) {
if case .failed(let message) = result {
statusMessage = message
return
}
for item in items {
store.markUsed(item.id)
consumeStackItem(item.id)
}
if stackItemIDs.isEmpty {
isStackFilterSelected = false
}
selectedItemID = items.first?.id
let noun = items.count == 1 ? "clip" : "clips"
switch result {
case .pastedPlainText:
statusMessage = "Pasted \(items.count) Stack \(noun) as Text"
case .copiedPlainTextNeedsPermission:
statusMessage = "Copied \(items.count) Stack \(noun) as Text. Grant Accessibility access to paste automatically."
case .copiedPlainText:
statusMessage = "Copied \(items.count) Stack \(noun) as Text"
default:
statusMessage = result.message
}
settings.setPasteStatus(message: statusMessage)
}
private func consumeStackItem(_ id: UUID) {
guard let index = stackItemIDs.firstIndex(of: id) else { return }
stackItemIDs.remove(at: index)
@@ -751,7 +847,7 @@ final class ClipboardPanelViewModel {
case .text:
return collectionFiltered
.filter { $0.1.kind == .text || $0.1.kind == .richText }
.filter { $0.1.kind == .text || $0.1.kind == .richText || $0.1.kind == .code }
.sorted(by: sortByUsage)
.map(\.1)
@@ -767,6 +863,24 @@ final class ClipboardPanelViewModel {
.sorted(by: sortByUsage)
.map(\.1)
case .videos:
return collectionFiltered
.filter { $0.1.kind == .video }
.sorted(by: sortByUsage)
.map(\.1)
case .colors:
return collectionFiltered
.filter { $0.1.kind == .color }
.sorted(by: sortByUsage)
.map(\.1)
case .code:
return collectionFiltered
.filter { $0.1.kind == .code }
.sorted(by: sortByUsage)
.map(\.1)
case .pinned:
return collectionFiltered
.filter { $0.1.isPinned }
@@ -967,6 +1081,8 @@ final class ClipboardPanelViewModel {
return [.richText]
case "note", "notes", "writing":
return [.text, .richText]
case "code", "snippet", "snippets", "source", "programming", "script", "scripts", "json", "css", "sql":
return [.code]
case "link", "links", "url", "urls", "web":
return [.url]
case "image", "images", "photo", "photos", "picture", "pictures":
@@ -977,6 +1093,10 @@ final class ClipboardPanelViewModel {
return [.pdf]
case "audio", "sound", "music":
return [.audio]
case "video", "videos", "movie", "movies", "mp4", "quicktime", "mov":
return [.video]
case "color", "colors", "swatch", "swatches", "hex":
return [.color]
case "unknown", "item":
return [.unknown]
default:

View File

@@ -167,9 +167,12 @@ final class SettingsWindowController: NSObject, NSTextFieldDelegate, NSTextViewD
let allowedRows = [
kindCheckbox("Text", .text),
kindCheckbox("Code", .code),
kindCheckbox("Links", .url),
kindCheckbox("Images", .image),
kindCheckbox("Colors", .color),
kindCheckbox("Audio", .audio),
kindCheckbox("Videos", .video),
kindCheckbox("Rich text", .richText),
kindCheckbox("PDFs", .pdf),
kindCheckbox("Files", .file)
@@ -199,7 +202,7 @@ final class SettingsWindowController: NSObject, NSTextFieldDelegate, NSTextViewD
}
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 storageLabel = caption("Clipboard history is stored locally in Application Support. Text, image cache files, audio clips, video 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)

View File

@@ -91,6 +91,32 @@ final class ClipboardCacheServiceTests: XCTestCase {
XCTAssertNotNil(cacheService.previewThumbnail(for: pdfItem(path: path)))
}
func testPreviewThumbnailUsesDecryptedVideoTemporaryCopyAndCachesResult() throws {
let baseURL = try makeTempDirectory()
let videoData = Data([0, 0, 0, 24, 102, 116, 121, 112, 109, 112, 52, 50])
var providerURLs: [URL] = []
let cacheService = ClipboardCacheService(
baseURL: baseURL,
encryptionService: fixedEncryptionService(),
videoThumbnailProvider: { url in
providerURLs.append(url)
XCTAssertEqual(url.pathExtension, "mp4")
XCTAssertEqual(try? Data(contentsOf: url), videoData)
return self.makeImage(color: .systemIndigo)
}
)
let path = try XCTUnwrap(cacheService.cacheVideo(videoData, id: UUID(), fileExtension: "mp4"))
let item = videoItem(path: path)
let thumbnail = cacheService.previewThumbnail(for: item)
let cachedThumbnail = cacheService.previewThumbnail(for: item)
XCTAssertNotNil(thumbnail)
XCTAssertNotNil(cachedThumbnail)
XCTAssertEqual(providerURLs.count, 1)
XCTAssertFalse(FileManager.default.fileExists(atPath: try XCTUnwrap(providerURLs.first).path))
}
func testPDFCacheFilesAreEncryptedAndReadable() throws {
let baseURL = try makeTempDirectory()
let cacheService = ClipboardCacheService(baseURL: baseURL, encryptionService: fixedEncryptionService())
@@ -119,6 +145,20 @@ final class ClipboardCacheServiceTests: XCTestCase {
XCTAssertEqual(try posixPermissions(URL(fileURLWithPath: path)), 0o600)
}
func testVideoCacheFilesAreEncryptedAndReadable() throws {
let baseURL = try makeTempDirectory()
let cacheService = ClipboardCacheService(baseURL: baseURL, encryptionService: fixedEncryptionService())
let videoData = Data([0, 0, 0, 24, 102, 116, 121, 112, 109, 112, 52, 50])
let path = try XCTUnwrap(cacheService.cacheVideo(videoData, id: UUID(), fileExtension: "mp4"))
let rawVideo = try Data(contentsOf: URL(fileURLWithPath: path))
XCTAssertTrue(ClipboardEncryptionService.isProtected(rawVideo))
XCTAssertNotEqual(rawVideo, videoData)
XCTAssertEqual(cacheService.data(for: path), videoData)
XCTAssertEqual(try posixPermissions(URL(fileURLWithPath: path)), 0o600)
}
func testRichTextCacheFilesAreEncryptedAndReadable() throws {
let baseURL = try makeTempDirectory()
let cacheService = ClipboardCacheService(baseURL: baseURL, encryptionService: fixedEncryptionService())
@@ -178,6 +218,19 @@ final class ClipboardCacheServiceTests: XCTestCase {
XCTAssertEqual(try posixPermissions(previewURL), 0o600)
}
func testTemporaryReadableURLWorksForVideo() throws {
let baseURL = try makeTempDirectory()
let cacheService = ClipboardCacheService(baseURL: baseURL, encryptionService: fixedEncryptionService())
let videoData = Data([0, 0, 0, 24, 102, 116, 121, 112, 109, 112, 52, 50])
let path = try XCTUnwrap(cacheService.cacheVideo(videoData, id: UUID(), fileExtension: "mp4"))
let previewURL = try XCTUnwrap(cacheService.temporaryReadableURL(for: videoItem(path: path)))
XCTAssertEqual(try Data(contentsOf: previewURL), videoData)
XCTAssertEqual(previewURL.pathExtension, "mp4")
XCTAssertEqual(try posixPermissions(previewURL), 0o600)
}
func testTemporaryReadableURLWorksForRichText() throws {
let baseURL = try makeTempDirectory()
let cacheService = ClipboardCacheService(baseURL: baseURL, encryptionService: fixedEncryptionService())
@@ -325,6 +378,22 @@ final class ClipboardCacheServiceTests: XCTestCase {
)
}
private func videoItem(path: String) -> ClipboardItem {
ClipboardItem(
id: UUID(),
kind: .video,
displayText: "Video",
payload: path,
payloadHash: "hash",
createdAt: Date(),
lastUsedAt: Date(),
useCount: 0,
sourceApp: nil,
imagePath: nil,
thumbnailPath: nil
)
}
private func richTextItem(path: String) -> ClipboardItem {
ClipboardItem(
id: UUID(),
@@ -346,7 +415,7 @@ final class ClipboardCacheServiceTests: XCTestCase {
}
private func makeImage(color: NSColor) -> NSImage {
let size = NSSize(width: 24, height: 24)
let size = NSSize(width: 64, height: 40)
let image = NSImage(size: size)
image.lockFocus()
color.setFill()

View File

@@ -108,6 +108,37 @@ final class ClipboardMonitorServiceTests: XCTestCase {
XCTAssertEqual(store.items.filter { $0.payload == text }.count, 1)
}
func testPollNowCapturesCodeSnippetAsCode() {
let settings = SettingsModel(defaults: makeTestDefaults())
settings.pruneDuplicates = false
let (store, cacheService) = makeStoreAndCache(settings: settings)
let monitor = ClipboardMonitorService(
store: store,
cacheService: cacheService,
settings: settings
)
let snippet = "func greet(name: String) -> String {\n return \"Hi \\(name)\"\n}"
let captured = expectation(description: "code snippet captured")
store.observeItems { items in
if items.contains(where: { $0.kind == .code && $0.payload == snippet }) {
captured.fulfill()
}
}
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
XCTAssertTrue(pasteboard.setString(snippet, forType: .string))
monitor.pollNowAndWait()
wait(for: [captured], timeout: 1.0)
let item = store.items.first
XCTAssertEqual(item?.kind, .code)
XCTAssertEqual(item?.displayText, "Swift Snippet")
XCTAssertEqual(item?.payload, snippet)
}
func testPollNowIgnoresClipBoredPasteboardWrites() {
let settings = SettingsModel(defaults: makeTestDefaults())
let (store, cacheService) = makeStoreAndCache(settings: settings)
@@ -187,6 +218,63 @@ final class ClipboardMonitorServiceTests: XCTestCase {
XCTAssertEqual(NSPasteboard.general.data(forType: .sound), audioData)
}
func testPollNowCapturesVideoAsRestorableAttachment() throws {
let settings = SettingsModel(defaults: makeTestDefaults())
let (store, cacheService) = makeStoreAndCache(settings: settings)
let monitor = ClipboardMonitorService(store: store, cacheService: cacheService, settings: settings)
let videoData = Data([0, 0, 0, 24, 102, 116, 121, 112, 109, 112, 52, 50])
let captured = expectation(description: "video captured")
store.observeItems { items in
if items.contains(where: { $0.kind == .video }) {
captured.fulfill()
}
}
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
XCTAssertTrue(pasteboard.setData(videoData, forType: VideoPayload.pasteboardTypes[0]))
monitor.pollNowAndWait()
wait(for: [captured], timeout: 1.0)
let item = try XCTUnwrap(store.items.first(where: { $0.kind == .video }))
XCTAssertTrue(FileManager.default.fileExists(atPath: item.payload))
XCTAssertEqual(item.displayText, VideoPayload.displayTitle(byteCount: videoData.count))
XCTAssertEqual(cacheService.data(for: item.payload), videoData)
XCTAssertEqual(PasteActionService(cacheService: cacheService).copy(item), .copied)
XCTAssertEqual(NSPasteboard.general.data(forType: VideoPayload.pasteboardTypes[0]), videoData)
}
func testPollNowCapturesColorAsRestorableSwatch() throws {
let settings = SettingsModel(defaults: makeTestDefaults())
let (store, cacheService) = makeStoreAndCache(settings: settings)
let monitor = ClipboardMonitorService(store: store, cacheService: cacheService, settings: settings)
let color = NSColor(deviceRed: 10 / 255, green: 132 / 255, blue: 255 / 255, alpha: 1)
let captured = expectation(description: "color captured")
store.observeItems { items in
if items.contains(where: { $0.kind == .color && $0.payload == "#0A84FF" }) {
captured.fulfill()
}
}
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
XCTAssertTrue(pasteboard.writeObjects([color]))
monitor.pollNowAndWait()
wait(for: [captured], timeout: 1.0)
let item = try XCTUnwrap(store.items.first(where: { $0.kind == .color }))
XCTAssertEqual(item.displayText, "#0A84FF")
XCTAssertEqual(item.payload, "#0A84FF")
XCTAssertEqual(PasteActionService(cacheService: cacheService).copy(item), .copied)
let restored = try XCTUnwrap(NSColor(from: NSPasteboard.general))
XCTAssertEqual(ColorPayload.hexString(from: restored), "#0A84FF")
XCTAssertEqual(NSPasteboard.general.string(forType: .string), "#0A84FF")
}
func testPollNowCapturesFileReference() throws {
let settings = SettingsModel(defaults: makeTestDefaults())
let (store, cacheService) = makeStoreAndCache(settings: settings)
@@ -592,6 +680,42 @@ final class ClipboardMonitorServiceTests: XCTestCase {
XCTAssertTrue(try imageCacheFileURLs(in: baseURL).isEmpty)
}
func testIgnoredColorKindDoesNotCaptureSwatch() throws {
let settings = SettingsModel(defaults: makeTestDefaults())
settings.ignoredItemKindsRaw = [ClipboardItemKind.color.rawValue]
let (store, cacheService, _) = makeStoreCacheAndBaseURL(settings: settings)
let monitor = ClipboardMonitorService(store: store, cacheService: cacheService, settings: settings)
let color = NSColor(deviceRed: 10 / 255, green: 132 / 255, blue: 255 / 255, alpha: 1)
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
XCTAssertTrue(pasteboard.writeObjects([color]))
monitor.pollNowAndWait()
RunLoop.main.run(until: Date().addingTimeInterval(0.05))
XCTAssertTrue(store.items.isEmpty)
XCTAssertEqual(settings.captureStatusMessage, "Skipped: Color items are ignored in capture settings.")
}
func testIgnoredCodeKindDoesNotCaptureSnippet() throws {
let settings = SettingsModel(defaults: makeTestDefaults())
settings.ignoredItemKindsRaw = [ClipboardItemKind.code.rawValue]
let (store, cacheService, _) = makeStoreCacheAndBaseURL(settings: settings)
let monitor = ClipboardMonitorService(store: store, cacheService: cacheService, settings: settings)
let snippet = "const title = \"ClipBored\";\nreturn title.toUpperCase();"
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
XCTAssertTrue(pasteboard.setString(snippet, forType: .string))
monitor.pollNowAndWait()
RunLoop.main.run(until: Date().addingTimeInterval(0.05))
XCTAssertTrue(store.items.isEmpty)
XCTAssertEqual(settings.captureStatusMessage, "Skipped: Code items are ignored in capture settings.")
}
func testIgnoredPDFKindDoesNotWriteAttachmentFiles() throws {
let settings = SettingsModel(defaults: makeTestDefaults())
settings.ignoredItemKindsRaw = [ClipboardItemKind.pdf.rawValue]
@@ -630,6 +754,25 @@ final class ClipboardMonitorServiceTests: XCTestCase {
XCTAssertEqual(settings.captureStatusMessage, "Skipped: Audio items are ignored in capture settings.")
}
func testIgnoredVideoKindDoesNotWriteAttachmentFiles() throws {
let settings = SettingsModel(defaults: makeTestDefaults())
settings.ignoredItemKindsRaw = [ClipboardItemKind.video.rawValue]
let (store, cacheService, baseURL) = makeStoreCacheAndBaseURL(settings: settings)
let monitor = ClipboardMonitorService(store: store, cacheService: cacheService, settings: settings)
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
XCTAssertTrue(pasteboard.setData(Data([0, 0, 0, 24, 102, 116, 121, 112]), forType: VideoPayload.pasteboardTypes[0]))
monitor.pollNowAndWait()
RunLoop.main.run(until: Date().addingTimeInterval(0.05))
cacheService.flushForTesting()
XCTAssertTrue(store.items.isEmpty)
XCTAssertTrue(try attachmentFileURLs(in: baseURL).isEmpty)
XCTAssertEqual(settings.captureStatusMessage, "Skipped: Video items are ignored in capture settings.")
}
func testIgnoredRichTextKindDoesNotWriteHTMLAttachmentFiles() throws {
let settings = SettingsModel(defaults: makeTestDefaults())
settings.ignoredItemKindsRaw = [ClipboardItemKind.richText.rawValue]

View File

@@ -156,6 +156,8 @@ final class ClipboardPanelControllerTests: XCTestCase {
XCTAssertEqual(ClipboardPanelController.collectionShortcutMode(forKeyCode: 22, modifiers: [.command, .option]), .files)
XCTAssertEqual(ClipboardPanelController.collectionShortcutMode(forKeyCode: 26, modifiers: [.command, .option]), .pinned)
XCTAssertEqual(ClipboardPanelController.collectionShortcutMode(forKeyCode: 28, modifiers: [.command, .option]), .audio)
XCTAssertEqual(ClipboardPanelController.collectionShortcutMode(forKeyCode: 25, modifiers: [.command, .option]), .colors)
XCTAssertEqual(ClipboardPanelController.collectionShortcutMode(forKeyCode: 29, modifiers: [.command, .option]), .code)
}
func testCollectionShortcutsRequireCommandOptionSoQuickPasteKeepsCommandNumbers() {

View File

@@ -39,6 +39,141 @@ final class ClipboardPanelViewModelTests: XCTestCase {
XCTAssertEqual(pinnedOnly.map(\.payload), ["four", "one"])
}
func testComputeVisibleItemsFiltersColorClipsAndStructuredType() {
let settings = makeSettings()
let store = makeStore(settings: settings)
let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: ClipboardCacheService())
let color = ClipboardItem(
id: UUID(),
kind: .color,
displayText: "#0A84FF",
payload: "#0A84FF",
payloadHash: hash("#0A84FF"),
createdAt: Date(timeIntervalSince1970: 100),
lastUsedAt: Date(timeIntervalSince1970: 100),
useCount: 0,
sourceApp: "Design Tool",
imagePath: nil,
thumbnailPath: nil
)
let text = ClipboardItem(
id: UUID(),
kind: .text,
displayText: "Color note",
payload: "Color note",
payloadHash: hash("Color note"),
createdAt: Date(timeIntervalSince1970: 200),
lastUsedAt: Date(timeIntervalSince1970: 200),
useCount: 0,
sourceApp: "Notes",
imagePath: nil,
thumbnailPath: nil
)
XCTAssertEqual(
viewModel.computeVisibleItems(from: [text, color], query: "", sortMode: .colors).map(\.payload),
["#0A84FF"]
)
XCTAssertEqual(
viewModel.computeVisibleItems(from: [text, color], query: "type:swatch", sortMode: .mostRecent).map(\.payload),
["#0A84FF"]
)
XCTAssertEqual(
viewModel.computeVisibleItems(from: [text, color], query: "hex 0a84ff", sortMode: .mostRecent).map(\.payload),
["#0A84FF"]
)
}
func testComputeVisibleItemsFiltersCodeSnippetsAndKeepsThemInTextView() {
let settings = makeSettings()
let store = makeStore(settings: settings)
let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: ClipboardCacheService())
let code = ClipboardItem(
id: UUID(),
kind: .code,
displayText: "Swift Snippet",
payload: "func greet(name: String) -> String {\n return \"Hi \\(name)\"\n}",
payloadHash: hash("swift-snippet"),
createdAt: Date(timeIntervalSince1970: 200),
lastUsedAt: Date(timeIntervalSince1970: 200),
useCount: 0,
sourceApp: "Xcode",
imagePath: nil,
thumbnailPath: nil
)
let text = ClipboardItem(
id: UUID(),
kind: .text,
displayText: "Meeting note",
payload: "Meeting note",
payloadHash: hash("Meeting note"),
createdAt: Date(timeIntervalSince1970: 100),
lastUsedAt: Date(timeIntervalSince1970: 100),
useCount: 0,
sourceApp: "Notes",
imagePath: nil,
thumbnailPath: nil
)
XCTAssertEqual(
viewModel.computeVisibleItems(from: [code, text], query: "", sortMode: .code).map(\.kind),
[.code]
)
XCTAssertEqual(
viewModel.computeVisibleItems(from: [code, text], query: "", sortMode: .text).map(\.kind),
[.code, .text]
)
XCTAssertEqual(
viewModel.computeVisibleItems(from: [code, text], query: "type:snippet greet", sortMode: .mostRecent).map(\.kind),
[.code]
)
}
func testComputeVisibleItemsFiltersVideoClipsAndStructuredType() {
let settings = makeSettings()
let store = makeStore(settings: settings)
let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: ClipboardCacheService())
let video = ClipboardItem(
id: UUID(),
kind: .video,
displayText: "Video (12 KB)",
payload: "/tmp/clip.mp4",
payloadHash: hash("clip-video"),
createdAt: Date(timeIntervalSince1970: 200),
lastUsedAt: Date(timeIntervalSince1970: 200),
useCount: 0,
sourceApp: "QuickTime Player",
imagePath: nil,
thumbnailPath: nil
)
let image = ClipboardItem(
id: UUID(),
kind: .image,
displayText: "Image",
payload: "/tmp/image.png",
payloadHash: hash("image"),
createdAt: Date(timeIntervalSince1970: 100),
lastUsedAt: Date(timeIntervalSince1970: 100),
useCount: 0,
sourceApp: "Preview",
imagePath: nil,
thumbnailPath: nil
)
XCTAssertEqual(
viewModel.computeVisibleItems(from: [video, image], query: "", sortMode: .videos).map(\.kind),
[.video]
)
XCTAssertEqual(
viewModel.computeVisibleItems(from: [video, image], query: "type:movie", sortMode: .mostRecent).map(\.kind),
[.video]
)
XCTAssertEqual(
viewModel.computeVisibleItems(from: [video, image], query: "mp4", sortMode: .mostRecent).map(\.kind),
[.video]
)
}
func testSearchMatchesIndependentTokensCaseInsensitively() {
let settings = makeSettings()
let store = makeStore(settings: settings)
@@ -739,6 +874,35 @@ final class ClipboardPanelViewModelTests: XCTestCase {
XCTAssertEqual(viewModel.stackCount, 0)
}
func testStackCopiesQueuedClipsAsPlainTextBatchAndConsumesThem() {
let settings = makeSettings()
let cacheService = makeCacheService()
let store = makeStore(settings: settings, cacheService: cacheService)
let first = makeTextItem("first reusable stack clip", createdAt: Date(timeIntervalSince1970: 100))
let second = makeTextItem("second reusable stack clip", createdAt: Date(timeIntervalSince1970: 200))
store.upsert(first)
store.upsert(second)
store.flushPersistenceForTesting()
let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: cacheService)
waitForVisibleItems(in: viewModel, count: 2)
NSPasteboard.general.clearContents()
viewModel.selectItem(at: 1)
viewModel.toggleSelectedStackMembership()
viewModel.selectItem(at: 0)
viewModel.toggleSelectedStackMembership()
viewModel.copyStackAsText()
store.flushPersistenceForTesting()
XCTAssertEqual(NSPasteboard.general.string(forType: .string), "first reusable stack clip\n\nsecond reusable stack clip")
XCTAssertEqual(viewModel.statusMessage, "Copied 2 Stack clips as Text")
XCTAssertEqual(viewModel.stackCount, 0)
XCTAssertEqual(store.items.first(where: { $0.id == first.id })?.useCount, 1)
XCTAssertEqual(store.items.first(where: { $0.id == second.id })?.useCount, 1)
}
func testStackToggleAndClearUpdateCount() {
let settings = makeSettings()
let cacheService = makeCacheService()
@@ -765,6 +929,36 @@ final class ClipboardPanelViewModelTests: XCTestCase {
XCTAssertEqual(viewModel.statusMessage, "Cleared Stack")
}
func testAddVisibleItemsToStackQueuesFilteredShelfInOrder() {
let settings = makeSettings()
let cacheService = makeCacheService()
let store = makeStore(settings: settings, cacheService: cacheService)
let olderNeedle = makeTextItem("older visible needle", createdAt: Date(timeIntervalSince1970: 100))
let hidden = makeTextItem("hidden meeting note", createdAt: Date(timeIntervalSince1970: 200))
let newerNeedle = makeTextItem("newer visible needle", createdAt: Date(timeIntervalSince1970: 300))
store.upsert(olderNeedle)
store.upsert(hidden)
store.upsert(newerNeedle)
store.flushPersistenceForTesting()
let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: cacheService)
waitForVisibleItems(in: viewModel, count: 3)
viewModel.searchText = "needle"
XCTAssertEqual(viewModel.visibleItems.map(\.id), [newerNeedle.id, olderNeedle.id])
viewModel.addVisibleItemsToStack()
XCTAssertEqual(viewModel.stackCount, 2)
XCTAssertEqual(viewModel.statusMessage, "Added 2 clips to Stack")
viewModel.selectStack()
XCTAssertEqual(viewModel.visibleItems.map(\.payload), ["newer visible needle", "older visible needle"])
viewModel.addVisibleItemsToStack()
XCTAssertEqual(viewModel.stackCount, 2)
XCTAssertEqual(viewModel.statusMessage, "Visible clips are already in Stack")
}
func testIgnoreSelectedSourceAppAddsPreciseCaptureRule() {
let settings = makeSettings()
let cacheService = makeCacheService()

View File

@@ -47,6 +47,25 @@ final class ClipboardPanelViewTests: XCTestCase {
XCTAssertTrue(view.isSearchFieldEditing)
}
func testShelfChromeKeepsSearchAndCollectionsInOneCompactRow() {
let fixture = makePanelFixture()
fixture.store.upsert(makeTextItem("Compact shelf chrome", store: fixture.store))
drainMainQueue()
fixture.window.contentView?.layoutSubtreeIfNeeded()
XCTAssertEqual(fixture.view.debugShelfChromeRowCount, 1)
XCTAssertTrue(fixture.view.debugShelfChromeContainsSearchAndCollections)
XCTAssertEqual(fixture.view.debugSearchFieldWidth, 34, accuracy: 0.5)
XCTAssertEqual(fixture.view.debugSearchFieldPlaceholderText, "")
fixture.view.debugSetSearchFieldText("type:text")
drainMainQueue()
fixture.window.contentView?.layoutSubtreeIfNeeded()
XCTAssertEqual(fixture.view.debugSearchFieldWidth, 300, accuracy: 0.5)
XCTAssertEqual(fixture.view.debugSearchFieldPlaceholderText, "Search clips")
}
func testCapturedTextItemCreatesVisibleCardDocument() {
let fixture = makePanelFixture()
let item = makeTextItem("Bruh it said copied text but it does not appear.", store: fixture.store)
@@ -227,11 +246,11 @@ final class ClipboardPanelViewTests: XCTestCase {
XCTAssertEqual(
fixture.view.debugCollectionTitles,
["Clipboard", "Frequent", "Text", "Links", "Images", "Audio", "Files", "Pinned"]
["Clipboard", "Frequent", "Text", "Links", "Images", "Colors", "Audio", "Videos", "Files", "Pinned", "Code"]
)
XCTAssertEqual(
fixture.view.debugCollectionLeadingSymbols,
["doc.on.clipboard", "chart.bar.fill", "text.alignleft", "link", "photo", "music.note", "doc.fill", "pin.fill"]
["doc.on.clipboard", "chart.bar.fill", "text.alignleft", "link", "photo", "paintpalette", "music.note", "film", "doc.fill", "pin.fill", "chevron.left.forwardslash.chevron.right"]
)
XCTAssertEqual(fixture.view.debugSelectedCollectionTitle, "Clipboard")
@@ -481,7 +500,7 @@ final class ClipboardPanelViewTests: XCTestCase {
XCTAssertEqual(fixture.view.debugFirstCardHeaderBadgeFrame.maxY, 220, accuracy: 0.5)
XCTAssertEqual(
fixture.view.debugFirstCardMenuTitles,
["Paste", "Copy", "Paste Plain Text", "Copy Plain Text", "Rename...", "Add to Stack", "Quick Look", "Pin", "Add to Collection", "Capture Rules", "-", "Open", "Reveal in Finder", "-", "Delete"]
["Paste", "Copy", "Paste Plain Text", "Copy Plain Text", "Rename...", "Add to Stack", "Add Visible Clips to Stack", "Quick Look", "Pin", "Add to Collection", "Capture Rules", "-", "Open", "Reveal in Finder", "-", "Delete"]
)
}
@@ -632,10 +651,24 @@ final class ClipboardPanelViewTests: XCTestCase {
func testCardHeaderUsesKindSymbolBadgeWhenSourceIconIsUnavailable() {
let fixture = makePanelFixture()
fixture.store.upsert(makeItem(kind: .url, text: "https://example.com", store: fixture.store))
var item = makeItem(kind: .url, text: "https://example.com", store: fixture.store)
item.sourceApp = nil
fixture.store.upsert(item)
drainMainQueue()
XCTAssertEqual(fixture.view.debugCardHeaderBadgeSymbols, ["link"])
XCTAssertEqual(fixture.view.debugCardHeaderBadgeTexts, [""])
}
func testCardHeaderUsesSourceMonogramWhenAppIconIsUnavailable() {
let fixture = makePanelFixture()
var item = makeTextItem("Copied from a named app", store: fixture.store)
item.sourceApp = "Arc Browser"
fixture.store.upsert(item)
drainMainQueue()
XCTAssertEqual(fixture.view.debugCardHeaderBadgeTexts, ["AB"])
}
func testCollectionRailShowsLiveCounts() {
@@ -645,17 +678,25 @@ final class ClipboardPanelViewTests: XCTestCase {
let rich = makeItem(kind: .richText, text: "Rich note", store: fixture.store)
let link = makeItem(kind: .url, text: "https://example.com/releases", store: fixture.store)
let image = makeItem(kind: .image, text: "image payload", store: fixture.store)
let color = makeItem(kind: .color, displayText: "#0A84FF", payload: "#0A84FF", store: fixture.store)
let audio = makeItem(kind: .audio, text: "audio payload", store: fixture.store)
let video = makeItem(kind: .video, text: "/tmp/movie.mp4", store: fixture.store)
let file = makeItem(kind: .file, text: "/tmp/report.pdf", store: fixture.store)
let code = makeItem(
kind: .code,
displayText: "Swift Snippet",
payload: "func greet(name: String) -> String {\n return \"Hi \\(name)\"\n}",
store: fixture.store
)
[pinned, rich, link, image, audio, file].forEach {
[pinned, rich, link, image, color, audio, video, file, code].forEach {
fixture.store.upsert($0)
drainMainQueue()
}
XCTAssertEqual(fixture.viewModel.visibleItems.count, 6)
XCTAssertEqual(ClipboardSortMode.allCases.map { fixture.viewModel.collectionCount(for: $0) }, [6, 6, 2, 1, 1, 1, 1, 1])
XCTAssertEqual(fixture.view.debugCollectionCounts, [6, 6, 2, 1, 1, 1, 1, 1])
XCTAssertEqual(fixture.viewModel.visibleItems.count, 9)
XCTAssertEqual(ClipboardSortMode.allCases.map { fixture.viewModel.collectionCount(for: $0) }, [9, 9, 3, 1, 1, 1, 1, 1, 1, 1, 1])
XCTAssertEqual(fixture.view.debugCollectionCounts, [9, 9, 3, 1, 1, 1, 1, 1, 1, 1, 1])
XCTAssertEqual(fixture.view.debugCollectionCountLabelHiddenStates, Array(repeating: false, count: ClipboardSortMode.allCases.count))
}
@@ -859,7 +900,7 @@ final class ClipboardPanelViewTests: XCTestCase {
XCTAssertEqual(
fixture.view.debugFirstCardMenuTitles,
["Paste", "Copy", "Rename...", "Add to Stack", "Edit", "Quick Look", "Pin", "Add to Collection", "Capture Rules", "-", "Open", "Reveal in Finder", "-", "Delete"]
["Paste", "Copy", "Rename...", "Add to Stack", "Add Visible Clips to Stack", "Edit", "Quick Look", "Pin", "Add to Collection", "Capture Rules", "-", "Open", "Reveal in Finder", "-", "Delete"]
)
XCTAssertEqual(
fixture.view.debugFirstCardCollectionMenuTitles,
@@ -893,7 +934,7 @@ final class ClipboardPanelViewTests: XCTestCase {
XCTAssertEqual(
fixture.view.debugFirstCardMenuTitles,
["Paste", "Copy", "Show in Clipboard", "Rename...", "Add to Stack", "Edit", "Quick Look", "Pin", "Add to Collection", "Capture Rules", "-", "Open", "Reveal in Finder", "-", "Delete"]
["Paste", "Copy", "Show in Clipboard", "Rename...", "Add to Stack", "Add Visible Clips to Stack", "Edit", "Quick Look", "Pin", "Add to Collection", "Capture Rules", "-", "Open", "Reveal in Finder", "-", "Delete"]
)
fixture.view.debugShowFirstCardInClipboard()
@@ -913,7 +954,7 @@ final class ClipboardPanelViewTests: XCTestCase {
XCTAssertEqual(
fixture.view.debugFirstCardMenuTitles,
["Paste", "Copy", "Paste Plain Text", "Copy Plain Text", "Rename...", "Add to Stack", "Quick Look", "Pin", "Add to Collection", "Capture Rules", "-", "Open", "Reveal in Finder", "-", "Delete"]
["Paste", "Copy", "Paste Plain Text", "Copy Plain Text", "Rename...", "Add to Stack", "Add Visible Clips to Stack", "Quick Look", "Pin", "Add to Collection", "Capture Rules", "-", "Open", "Reveal in Finder", "-", "Delete"]
)
XCTAssertEqual(
fixture.view.debugFirstCardCaptureRuleMenuTitles,
@@ -933,7 +974,7 @@ final class ClipboardPanelViewTests: XCTestCase {
XCTAssertEqual(
fixture.view.debugFirstCardMenuTitles,
["Paste", "Copy", "Rename...", "Remove from Stack", "Paste Stack Next", "Copy Stack Next", "Clear Stack", "Edit", "Quick Look", "Pin", "Add to Collection", "Capture Rules", "-", "Open", "Reveal in Finder", "-", "Delete"]
["Paste", "Copy", "Rename...", "Remove from Stack", "Add Visible Clips to Stack", "Paste Stack Next", "Copy Stack Next", "Paste Stack as Text", "Copy Stack as Text", "Clear Stack", "Edit", "Quick Look", "Pin", "Add to Collection", "Capture Rules", "-", "Open", "Reveal in Finder", "-", "Delete"]
)
XCTAssertEqual(fixture.view.debugFirstCardVisibleActionLabels, ["Paste", "Copy", "Pin", "Collect", "Edit", "Preview", "Delete"])
XCTAssertEqual(fixture.view.debugStackCornerLabels, ["Remove from Stack"])
@@ -1006,6 +1047,51 @@ final class ClipboardPanelViewTests: XCTestCase {
XCTAssertEqual(fixture.view.debugStackChipCount, 0)
}
func testStackChipMenuAddsVisibleShelfToQueue() {
let fixture = makePanelFixture()
var older = makeTextItem("Older batch stack item", store: fixture.store)
older.createdAt = Date(timeIntervalSince1970: 100)
older.lastUsedAt = older.createdAt
var middle = makeTextItem("Middle batch stack item", store: fixture.store)
middle.createdAt = Date(timeIntervalSince1970: 200)
middle.lastUsedAt = middle.createdAt
var newest = makeTextItem("Newest batch stack item", store: fixture.store)
newest.createdAt = Date(timeIntervalSince1970: 300)
newest.lastUsedAt = newest.createdAt
fixture.store.upsert(older)
fixture.store.upsert(middle)
fixture.store.upsert(newest)
drainMainQueue()
fixture.window.contentView?.layoutSubtreeIfNeeded()
fixture.viewModel.selectItem(at: 0)
fixture.viewModel.toggleSelectedStackMembership()
drainMainQueue()
fixture.window.contentView?.layoutSubtreeIfNeeded()
XCTAssertTrue(fixture.view.debugStackChipIsVisible)
XCTAssertEqual(fixture.view.debugStackChipCount, 1)
XCTAssertEqual(
fixture.view.debugStackChipMenuTitles,
["Add Visible Clips to Stack", "Paste Stack Next", "Copy Stack Next", "Paste Stack as Text", "Copy Stack as Text", "Clear Stack"]
)
fixture.view.debugAddVisibleClipsToStackFromStackChip()
drainMainQueue()
fixture.window.contentView?.layoutSubtreeIfNeeded()
XCTAssertEqual(fixture.view.debugStackChipCount, 3)
XCTAssertEqual(fixture.view.debugStatusText, "Added 2 clips to Stack")
fixture.view.debugPressStackChip()
drainMainQueue()
XCTAssertEqual(
fixture.viewModel.visibleItems.map(\.payload),
["Newest batch stack item", "Middle batch stack item", "Older batch stack item"]
)
}
func testCollectionMenuOffersExistingCustomCollections() {
let fixture = makePanelFixture()
var existing = makeTextItem("Existing client note", store: fixture.store)
@@ -1059,7 +1145,25 @@ final class ClipboardPanelViewTests: XCTestCase {
XCTAssertEqual(fixture.view.debugCardAccessibilityLabels, ["Link: Release notes"])
XCTAssertEqual(fixture.view.debugCardPreviewSummaries, ["Release notes|example.com/releases/v1|example.com"])
XCTAssertEqual(fixture.view.debugCardPreviewStyles, ["link-preview"])
XCTAssertEqual(fixture.view.debugCardPreviewStyles, ["link-site-preview"])
}
func testPlainURLCardsDeriveReadableTitleFromPath() {
let fixture = makePanelFixture()
let item = makeItem(
kind: .url,
displayText: "https://www.example.com/articles/weekly-design-review?utm_source=copy",
payload: "https://www.example.com/articles/weekly-design-review?utm_source=copy",
store: fixture.store
)
fixture.store.upsert(item)
drainMainQueue()
fixture.window.contentView?.layoutSubtreeIfNeeded()
XCTAssertEqual(fixture.view.debugCardAccessibilityLabels, ["Link: Weekly Design Review"])
XCTAssertEqual(fixture.view.debugCardPreviewSummaries, ["Weekly Design Review|example.com/articles/weekly-design-review|example.com"])
XCTAssertEqual(fixture.view.debugCardPreviewStyles, ["link-site-preview"])
}
func testLinkCardsUseMediaPreviewWhenThumbnailExists() throws {
@@ -1171,7 +1275,7 @@ final class ClipboardPanelViewTests: XCTestCase {
XCTAssertEqual(fixture.view.debugCardAccessibilityLabels, ["File: 2 files"])
XCTAssertEqual(fixture.view.debugCardPreviewSummaries, ["2 files|\(directory.path)|2 files"])
XCTAssertEqual(fixture.view.debugCardPreviewStyles, ["file-preview"])
XCTAssertEqual(fixture.view.debugCardPreviewStyles, ["multi-file-preview"])
}
func testExistingFileCardsUseFullBleedMediaPreviewLayout() throws {
@@ -1263,14 +1367,108 @@ final class ClipboardPanelViewTests: XCTestCase {
XCTAssertEqual(fixture.view.debugCardPreviewStyles, ["audio-preview"])
}
func testVideoCardsUseFilmPreview() {
let fixture = makePanelFixture()
let item = makeItem(
kind: .video,
displayText: "Video (24 KB)",
payload: "/tmp/clipbored-video.mp4",
store: fixture.store
)
fixture.store.upsert(item)
fixture.viewModel.sortMode = .videos
drainMainQueue()
fixture.window.contentView?.layoutSubtreeIfNeeded()
XCTAssertEqual(fixture.view.debugCardAccessibilityLabels, ["Video: Video (24 KB)"])
XCTAssertEqual(fixture.view.debugCardPreviewSummaries, ["Video (24 KB)|Video clip|MP4"])
XCTAssertEqual(fixture.view.debugCardPreviewStyles, ["video-preview"])
}
func testVideoCardsUseMediaPreviewWhenThumbnailExists() throws {
let cacheService = ClipboardCacheService(
baseURL: makeTempDirectory(),
encryptionService: ClipboardEncryptionService(keyProvider: { nil }),
videoThumbnailProvider: { _ in self.sampleImage() }
)
let fixture = makePanelFixture(cacheService: cacheService)
let id = UUID()
let path = try XCTUnwrap(cacheService.cacheVideo(Data([0, 1, 2, 3]), id: id, fileExtension: "mp4"))
let item = ClipboardItem(
id: id,
kind: .video,
displayText: "Video (24 KB)",
payload: path,
payloadHash: fixture.store.hashString("video-thumbnail"),
createdAt: Date(),
lastUsedAt: Date(),
useCount: 0,
sourceApp: "QuickTime Player",
imagePath: nil,
thumbnailPath: nil
)
fixture.store.upsert(item)
fixture.viewModel.sortMode = .videos
drainMainQueue()
fixture.window.contentView?.layoutSubtreeIfNeeded()
XCTAssertEqual(fixture.view.debugCardAccessibilityLabels, ["Video: Video (24 KB)"])
XCTAssertEqual(fixture.view.debugCardPreviewSummaries, ["Video (24 KB)|Video clip|MP4"])
XCTAssertEqual(fixture.view.debugCardPreviewStyles, ["video-media-preview"])
}
func testColorCardsUseSwatchPreview() {
let fixture = makePanelFixture()
let item = makeItem(
kind: .color,
displayText: "#0A84FF",
payload: "#0A84FF",
store: fixture.store
)
fixture.store.upsert(item)
fixture.viewModel.sortMode = .colors
drainMainQueue()
fixture.window.contentView?.layoutSubtreeIfNeeded()
XCTAssertEqual(fixture.view.debugCardAccessibilityLabels, ["Color: #0A84FF"])
XCTAssertEqual(fixture.view.debugCardPreviewSummaries, ["#0A84FF|RGB 10 132 255|Color"])
XCTAssertEqual(fixture.view.debugCardPreviewStyles, ["color-preview"])
}
func testCodeCardsUseMonospaceSnippetPreview() {
let fixture = makePanelFixture()
let item = makeItem(
kind: .code,
displayText: "Swift Snippet",
payload: "func greet(name: String) -> String {\n return \"Hi \\(name)\"\n}",
store: fixture.store
)
fixture.store.upsert(item)
fixture.viewModel.sortMode = .code
drainMainQueue()
fixture.window.contentView?.layoutSubtreeIfNeeded()
XCTAssertEqual(fixture.view.debugCardAccessibilityLabels, ["Code: Swift Snippet"])
XCTAssertEqual(
fixture.view.debugCardPreviewSummaries,
["Swift Snippet|func greet(name: String) -> String { return \"Hi \\(name)\" }|Swift"]
)
XCTAssertEqual(fixture.view.debugCardPreviewStyles, ["code-preview"])
XCTAssertEqual(fixture.view.debugFirstCardVisibleActionLabels, ["Paste", "Copy", "Pin", "Collect", "Edit", "Preview", "Delete"])
}
private func makePanelWithPanelView() -> (NSWindow, ClipboardPanelView) {
let fixture = makePanelFixture()
return (fixture.window, fixture.view)
}
private func makePanelFixture() -> PanelFixture {
private func makePanelFixture(cacheService: ClipboardCacheService? = nil) -> PanelFixture {
let settings = makeSettings()
let cacheService = ClipboardCacheService()
let cacheService = cacheService ?? ClipboardCacheService()
let store = makeStore(settings: settings, cacheService: cacheService)
let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: cacheService)
let previewProbe = PreviewProbe()

View File

@@ -32,6 +32,27 @@ final class PasteActionServiceTests: XCTestCase {
XCTAssertEqual(NSPasteboard.general.string(forType: .string), "Hello")
}
func testCopyWritesCodeSnippetAsPlainString() {
let service = PasteActionService()
let snippet = "func greet(name: String) -> String {\n return \"Hi \\(name)\"\n}"
let item = ClipboardItem(
id: UUID(),
kind: .code,
displayText: "Swift Snippet",
payload: snippet,
payloadHash: "hash",
createdAt: Date(),
lastUsedAt: Date(),
useCount: 0,
sourceApp: "Xcode",
imagePath: nil,
thumbnailPath: nil
)
XCTAssertEqual(service.copy(item), .copied)
XCTAssertEqual(NSPasteboard.general.string(forType: .string), snippet)
}
func testPasteboardWritersExposeTextForDragOut() {
let service = PasteActionService()
let item = ClipboardItem(
@@ -83,6 +104,30 @@ final class PasteActionServiceTests: XCTestCase {
)
}
func testCopyWritesColorToPasteboardWithHexFallback() throws {
let service = PasteActionService()
let item = ClipboardItem(
id: UUID(),
kind: .color,
displayText: "#0A84FF",
payload: "#0A84FF",
payloadHash: "hash",
createdAt: Date(),
lastUsedAt: Date(),
useCount: 0,
sourceApp: nil,
imagePath: nil,
thumbnailPath: nil
)
XCTAssertEqual(service.copy(item), .copied)
let restored = try XCTUnwrap(NSColor(from: NSPasteboard.general))
XCTAssertEqual(ColorPayload.hexString(from: restored), "#0A84FF")
XCTAssertEqual(NSPasteboard.general.string(forType: .string), "#0A84FF")
XCTAssertEqual(service.copyPlainText(item), .copiedPlainText)
XCTAssertEqual(NSPasteboard.general.string(forType: .string), "#0A84FF")
}
func testPasteWithoutTargetCopiesWithoutRequestingAutomaticPaste() {
let service = PasteActionService()
let item = ClipboardItem(
@@ -489,6 +534,32 @@ final class PasteActionServiceTests: XCTestCase {
XCTAssertEqual(NSPasteboard.general.data(forType: .sound), audioData)
}
func testCopyWritesVideoData() throws {
let directory = try makeTempDirectory()
let cacheService = ClipboardCacheService(baseURL: directory, encryptionService: fixedEncryptionService())
let videoData = Data([0, 0, 0, 24, 102, 116, 121, 112, 109, 112, 52, 50])
let path = try XCTUnwrap(cacheService.cacheVideo(videoData, id: UUID(), fileExtension: "mp4"))
let service = PasteActionService(cacheService: cacheService)
let item = ClipboardItem(
id: UUID(),
kind: .video,
displayText: "Video",
payload: path,
payloadHash: "hash",
createdAt: Date(),
lastUsedAt: Date(),
useCount: 0,
sourceApp: nil,
imagePath: nil,
thumbnailPath: nil
)
XCTAssertEqual(service.copy(item), .copied)
XCTAssertEqual(NSPasteboard.general.data(forType: VideoPayload.pasteboardTypes[0]), videoData)
XCTAssertEqual(service.copyPlainText(item), .copiedPlainText)
XCTAssertEqual(NSPasteboard.general.string(forType: .string), "Video")
}
func testCopyWritesEncryptedPDFData() throws {
let directory = try makeTempDirectory()
let cacheService = ClipboardCacheService(baseURL: directory, encryptionService: fixedEncryptionService())