WIP: add video clip support
This commit is contained in:
@@ -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.
|
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.
|
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.
|
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.
|
8. Copy a movie or video clip and confirm it appears as Video.
|
||||||
9. Copy one Finder file and confirm it appears as a File.
|
9. Copy a PDF or PDF selection and confirm it appears as a PDF.
|
||||||
10. Copy multiple Finder files at once and confirm they appear as one grouped File item with the file count.
|
10. Copy one Finder file and confirm it appears as a File.
|
||||||
11. Copy formatted text from a browser or Mail message and confirm it appears as Rich Text rather than flattened plain text.
|
11. Copy multiple Finder files at once and confirm they appear as one grouped File item with the file count.
|
||||||
12. Disable Images, Audio, Rich Text, PDFs, or Files in Settings > Capture, copy that type again, and confirm it is not captured.
|
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
|
## Panel
|
||||||
|
|
||||||
@@ -68,7 +69,8 @@ Use this checklist before a release or after changes to panel, pasteboard, setti
|
|||||||
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.
|
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.
|
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.
|
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. 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.
|
38. Copy a video/movie clip and confirm it appears as a Video card, 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.
|
||||||
|
|
||||||
## Copy And Paste
|
## Copy And Paste
|
||||||
|
|
||||||
@@ -76,11 +78,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.
|
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.
|
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.
|
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.
|
5. Select a Video item and paste into an app that accepts movie 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.
|
6. Select a PDF item and paste into Preview, Finder, or an app that accepts PDF pasteboard data.
|
||||||
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.
|
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. Without Accessibility permission, confirm paste actions copy and show the permission fallback status.
|
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. With Accessibility permission granted, confirm paste returns focus to the previous app and inserts the selected item.
|
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
|
## Settings
|
||||||
|
|
||||||
@@ -97,11 +100,11 @@ Use this checklist before a release or after changes to panel, pasteboard, setti
|
|||||||
1. Open the data folder from Settings > Data.
|
1. Open the data folder from Settings > Data.
|
||||||
2. Confirm `history.sqlite` exists after capture.
|
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.
|
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.
|
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`.
|
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.
|
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.
|
10. Confirm quitting with `Clear history on quit` enabled removes history and app-managed cache/attachment files.
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ enum ClipboardItemKind: Int {
|
|||||||
case audio
|
case audio
|
||||||
case color
|
case color
|
||||||
case code
|
case code
|
||||||
|
case video
|
||||||
|
|
||||||
var displayName: String {
|
var displayName: String {
|
||||||
switch self {
|
switch self {
|
||||||
@@ -24,6 +25,7 @@ enum ClipboardItemKind: Int {
|
|||||||
case .audio: return "audio"
|
case .audio: return "audio"
|
||||||
case .color: return "color"
|
case .color: return "color"
|
||||||
case .code: return "code"
|
case .code: return "code"
|
||||||
|
case .video: return "video"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -31,7 +33,7 @@ enum ClipboardItemKind: Int {
|
|||||||
extension ClipboardItemKind {
|
extension ClipboardItemKind {
|
||||||
var canOpen: Bool {
|
var canOpen: Bool {
|
||||||
switch self {
|
switch self {
|
||||||
case .url, .file, .image, .pdf, .audio:
|
case .url, .file, .image, .pdf, .audio, .video:
|
||||||
return true
|
return true
|
||||||
case .text, .richText, .unknown, .color, .code:
|
case .text, .richText, .unknown, .color, .code:
|
||||||
return false
|
return false
|
||||||
@@ -40,7 +42,7 @@ extension ClipboardItemKind {
|
|||||||
|
|
||||||
var canReveal: Bool {
|
var canReveal: Bool {
|
||||||
switch self {
|
switch self {
|
||||||
case .file, .image, .pdf, .audio:
|
case .file, .image, .pdf, .audio, .video:
|
||||||
return true
|
return true
|
||||||
case .text, .richText, .unknown, .url, .color, .code:
|
case .text, .richText, .unknown, .url, .color, .code:
|
||||||
return false
|
return false
|
||||||
@@ -49,7 +51,7 @@ extension ClipboardItemKind {
|
|||||||
|
|
||||||
var hasManagedCacheReference: Bool {
|
var hasManagedCacheReference: Bool {
|
||||||
switch self {
|
switch self {
|
||||||
case .url, .image, .pdf, .audio, .richText:
|
case .url, .image, .pdf, .audio, .richText, .video:
|
||||||
return true
|
return true
|
||||||
case .text, .file, .unknown, .color, .code:
|
case .text, .file, .unknown, .color, .code:
|
||||||
return false
|
return false
|
||||||
@@ -68,8 +70,9 @@ enum ClipboardSortMode: Int {
|
|||||||
case audio
|
case audio
|
||||||
case colors
|
case colors
|
||||||
case code
|
case code
|
||||||
|
case videos
|
||||||
|
|
||||||
static let allCases: [ClipboardSortMode] = [.mostRecent, .mostUsed, .text, .links, .images, .colors, .audio, .files, .pinned, .code]
|
static let allCases: [ClipboardSortMode] = [.mostRecent, .mostUsed, .text, .links, .images, .colors, .audio, .videos, .files, .pinned, .code]
|
||||||
|
|
||||||
var title: String {
|
var title: String {
|
||||||
switch self {
|
switch self {
|
||||||
@@ -83,6 +86,7 @@ enum ClipboardSortMode: Int {
|
|||||||
case .audio: return "Audio"
|
case .audio: return "Audio"
|
||||||
case .colors: return "Colors"
|
case .colors: return "Colors"
|
||||||
case .code: return "Code"
|
case .code: return "Code"
|
||||||
|
case .videos: return "Videos"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -156,6 +160,7 @@ struct ClipboardItem {
|
|||||||
case .audio: return "audio sound"
|
case .audio: return "audio sound"
|
||||||
case .color: return "color swatch hex"
|
case .color: return "color swatch hex"
|
||||||
case .code: return "code snippet source programming"
|
case .code: return "code snippet source programming"
|
||||||
|
case .video: return "video movie mp4 quicktime"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
62
sources/clipbored/models/VideoPayload.swift
Normal file
62
sources/clipbored/models/VideoPayload.swift
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -55,6 +55,10 @@ final class ClipboardCacheService {
|
|||||||
cacheAttachment(data, id: id, fileExtension: "sound")
|
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? {
|
func cacheRichText(_ data: Data, id: UUID) -> String? {
|
||||||
cacheAttachment(data, id: id, fileExtension: "rtf")
|
cacheAttachment(data, id: id, fileExtension: "rtf")
|
||||||
}
|
}
|
||||||
@@ -105,7 +109,7 @@ final class ClipboardCacheService {
|
|||||||
case .file:
|
case .file:
|
||||||
return filePreviewThumbnail(for: item.payload)
|
return filePreviewThumbnail(for: item.payload)
|
||||||
|
|
||||||
case .text, .unknown, .audio, .richText, .color, .code:
|
case .text, .unknown, .audio, .richText, .color, .code, .video:
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -174,6 +178,9 @@ final class ClipboardCacheService {
|
|||||||
case .audio:
|
case .audio:
|
||||||
guard let data = data(for: item.payload) else { return nil }
|
guard let data = data(for: item.payload) else { return nil }
|
||||||
return writeTemporaryCopy(data: data, id: item.id, fileExtension: "sound")
|
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:
|
case .richText:
|
||||||
guard let data = data(for: item.payload) else { return nil }
|
guard let data = data(for: item.payload) else { return nil }
|
||||||
return writeTemporaryCopy(data: data, id: item.id, fileExtension: "rtf")
|
return writeTemporaryCopy(data: data, id: item.id, fileExtension: "rtf")
|
||||||
@@ -197,7 +204,7 @@ final class ClipboardCacheService {
|
|||||||
case .url:
|
case .url:
|
||||||
guard let data = webLocationData(for: item.payload) else { return nil }
|
guard let data = webLocationData(for: item.payload) else { return nil }
|
||||||
return writeTemporaryCopy(data: data, id: item.id, fileExtension: "webloc")
|
return writeTemporaryCopy(data: data, id: item.id, fileExtension: "webloc")
|
||||||
case .image, .pdf, .audio, .richText:
|
case .image, .pdf, .audio, .richText, .video:
|
||||||
return temporaryReadableURL(for: item)
|
return temporaryReadableURL(for: item)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -212,7 +219,7 @@ final class ClipboardCacheService {
|
|||||||
if let thumbnailPath = item.thumbnailPath {
|
if let thumbnailPath = item.thumbnailPath {
|
||||||
_ = self.data(for: 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)
|
_ = self.data(for: item.payload)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -230,7 +237,7 @@ final class ClipboardCacheService {
|
|||||||
try? self.fileManager.removeItem(atPath: path)
|
try? self.fileManager.removeItem(atPath: path)
|
||||||
self.thumbnailCache.removeObject(forKey: NSString(string: 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)
|
try? self.fileManager.removeItem(atPath: item.payload)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -222,6 +222,15 @@ final class ClipboardMonitorService {
|
|||||||
return pdfItem
|
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) {
|
if isIgnored(.audio), hasAudio(on: pasteboard) {
|
||||||
reportReadFailureStatus(ignoredKindMessage(.audio))
|
reportReadFailureStatus(ignoredKindMessage(.audio))
|
||||||
return nil
|
return nil
|
||||||
@@ -440,6 +449,10 @@ final class ClipboardMonitorService {
|
|||||||
pasteboard.data(forType: .sound) != nil
|
pasteboard.data(forType: .sound) != nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func hasVideo(on pasteboard: NSPasteboard) -> Bool {
|
||||||
|
VideoPayload.data(from: pasteboard) != nil
|
||||||
|
}
|
||||||
|
|
||||||
private func hasColor(on pasteboard: NSPasteboard) -> Bool {
|
private func hasColor(on pasteboard: NSPasteboard) -> Bool {
|
||||||
NSColor(from: pasteboard) != nil
|
NSColor(from: pasteboard) != nil
|
||||||
}
|
}
|
||||||
@@ -511,6 +524,32 @@ 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? {
|
private func itemFromColor(_ pasteboard: NSPasteboard, sourceApp: String?, sourceBundleId: String?) -> ClipboardItem? {
|
||||||
guard let color = NSColor(from: pasteboard) else { return nil }
|
guard let color = NSColor(from: pasteboard) else { return nil }
|
||||||
guard let hex = ColorPayload.hexString(from: color) else {
|
guard let hex = ColorPayload.hexString(from: color) else {
|
||||||
|
|||||||
@@ -130,6 +130,13 @@ final class PasteActionService {
|
|||||||
pasteboardItem.setString(dragLabel(for: item), forType: .string)
|
pasteboardItem.setString(dragLabel(for: item), forType: .string)
|
||||||
return [pasteboardItem]
|
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:
|
case .color:
|
||||||
guard let color = ColorPayload.color(from: item.payload) else { return [] }
|
guard let color = ColorPayload.color(from: item.payload) else { return [] }
|
||||||
return [color]
|
return [color]
|
||||||
@@ -188,6 +195,10 @@ final class PasteActionService {
|
|||||||
guard let data = cacheService.data(for: item.payload) else { return false }
|
guard let data = cacheService.data(for: item.payload) else { return false }
|
||||||
board.clearContents()
|
board.clearContents()
|
||||||
didWrite = board.setData(data, forType: .sound)
|
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:
|
case .color:
|
||||||
guard let color = ColorPayload.color(from: item.payload) else { return false }
|
guard let color = ColorPayload.color(from: item.payload) else { return false }
|
||||||
board.clearContents()
|
board.clearContents()
|
||||||
@@ -248,7 +259,7 @@ final class PasteActionService {
|
|||||||
return nonEmptyPlainText(richTextFallbackPlainString(for: item))
|
return nonEmptyPlainText(richTextFallbackPlainString(for: item))
|
||||||
case .image:
|
case .image:
|
||||||
return nonEmptyPlainText(item.ocrText) ?? nonEmptyPlainText(item.displayText)
|
return nonEmptyPlainText(item.ocrText) ?? nonEmptyPlainText(item.displayText)
|
||||||
case .pdf, .audio:
|
case .pdf, .audio, .video:
|
||||||
return nonEmptyPlainText(item.displayText)
|
return nonEmptyPlainText(item.displayText)
|
||||||
case .color:
|
case .color:
|
||||||
return nonEmptyPlainText(ColorPayload.displayHex(from: item.payload)) ?? nonEmptyPlainText(item.displayText)
|
return nonEmptyPlainText(ColorPayload.displayHex(from: item.payload)) ?? nonEmptyPlainText(item.displayText)
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ private enum ClipboardCollectionVisuals {
|
|||||||
case .files: return NSColor(calibratedRed: 0.11, green: 0.68, blue: 0.36, alpha: 1)
|
case .files: return NSColor(calibratedRed: 0.11, green: 0.68, blue: 0.36, alpha: 1)
|
||||||
case .pinned: return NSColor(calibratedRed: 0.94, green: 0.12, blue: 0.48, alpha: 1)
|
case .pinned: return NSColor(calibratedRed: 0.94, green: 0.12, blue: 0.48, alpha: 1)
|
||||||
case .code: return NSColor(calibratedRed: 0.25, green: 0.38, blue: 0.78, alpha: 1)
|
case .code: return NSColor(calibratedRed: 0.25, green: 0.38, blue: 0.78, alpha: 1)
|
||||||
|
case .videos: return NSColor(calibratedRed: 0.43, green: 0.32, blue: 0.94, alpha: 1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -644,6 +645,7 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
|
|||||||
case .files: return "Files"
|
case .files: return "Files"
|
||||||
case .pinned: return "Pinned"
|
case .pinned: return "Pinned"
|
||||||
case .code: return "Code"
|
case .code: return "Code"
|
||||||
|
case .videos: return "Videos"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -659,6 +661,7 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
|
|||||||
case .files: return "doc.fill"
|
case .files: return "doc.fill"
|
||||||
case .pinned: return "pin.fill"
|
case .pinned: return "pin.fill"
|
||||||
case .code: return "chevron.left.forwardslash.chevron.right"
|
case .code: return "chevron.left.forwardslash.chevron.right"
|
||||||
|
case .videos: return "film"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1159,6 +1162,8 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
|
|||||||
return ("No text clips yet", "Copied text and rich text appear here.")
|
return ("No text clips yet", "Copied text and rich text appear here.")
|
||||||
case .code:
|
case .code:
|
||||||
return ("No code snippets yet", "Copied code snippets appear here.")
|
return ("No code snippets yet", "Copied code snippets appear here.")
|
||||||
|
case .videos:
|
||||||
|
return ("No videos yet", "Copied movie and video clips appear here.")
|
||||||
case .files:
|
case .files:
|
||||||
return ("No files yet", "Copied files and PDFs appear here.")
|
return ("No files yet", "Copied files and PDFs appear here.")
|
||||||
case .audio:
|
case .audio:
|
||||||
@@ -1968,7 +1973,7 @@ private enum ClipboardItemDragPasteboard {
|
|||||||
.sound,
|
.sound,
|
||||||
.rtf,
|
.rtf,
|
||||||
.color
|
.color
|
||||||
]
|
] + VideoPayload.pasteboardTypes
|
||||||
}
|
}
|
||||||
|
|
||||||
private func shelfSearchText(from event: NSEvent) -> String? {
|
private func shelfSearchText(from event: NSEvent) -> String? {
|
||||||
@@ -3100,7 +3105,7 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
|
|||||||
|
|
||||||
private var canOpen: Bool {
|
private var canOpen: Bool {
|
||||||
switch itemKind {
|
switch itemKind {
|
||||||
case .url, .file, .image, .pdf, .audio:
|
case .url, .file, .image, .pdf, .audio, .video:
|
||||||
return true
|
return true
|
||||||
case .text, .richText, .unknown, .color, .code:
|
case .text, .richText, .unknown, .color, .code:
|
||||||
return false
|
return false
|
||||||
@@ -3109,7 +3114,7 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
|
|||||||
|
|
||||||
private var canPreview: Bool {
|
private var canPreview: Bool {
|
||||||
switch itemKind {
|
switch itemKind {
|
||||||
case .text, .url, .image, .richText, .file, .pdf, .audio, .unknown, .color, .code:
|
case .text, .url, .image, .richText, .file, .pdf, .audio, .unknown, .color, .code, .video:
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3120,7 +3125,7 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
|
|||||||
|
|
||||||
private var canPlainText: Bool {
|
private var canPlainText: Bool {
|
||||||
switch itemKind {
|
switch itemKind {
|
||||||
case .url, .image, .richText, .file, .pdf, .audio, .color:
|
case .url, .image, .richText, .file, .pdf, .audio, .color, .video:
|
||||||
return true
|
return true
|
||||||
case .text, .unknown, .code:
|
case .text, .unknown, .code:
|
||||||
return false
|
return false
|
||||||
@@ -3129,7 +3134,7 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
|
|||||||
|
|
||||||
private var canReveal: Bool {
|
private var canReveal: Bool {
|
||||||
switch itemKind {
|
switch itemKind {
|
||||||
case .file, .image, .pdf, .audio:
|
case .file, .image, .pdf, .audio, .video:
|
||||||
return true
|
return true
|
||||||
case .text, .richText, .url, .unknown, .color, .code:
|
case .text, .richText, .url, .unknown, .color, .code:
|
||||||
return false
|
return false
|
||||||
@@ -3710,6 +3715,8 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
|
|||||||
return filePreviewView(for: item, thumbnail: thumbnail)
|
return filePreviewView(for: item, thumbnail: thumbnail)
|
||||||
case .audio:
|
case .audio:
|
||||||
return audioPreviewView(for: item)
|
return audioPreviewView(for: item)
|
||||||
|
case .video:
|
||||||
|
return videoPreviewView(for: item)
|
||||||
case .color:
|
case .color:
|
||||||
return colorPreviewView(for: item)
|
return colorPreviewView(for: item)
|
||||||
case .code:
|
case .code:
|
||||||
@@ -4149,6 +4156,78 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
|
|||||||
return container
|
return container
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func videoPreviewView(for item: ClipboardItem) -> NSView {
|
||||||
|
let container = NSView()
|
||||||
|
container.wantsLayer = true
|
||||||
|
container.layer?.backgroundColor = accentColor(for: item.kind).withAlphaComponent(0.10).cgColor
|
||||||
|
container.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
|
||||||
|
let frame = NSView()
|
||||||
|
frame.wantsLayer = true
|
||||||
|
frame.layer?.cornerRadius = 14
|
||||||
|
frame.layer?.backgroundColor = NSColor.black.withAlphaComponent(0.78).cgColor
|
||||||
|
frame.layer?.borderWidth = 1
|
||||||
|
frame.layer?.borderColor = NSColor.white.withAlphaComponent(0.18).cgColor
|
||||||
|
frame.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
|
||||||
|
let film = headerIcon("film", color: .white)
|
||||||
|
film.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
let play = headerIcon("play.fill", color: .white)
|
||||||
|
play.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
frame.addSubview(film)
|
||||||
|
frame.addSubview(play)
|
||||||
|
|
||||||
|
let extensionPill = capsuleLabel(VideoPayload.kindText(from: item.payload), color: accentColor(for: item.kind))
|
||||||
|
extensionPill.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
frame.addSubview(extensionPill)
|
||||||
|
|
||||||
|
let title = NSTextField(labelWithString: titleText(for: item))
|
||||||
|
title.font = .systemFont(ofSize: 14, weight: .semibold)
|
||||||
|
title.textColor = .labelColor
|
||||||
|
title.maximumNumberOfLines = 1
|
||||||
|
title.lineBreakMode = .byTruncatingTail
|
||||||
|
title.toolTip = title.stringValue
|
||||||
|
|
||||||
|
let detail = NSTextField(labelWithString: previewText(for: item))
|
||||||
|
detail.font = .systemFont(ofSize: 12)
|
||||||
|
detail.textColor = .secondaryLabelColor
|
||||||
|
detail.maximumNumberOfLines = 1
|
||||||
|
detail.lineBreakMode = .byTruncatingTail
|
||||||
|
detail.toolTip = detail.stringValue
|
||||||
|
|
||||||
|
let labels = NSStackView(views: [title, detail])
|
||||||
|
labels.orientation = .vertical
|
||||||
|
labels.alignment = .leading
|
||||||
|
labels.spacing = 3
|
||||||
|
labels.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
|
||||||
|
container.addSubview(frame)
|
||||||
|
container.addSubview(labels)
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
frame.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: layout.inset),
|
||||||
|
frame.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -layout.inset),
|
||||||
|
frame.topAnchor.constraint(equalTo: container.topAnchor, constant: layout.isCompact ? 14 : 16),
|
||||||
|
frame.heightAnchor.constraint(equalToConstant: layout.isCompact ? 82 : 92),
|
||||||
|
film.centerXAnchor.constraint(equalTo: frame.centerXAnchor),
|
||||||
|
film.centerYAnchor.constraint(equalTo: frame.centerYAnchor),
|
||||||
|
film.widthAnchor.constraint(equalToConstant: 38),
|
||||||
|
film.heightAnchor.constraint(equalToConstant: 38),
|
||||||
|
play.centerXAnchor.constraint(equalTo: frame.centerXAnchor, constant: 1),
|
||||||
|
play.centerYAnchor.constraint(equalTo: frame.centerYAnchor),
|
||||||
|
play.widthAnchor.constraint(equalToConstant: 16),
|
||||||
|
play.heightAnchor.constraint(equalToConstant: 16),
|
||||||
|
extensionPill.trailingAnchor.constraint(equalTo: frame.trailingAnchor, constant: -10),
|
||||||
|
extensionPill.bottomAnchor.constraint(equalTo: frame.bottomAnchor, constant: -10),
|
||||||
|
labels.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: layout.inset),
|
||||||
|
labels.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -layout.inset),
|
||||||
|
labels.topAnchor.constraint(equalTo: frame.bottomAnchor, constant: 10),
|
||||||
|
labels.bottomAnchor.constraint(lessThanOrEqualTo: container.bottomAnchor, constant: -8),
|
||||||
|
title.widthAnchor.constraint(equalTo: labels.widthAnchor),
|
||||||
|
detail.widthAnchor.constraint(equalTo: labels.widthAnchor)
|
||||||
|
])
|
||||||
|
return container
|
||||||
|
}
|
||||||
|
|
||||||
private func colorPreviewView(for item: ClipboardItem) -> NSView {
|
private func colorPreviewView(for item: ClipboardItem) -> NSView {
|
||||||
let swatchColor = ColorPayload.color(from: item.payload) ?? accentColor(for: item.kind)
|
let swatchColor = ColorPayload.color(from: item.payload) ?? accentColor(for: item.kind)
|
||||||
let textColor = ColorPayload.contrastingTextColor(for: swatchColor)
|
let textColor = ColorPayload.contrastingTextColor(for: swatchColor)
|
||||||
@@ -4363,6 +4442,8 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
|
|||||||
return thumbnail == nil ? "file-preview" : "file-media-preview"
|
return thumbnail == nil ? "file-preview" : "file-media-preview"
|
||||||
case .audio:
|
case .audio:
|
||||||
return "audio-preview"
|
return "audio-preview"
|
||||||
|
case .video:
|
||||||
|
return "video-preview"
|
||||||
case .color:
|
case .color:
|
||||||
return "color-preview"
|
return "color-preview"
|
||||||
case .code:
|
case .code:
|
||||||
@@ -4542,6 +4623,8 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
|
|||||||
return fileTitle(for: item, fallback: "PDF document")
|
return fileTitle(for: item, fallback: "PDF document")
|
||||||
case .audio:
|
case .audio:
|
||||||
return audioTitle(for: item)
|
return audioTitle(for: item)
|
||||||
|
case .video:
|
||||||
|
return videoTitle(for: item)
|
||||||
case .image:
|
case .image:
|
||||||
return imageTitle(for: item)
|
return imageTitle(for: item)
|
||||||
case .color:
|
case .color:
|
||||||
@@ -4576,6 +4659,8 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
|
|||||||
return fileLocationText(from: item.payload, fallback: "PDF document")
|
return fileLocationText(from: item.payload, fallback: "PDF document")
|
||||||
case .audio:
|
case .audio:
|
||||||
return "Sound clip"
|
return "Sound clip"
|
||||||
|
case .video:
|
||||||
|
return "Video clip"
|
||||||
case .color:
|
case .color:
|
||||||
return ColorPayload.componentSummary(from: item.payload)
|
return ColorPayload.componentSummary(from: item.payload)
|
||||||
case .code:
|
case .code:
|
||||||
@@ -4611,6 +4696,8 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
|
|||||||
return "PDF"
|
return "PDF"
|
||||||
case .audio:
|
case .audio:
|
||||||
return "Audio"
|
return "Audio"
|
||||||
|
case .video:
|
||||||
|
return VideoPayload.kindText(from: item.payload)
|
||||||
case .color:
|
case .color:
|
||||||
return "Color"
|
return "Color"
|
||||||
case .code:
|
case .code:
|
||||||
@@ -4702,6 +4789,14 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
|
|||||||
return "Audio"
|
return "Audio"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func videoTitle(for item: ClipboardItem) -> String {
|
||||||
|
let display = firstUsefulLine(item.displayText)
|
||||||
|
if !display.isEmpty, !looksInternal(display), display.lowercased() != "video" {
|
||||||
|
return display
|
||||||
|
}
|
||||||
|
return "Video"
|
||||||
|
}
|
||||||
|
|
||||||
private func webComponents(from value: String) -> URLComponents? {
|
private func webComponents(from value: String) -> URLComponents? {
|
||||||
let trimmed = value.clipboardTrimmed
|
let trimmed = value.clipboardTrimmed
|
||||||
guard !trimmed.isEmpty else { return nil }
|
guard !trimmed.isEmpty else { return nil }
|
||||||
@@ -4929,6 +5024,8 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
|
|||||||
return NSColor(calibratedRed: 0.55, green: 0.35, blue: 0.88, alpha: 1)
|
return NSColor(calibratedRed: 0.55, green: 0.35, blue: 0.88, alpha: 1)
|
||||||
case .audio:
|
case .audio:
|
||||||
return NSColor(calibratedRed: 0.93, green: 0.12, blue: 0.34, alpha: 1)
|
return NSColor(calibratedRed: 0.93, green: 0.12, blue: 0.34, alpha: 1)
|
||||||
|
case .video:
|
||||||
|
return NSColor(calibratedRed: 0.43, green: 0.32, blue: 0.94, alpha: 1)
|
||||||
case .color:
|
case .color:
|
||||||
return NSColor(calibratedRed: 0.00, green: 0.65, blue: 0.74, alpha: 1)
|
return NSColor(calibratedRed: 0.00, green: 0.65, blue: 0.74, alpha: 1)
|
||||||
case .code:
|
case .code:
|
||||||
@@ -4947,6 +5044,7 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
|
|||||||
case .file: return "doc"
|
case .file: return "doc"
|
||||||
case .pdf: return "doc.text.fill"
|
case .pdf: return "doc.text.fill"
|
||||||
case .audio: return "music.note"
|
case .audio: return "music.note"
|
||||||
|
case .video: return "film"
|
||||||
case .color: return "paintpalette"
|
case .color: return "paintpalette"
|
||||||
case .code: return "chevron.left.forwardslash.chevron.right"
|
case .code: return "chevron.left.forwardslash.chevron.right"
|
||||||
case .unknown: return "questionmark"
|
case .unknown: return "questionmark"
|
||||||
@@ -5020,6 +5118,7 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
|
|||||||
case .unknown: return "Unknown"
|
case .unknown: return "Unknown"
|
||||||
case .pdf: return "PDF"
|
case .pdf: return "PDF"
|
||||||
case .audio: return "Audio"
|
case .audio: return "Audio"
|
||||||
|
case .video: return "Video"
|
||||||
case .color: return "Color"
|
case .color: return "Color"
|
||||||
case .code: return "Code"
|
case .code: return "Code"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -454,6 +454,9 @@ final class ClipboardPanelViewModel {
|
|||||||
case .audio:
|
case .audio:
|
||||||
guard let url = cacheService.temporaryReadableURL(for: item) else { return }
|
guard let url = cacheService.temporaryReadableURL(for: item) else { return }
|
||||||
NSWorkspace.shared.open(url)
|
NSWorkspace.shared.open(url)
|
||||||
|
case .video:
|
||||||
|
guard let url = cacheService.temporaryReadableURL(for: item) else { return }
|
||||||
|
NSWorkspace.shared.open(url)
|
||||||
case .color:
|
case .color:
|
||||||
break
|
break
|
||||||
default:
|
default:
|
||||||
@@ -477,6 +480,9 @@ final class ClipboardPanelViewModel {
|
|||||||
case .audio:
|
case .audio:
|
||||||
guard let url = cacheService.temporaryReadableURL(for: item) else { return }
|
guard let url = cacheService.temporaryReadableURL(for: item) else { return }
|
||||||
NSWorkspace.shared.activateFileViewerSelecting([url])
|
NSWorkspace.shared.activateFileViewerSelecting([url])
|
||||||
|
case .video:
|
||||||
|
guard let url = cacheService.temporaryReadableURL(for: item) else { return }
|
||||||
|
NSWorkspace.shared.activateFileViewerSelecting([url])
|
||||||
case .color:
|
case .color:
|
||||||
break
|
break
|
||||||
default:
|
default:
|
||||||
@@ -792,6 +798,12 @@ final class ClipboardPanelViewModel {
|
|||||||
.sorted(by: sortByUsage)
|
.sorted(by: sortByUsage)
|
||||||
.map(\.1)
|
.map(\.1)
|
||||||
|
|
||||||
|
case .videos:
|
||||||
|
return collectionFiltered
|
||||||
|
.filter { $0.1.kind == .video }
|
||||||
|
.sorted(by: sortByUsage)
|
||||||
|
.map(\.1)
|
||||||
|
|
||||||
case .colors:
|
case .colors:
|
||||||
return collectionFiltered
|
return collectionFiltered
|
||||||
.filter { $0.1.kind == .color }
|
.filter { $0.1.kind == .color }
|
||||||
@@ -1016,6 +1028,8 @@ final class ClipboardPanelViewModel {
|
|||||||
return [.pdf]
|
return [.pdf]
|
||||||
case "audio", "sound", "music":
|
case "audio", "sound", "music":
|
||||||
return [.audio]
|
return [.audio]
|
||||||
|
case "video", "videos", "movie", "movies", "mp4", "quicktime", "mov":
|
||||||
|
return [.video]
|
||||||
case "color", "colors", "swatch", "swatches", "hex":
|
case "color", "colors", "swatch", "swatches", "hex":
|
||||||
return [.color]
|
return [.color]
|
||||||
case "unknown", "item":
|
case "unknown", "item":
|
||||||
|
|||||||
@@ -172,6 +172,7 @@ final class SettingsWindowController: NSObject, NSTextFieldDelegate, NSTextViewD
|
|||||||
kindCheckbox("Images", .image),
|
kindCheckbox("Images", .image),
|
||||||
kindCheckbox("Colors", .color),
|
kindCheckbox("Colors", .color),
|
||||||
kindCheckbox("Audio", .audio),
|
kindCheckbox("Audio", .audio),
|
||||||
|
kindCheckbox("Videos", .video),
|
||||||
kindCheckbox("Rich text", .richText),
|
kindCheckbox("Rich text", .richText),
|
||||||
kindCheckbox("PDFs", .pdf),
|
kindCheckbox("PDFs", .pdf),
|
||||||
kindCheckbox("Files", .file)
|
kindCheckbox("Files", .file)
|
||||||
@@ -201,7 +202,7 @@ final class SettingsWindowController: NSObject, NSTextFieldDelegate, NSTextViewD
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func privacySettingsView() -> NSView {
|
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.")
|
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))
|
configureCheckbox(clearHistoryOnQuitButton, title: "Clear history on quit", action: #selector(clearHistoryOnQuitChanged))
|
||||||
configureStatusLabel(accessibilityStatusLabel)
|
configureStatusLabel(accessibilityStatusLabel)
|
||||||
|
|||||||
@@ -119,6 +119,20 @@ final class ClipboardCacheServiceTests: XCTestCase {
|
|||||||
XCTAssertEqual(try posixPermissions(URL(fileURLWithPath: path)), 0o600)
|
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 {
|
func testRichTextCacheFilesAreEncryptedAndReadable() throws {
|
||||||
let baseURL = try makeTempDirectory()
|
let baseURL = try makeTempDirectory()
|
||||||
let cacheService = ClipboardCacheService(baseURL: baseURL, encryptionService: fixedEncryptionService())
|
let cacheService = ClipboardCacheService(baseURL: baseURL, encryptionService: fixedEncryptionService())
|
||||||
@@ -178,6 +192,19 @@ final class ClipboardCacheServiceTests: XCTestCase {
|
|||||||
XCTAssertEqual(try posixPermissions(previewURL), 0o600)
|
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 {
|
func testTemporaryReadableURLWorksForRichText() throws {
|
||||||
let baseURL = try makeTempDirectory()
|
let baseURL = try makeTempDirectory()
|
||||||
let cacheService = ClipboardCacheService(baseURL: baseURL, encryptionService: fixedEncryptionService())
|
let cacheService = ClipboardCacheService(baseURL: baseURL, encryptionService: fixedEncryptionService())
|
||||||
@@ -325,6 +352,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 {
|
private func richTextItem(path: String) -> ClipboardItem {
|
||||||
ClipboardItem(
|
ClipboardItem(
|
||||||
id: UUID(),
|
id: UUID(),
|
||||||
|
|||||||
@@ -218,6 +218,34 @@ final class ClipboardMonitorServiceTests: XCTestCase {
|
|||||||
XCTAssertEqual(NSPasteboard.general.data(forType: .sound), audioData)
|
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 {
|
func testPollNowCapturesColorAsRestorableSwatch() throws {
|
||||||
let settings = SettingsModel(defaults: makeTestDefaults())
|
let settings = SettingsModel(defaults: makeTestDefaults())
|
||||||
let (store, cacheService) = makeStoreAndCache(settings: settings)
|
let (store, cacheService) = makeStoreAndCache(settings: settings)
|
||||||
@@ -726,6 +754,25 @@ final class ClipboardMonitorServiceTests: XCTestCase {
|
|||||||
XCTAssertEqual(settings.captureStatusMessage, "Skipped: Audio items are ignored in capture settings.")
|
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 {
|
func testIgnoredRichTextKindDoesNotWriteHTMLAttachmentFiles() throws {
|
||||||
let settings = SettingsModel(defaults: makeTestDefaults())
|
let settings = SettingsModel(defaults: makeTestDefaults())
|
||||||
settings.ignoredItemKindsRaw = [ClipboardItemKind.richText.rawValue]
|
settings.ignoredItemKindsRaw = [ClipboardItemKind.richText.rawValue]
|
||||||
|
|||||||
@@ -129,6 +129,51 @@ final class ClipboardPanelViewModelTests: XCTestCase {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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() {
|
func testSearchMatchesIndependentTokensCaseInsensitively() {
|
||||||
let settings = makeSettings()
|
let settings = makeSettings()
|
||||||
let store = makeStore(settings: settings)
|
let store = makeStore(settings: settings)
|
||||||
|
|||||||
@@ -246,11 +246,11 @@ final class ClipboardPanelViewTests: XCTestCase {
|
|||||||
|
|
||||||
XCTAssertEqual(
|
XCTAssertEqual(
|
||||||
fixture.view.debugCollectionTitles,
|
fixture.view.debugCollectionTitles,
|
||||||
["Clipboard", "Frequent", "Text", "Links", "Images", "Colors", "Audio", "Files", "Pinned", "Code"]
|
["Clipboard", "Frequent", "Text", "Links", "Images", "Colors", "Audio", "Videos", "Files", "Pinned", "Code"]
|
||||||
)
|
)
|
||||||
XCTAssertEqual(
|
XCTAssertEqual(
|
||||||
fixture.view.debugCollectionLeadingSymbols,
|
fixture.view.debugCollectionLeadingSymbols,
|
||||||
["doc.on.clipboard", "chart.bar.fill", "text.alignleft", "link", "photo", "paintpalette", "music.note", "doc.fill", "pin.fill", "chevron.left.forwardslash.chevron.right"]
|
["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")
|
XCTAssertEqual(fixture.view.debugSelectedCollectionTitle, "Clipboard")
|
||||||
|
|
||||||
@@ -680,6 +680,7 @@ final class ClipboardPanelViewTests: XCTestCase {
|
|||||||
let image = makeItem(kind: .image, text: "image payload", 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 color = makeItem(kind: .color, displayText: "#0A84FF", payload: "#0A84FF", store: fixture.store)
|
||||||
let audio = makeItem(kind: .audio, text: "audio payload", 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 file = makeItem(kind: .file, text: "/tmp/report.pdf", store: fixture.store)
|
||||||
let code = makeItem(
|
let code = makeItem(
|
||||||
kind: .code,
|
kind: .code,
|
||||||
@@ -688,14 +689,14 @@ final class ClipboardPanelViewTests: XCTestCase {
|
|||||||
store: fixture.store
|
store: fixture.store
|
||||||
)
|
)
|
||||||
|
|
||||||
[pinned, rich, link, image, color, audio, file, code].forEach {
|
[pinned, rich, link, image, color, audio, video, file, code].forEach {
|
||||||
fixture.store.upsert($0)
|
fixture.store.upsert($0)
|
||||||
drainMainQueue()
|
drainMainQueue()
|
||||||
}
|
}
|
||||||
|
|
||||||
XCTAssertEqual(fixture.viewModel.visibleItems.count, 8)
|
XCTAssertEqual(fixture.viewModel.visibleItems.count, 9)
|
||||||
XCTAssertEqual(ClipboardSortMode.allCases.map { fixture.viewModel.collectionCount(for: $0) }, [8, 8, 3, 1, 1, 1, 1, 1, 1, 1])
|
XCTAssertEqual(ClipboardSortMode.allCases.map { fixture.viewModel.collectionCount(for: $0) }, [9, 9, 3, 1, 1, 1, 1, 1, 1, 1, 1])
|
||||||
XCTAssertEqual(fixture.view.debugCollectionCounts, [8, 8, 3, 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))
|
XCTAssertEqual(fixture.view.debugCollectionCountLabelHiddenStates, Array(repeating: false, count: ClipboardSortMode.allCases.count))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1366,6 +1367,25 @@ final class ClipboardPanelViewTests: XCTestCase {
|
|||||||
XCTAssertEqual(fixture.view.debugCardPreviewStyles, ["audio-preview"])
|
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 testColorCardsUseSwatchPreview() {
|
func testColorCardsUseSwatchPreview() {
|
||||||
let fixture = makePanelFixture()
|
let fixture = makePanelFixture()
|
||||||
let item = makeItem(
|
let item = makeItem(
|
||||||
|
|||||||
@@ -534,6 +534,32 @@ final class PasteActionServiceTests: XCTestCase {
|
|||||||
XCTAssertEqual(NSPasteboard.general.data(forType: .sound), audioData)
|
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 {
|
func testCopyWritesEncryptedPDFData() throws {
|
||||||
let directory = try makeTempDirectory()
|
let directory = try makeTempDirectory()
|
||||||
let cacheService = ClipboardCacheService(baseURL: directory, encryptionService: fixedEncryptionService())
|
let cacheService = ClipboardCacheService(baseURL: directory, encryptionService: fixedEncryptionService())
|
||||||
|
|||||||
Reference in New Issue
Block a user