WIP: add video thumbnails
This commit is contained in:
@@ -16,6 +16,7 @@ let package = Package(
|
|||||||
exclude: ["resources"],
|
exclude: ["resources"],
|
||||||
linkerSettings: [
|
linkerSettings: [
|
||||||
.linkedFramework("AppKit"),
|
.linkedFramework("AppKit"),
|
||||||
|
.linkedFramework("AVFoundation"),
|
||||||
.linkedFramework("Carbon"),
|
.linkedFramework("Carbon"),
|
||||||
.linkedFramework("LocalAuthentication"),
|
.linkedFramework("LocalAuthentication"),
|
||||||
.linkedFramework("QuickLookUI"),
|
.linkedFramework("QuickLookUI"),
|
||||||
|
|||||||
@@ -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
|
- `Command + G` shows a filtered result back in the full clipboard history
|
||||||
- `Shift + Command + N` creates a new collection
|
- `Shift + Command + N` creates a new collection
|
||||||
- `Space` previews the selected card when the focused search field is empty
|
- `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
|
- 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 cards: Left/Right, Page Up/Page Down, Home, and End
|
||||||
- Shelf navigation keys for focused collection chips: Left/Right, Home, and End
|
- Shelf navigation keys for focused collection chips: Left/Right, Home, and End
|
||||||
@@ -80,7 +80,7 @@ Project layout:
|
|||||||
|
|
||||||
ClipBored does not use network APIs or telemetry. Clipboard history is stored locally under Application Support.
|
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
|
## Contributing
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- Accessibility permission is required for automatic paste simulation.
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ 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. 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.
|
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.
|
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
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import AppKit
|
import AppKit
|
||||||
|
import AVFoundation
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
final class ClipboardCacheService {
|
final class ClipboardCacheService {
|
||||||
|
typealias VideoThumbnailProvider = (URL) -> NSImage?
|
||||||
|
|
||||||
private let thumbnailCache = NSCache<NSString, NSImage>()
|
private let thumbnailCache = NSCache<NSString, NSImage>()
|
||||||
private let fileManager = FileManager.default
|
private let fileManager = FileManager.default
|
||||||
private let queue = DispatchQueue(label: "clipboard.cache.service", qos: .utility)
|
private let queue = DispatchQueue(label: "clipboard.cache.service", qos: .utility)
|
||||||
@@ -9,8 +12,13 @@ final class ClipboardCacheService {
|
|||||||
private let attachmentDirectory: URL
|
private let attachmentDirectory: URL
|
||||||
private let temporaryPreviewDirectory: URL
|
private let temporaryPreviewDirectory: URL
|
||||||
private let encryptionService: ClipboardEncryptionService
|
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()
|
let base = baseURL ?? ClipboardStore.storageDirectory()
|
||||||
imageDirectory = base.appendingPathComponent("images", isDirectory: true)
|
imageDirectory = base.appendingPathComponent("images", isDirectory: true)
|
||||||
attachmentDirectory = base.appendingPathComponent("attachments", isDirectory: true)
|
attachmentDirectory = base.appendingPathComponent("attachments", isDirectory: true)
|
||||||
@@ -18,6 +26,7 @@ final class ClipboardCacheService {
|
|||||||
.appendingPathComponent(AppConfiguration.appName, isDirectory: true)
|
.appendingPathComponent(AppConfiguration.appName, isDirectory: true)
|
||||||
.appendingPathComponent("Previews", isDirectory: true)
|
.appendingPathComponent("Previews", isDirectory: true)
|
||||||
self.encryptionService = encryptionService
|
self.encryptionService = encryptionService
|
||||||
|
self.videoThumbnailProvider = videoThumbnailProvider ?? Self.makeVideoThumbnail
|
||||||
thumbnailCache.countLimit = 128
|
thumbnailCache.countLimit = 128
|
||||||
try? fileManager.createDirectory(at: imageDirectory, withIntermediateDirectories: true)
|
try? fileManager.createDirectory(at: imageDirectory, withIntermediateDirectories: true)
|
||||||
try? fileManager.createDirectory(at: attachmentDirectory, withIntermediateDirectories: true)
|
try? fileManager.createDirectory(at: attachmentDirectory, withIntermediateDirectories: true)
|
||||||
@@ -109,7 +118,10 @@ final class ClipboardCacheService {
|
|||||||
case .file:
|
case .file:
|
||||||
return filePreviewThumbnail(for: item.payload)
|
return filePreviewThumbnail(for: item.payload)
|
||||||
|
|
||||||
case .text, .unknown, .audio, .richText, .color, .code, .video:
|
case .video:
|
||||||
|
return videoPreviewThumbnail(for: item)
|
||||||
|
|
||||||
|
case .text, .unknown, .audio, .richText, .color, .code:
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -154,6 +166,51 @@ final class ClipboardCacheService {
|
|||||||
return image
|
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 {
|
private func hasDrawableSize(_ image: NSImage) -> Bool {
|
||||||
image.size.width > 0 && image.size.height > 0
|
image.size.width > 0 && image.size.height > 0
|
||||||
}
|
}
|
||||||
@@ -347,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? {
|
private func webLocationData(for value: String) -> Data? {
|
||||||
let trimmed = value.clipboardTrimmed
|
let trimmed = value.clipboardTrimmed
|
||||||
guard !trimmed.isEmpty else { return nil }
|
guard !trimmed.isEmpty else { return nil }
|
||||||
|
|||||||
@@ -3716,6 +3716,9 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
|
|||||||
case .audio:
|
case .audio:
|
||||||
return audioPreviewView(for: item)
|
return audioPreviewView(for: item)
|
||||||
case .video:
|
case .video:
|
||||||
|
if let thumbnail {
|
||||||
|
return videoMediaPreviewView(for: item, thumbnail: thumbnail)
|
||||||
|
}
|
||||||
return videoPreviewView(for: item)
|
return videoPreviewView(for: item)
|
||||||
case .color:
|
case .color:
|
||||||
return colorPreviewView(for: item)
|
return colorPreviewView(for: item)
|
||||||
@@ -4382,6 +4385,50 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
|
|||||||
return container
|
return container
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func videoMediaPreviewView(for item: ClipboardItem, thumbnail: NSImage) -> NSView {
|
||||||
|
let container = NSView()
|
||||||
|
container.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
|
||||||
|
let imageView = AspectFillImageView(image: thumbnail)
|
||||||
|
imageView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
|
||||||
|
let playBadge = NSView()
|
||||||
|
playBadge.wantsLayer = true
|
||||||
|
playBadge.layer?.cornerRadius = 22
|
||||||
|
playBadge.layer?.backgroundColor = NSColor.black.withAlphaComponent(0.48).cgColor
|
||||||
|
playBadge.layer?.borderWidth = 0.8
|
||||||
|
playBadge.layer?.borderColor = NSColor.white.withAlphaComponent(0.30).cgColor
|
||||||
|
playBadge.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
|
||||||
|
let play = headerIcon("play.fill", color: .white)
|
||||||
|
play.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
playBadge.addSubview(play)
|
||||||
|
|
||||||
|
let extensionPill = capsuleLabel(VideoPayload.kindText(from: item.payload), color: NSColor.black.withAlphaComponent(0.60))
|
||||||
|
extensionPill.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
|
||||||
|
container.addSubview(imageView)
|
||||||
|
container.addSubview(playBadge)
|
||||||
|
container.addSubview(extensionPill)
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
imageView.leadingAnchor.constraint(equalTo: container.leadingAnchor),
|
||||||
|
imageView.trailingAnchor.constraint(equalTo: container.trailingAnchor),
|
||||||
|
imageView.topAnchor.constraint(equalTo: container.topAnchor),
|
||||||
|
imageView.bottomAnchor.constraint(equalTo: container.bottomAnchor),
|
||||||
|
playBadge.centerXAnchor.constraint(equalTo: container.centerXAnchor),
|
||||||
|
playBadge.centerYAnchor.constraint(equalTo: container.centerYAnchor),
|
||||||
|
playBadge.widthAnchor.constraint(equalToConstant: 44),
|
||||||
|
playBadge.heightAnchor.constraint(equalToConstant: 44),
|
||||||
|
play.centerXAnchor.constraint(equalTo: playBadge.centerXAnchor, constant: 1),
|
||||||
|
play.centerYAnchor.constraint(equalTo: playBadge.centerYAnchor),
|
||||||
|
play.widthAnchor.constraint(equalToConstant: 16),
|
||||||
|
play.heightAnchor.constraint(equalToConstant: 16),
|
||||||
|
extensionPill.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -12),
|
||||||
|
extensionPill.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: -10)
|
||||||
|
])
|
||||||
|
return container
|
||||||
|
}
|
||||||
|
|
||||||
private func capsuleLabel(_ text: String, color: NSColor) -> NSTextField {
|
private func capsuleLabel(_ text: String, color: NSColor) -> NSTextField {
|
||||||
let label = NSTextField(labelWithString: text)
|
let label = NSTextField(labelWithString: text)
|
||||||
label.translatesAutoresizingMaskIntoConstraints = false
|
label.translatesAutoresizingMaskIntoConstraints = false
|
||||||
@@ -4443,7 +4490,7 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
|
|||||||
case .audio:
|
case .audio:
|
||||||
return "audio-preview"
|
return "audio-preview"
|
||||||
case .video:
|
case .video:
|
||||||
return "video-preview"
|
return thumbnail == nil ? "video-preview" : "video-media-preview"
|
||||||
case .color:
|
case .color:
|
||||||
return "color-preview"
|
return "color-preview"
|
||||||
case .code:
|
case .code:
|
||||||
|
|||||||
@@ -91,6 +91,32 @@ final class ClipboardCacheServiceTests: XCTestCase {
|
|||||||
XCTAssertNotNil(cacheService.previewThumbnail(for: pdfItem(path: path)))
|
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 {
|
func testPDFCacheFilesAreEncryptedAndReadable() throws {
|
||||||
let baseURL = try makeTempDirectory()
|
let baseURL = try makeTempDirectory()
|
||||||
let cacheService = ClipboardCacheService(baseURL: baseURL, encryptionService: fixedEncryptionService())
|
let cacheService = ClipboardCacheService(baseURL: baseURL, encryptionService: fixedEncryptionService())
|
||||||
@@ -389,7 +415,7 @@ final class ClipboardCacheServiceTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func makeImage(color: NSColor) -> NSImage {
|
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)
|
let image = NSImage(size: size)
|
||||||
image.lockFocus()
|
image.lockFocus()
|
||||||
color.setFill()
|
color.setFill()
|
||||||
|
|||||||
@@ -1386,6 +1386,39 @@ final class ClipboardPanelViewTests: XCTestCase {
|
|||||||
XCTAssertEqual(fixture.view.debugCardPreviewStyles, ["video-preview"])
|
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() {
|
func testColorCardsUseSwatchPreview() {
|
||||||
let fixture = makePanelFixture()
|
let fixture = makePanelFixture()
|
||||||
let item = makeItem(
|
let item = makeItem(
|
||||||
@@ -1433,9 +1466,9 @@ final class ClipboardPanelViewTests: XCTestCase {
|
|||||||
return (fixture.window, fixture.view)
|
return (fixture.window, fixture.view)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func makePanelFixture() -> PanelFixture {
|
private func makePanelFixture(cacheService: ClipboardCacheService? = nil) -> PanelFixture {
|
||||||
let settings = makeSettings()
|
let settings = makeSettings()
|
||||||
let cacheService = ClipboardCacheService()
|
let cacheService = cacheService ?? ClipboardCacheService()
|
||||||
let store = makeStore(settings: settings, cacheService: cacheService)
|
let store = makeStore(settings: settings, cacheService: cacheService)
|
||||||
let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: cacheService)
|
let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: cacheService)
|
||||||
let previewProbe = PreviewProbe()
|
let previewProbe = PreviewProbe()
|
||||||
|
|||||||
Reference in New Issue
Block a user