Slim down app and test suite

This commit is contained in:
Akshay Kolli
2026-07-09 22:30:43 -04:00
parent 52f712eb73
commit 54fac96dec
28 changed files with 1046 additions and 8950 deletions

View File

@@ -24,6 +24,7 @@ Run the full local check before opening a pull request:
- Avoid storing new classes of sensitive data. If capture behavior expands, add tests and update `docs/SECURITY.md`.
- Keep idle work bounded. Polling, timers, file scans, and cache purges should have clear caps or backoff behavior.
- Add tests for persistence, pruning, sensitive filtering, shortcut parsing, pasteboard behavior, and search/sort changes.
- Prefer behavior-level tests. Use the smoke checklist for visual layout instead of adding production debug accessors for private UI details.
- Keep UI native and compact. This is a utility, not a marketing surface.
## Pull Request Checklist

View File

@@ -90,7 +90,7 @@ Project layout:
- `sources/clipbored/resources` - app bundle metadata and icon assets
- `sources/clipbored/services` - capture, persistence, cache, shortcuts, paste, diagnostics, and privacy filters
- `sources/clipbored/views` - panel, onboarding, preview, and settings UI
- `tests/clipboredtests` - unit and UI-structure regression tests
- `tests/clipboredtests` - focused behavior tests for capture, persistence, search, paste, and settings decisions
- `docs` - architecture, security, release, smoke-test, and roadmap notes
## Privacy And Security

View File

@@ -1,15 +0,0 @@
# UI Cleanup Resolution
All eleven findings from the original cleanup list are resolved in the current shelf design.
1. [x] **Hover controls obscured clip text.** Hover now expands the preview visually; commands are exposed through the context menu and keyboard, so no hover rail covers content.
2. [x] **Hover broke keyboard navigation.** Hover state is independent from keyboard focus and selection, and arrow navigation clears stale hover ownership without changing the wrong clip.
3. [x] **Category changes felt abrupt.** Search, category, card, and panel changes use short eased transitions and become immediate when macOS Reduce Motion is enabled.
4. [x] **The collapsed search control was broken.** Search has one aligned container: it expands on click, typing, or `Command + F`, and collapses after click-away only when the query is empty.
5. [x] **Filtering was split across categories and a search menu.** Category chips are the primary visual filters; a click replaces the filter and Command-click builds a union. `Command + F` only focuses search.
6. [x] **Empty built-in categories added noise.** Built-in type/sort chips are created only when they have matches or are selected; empty custom Pinboards remain visible by design.
7. [x] **A nonfunctional resize lip was visible.** The shelf has no resize handle and uses its fixed side-shelf frame.
8. [x] **New Text Clip was unnecessary.** The panel, menu-bar menu, and shortcut map no longer expose a new-text action.
9. [x] **The alternate compact mode was unnecessary.** Settings exposes one Side Shelf layout; row sizing adapts automatically to available space instead of presenting a mode toggle.
10. [x] **The panel did not need a close control.** There is no close button in shelf chrome; `Esc` and the configured global shortcut dismiss it.
11. [x] **The persistent status bar consumed space.** The shelf flows directly from its toolbar into the side-rail card list; feedback is shown in the relevant menu or Settings page.

View File

@@ -62,9 +62,3 @@ extension NSImage {
)
}
}
extension NSView {
var isInAnyViewHierarchy: Bool {
return window != nil
}
}

View File

@@ -61,14 +61,6 @@ enum ColorPayload {
"\(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())
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.6 KiB

View File

@@ -329,7 +329,6 @@ final class ClipboardCacheService {
func purgeIfNeeded(maxBytes: Int64) {
queue.async {
DiagnosticsService.shared.incrementCachePurge()
let urls = (try? self.fileManager.contentsOfDirectory(at: self.imageDirectory, includingPropertiesForKeys: nil, options: [])) ?? []
var items: [(url: URL, size: Int64, date: Date)] = []
var totalSize: Int64 = 0

View File

@@ -108,7 +108,6 @@ final class ClipboardMonitorService {
}
private func tick() {
DiagnosticsService.shared.incrementMonitorTick()
pollPasteboard(rescheduleAfterCapture: true)
}
@@ -138,7 +137,6 @@ final class ClipboardMonitorService {
return
}
DiagnosticsService.shared.incrementPasteboardChange()
didReportReadFailure = false
if let item = readCurrentItem(from: pasteboard) {
@@ -162,7 +160,6 @@ final class ClipboardMonitorService {
}
private func readCurrentItem(from pasteboard: NSPasteboard) -> ClipboardItem? {
DiagnosticsService.shared.incrementExtractionAttempt()
let source = frontmostApp()
func isIgnored(_ kind: ClipboardItemKind) -> Bool {

View File

@@ -659,78 +659,22 @@ final class ClipboardStore {
}
private func legacyISO8601Date(_ string: String) -> Date? {
string.withCString { pointer -> Date? in
let byteCount = strlen(pointer)
guard byteCount >= 20,
byte(pointer, 4) == 45,
byte(pointer, 7) == 45,
byte(pointer, 10) == 84 || byte(pointer, 10) == 32,
byte(pointer, 13) == 58,
byte(pointer, 16) == 58,
let year = decimal(pointer, byteCount, 0, 4),
let month = decimal(pointer, byteCount, 5, 2),
let day = decimal(pointer, byteCount, 8, 2),
let hour = decimal(pointer, byteCount, 11, 2),
let minute = decimal(pointer, byteCount, 14, 2),
let second = decimal(pointer, byteCount, 17, 2)
else {
return nil
}
let value = string.replacingOccurrences(of: " ", with: "T")
return Self.legacyDateFormatters.lazy.compactMap { $0.date(from: value) }.first
}
var cursor = 19
var fraction = 0.0
if cursor < byteCount, byte(pointer, cursor) == 46 {
cursor += 1
var scale = 0.1
while cursor < byteCount {
let digit = byte(pointer, cursor)
guard digit >= 48, digit <= 57 else { break }
fraction += Double(digit - 48) * scale
scale /= 10
cursor += 1
}
}
var offset = 0
if cursor < byteCount, byte(pointer, cursor) == 90 {
offset = 0
} else if cursor + 5 < byteCount, byte(pointer, cursor) == 43 || byte(pointer, cursor) == 45 {
let sign = byte(pointer, cursor) == 43 ? 1 : -1
guard let offsetHour = decimal(pointer, byteCount, cursor + 1, 2),
let offsetMinute = decimal(pointer, byteCount, cursor + 4, 2)
else { return nil }
offset = sign * ((offsetHour * 3600) + (offsetMinute * 60))
}
var components = tm()
components.tm_year = Int32(year - 1900)
components.tm_mon = Int32(month - 1)
components.tm_mday = Int32(day)
components.tm_hour = Int32(hour)
components.tm_min = Int32(minute)
components.tm_sec = Int32(second)
components.tm_isdst = 0
let epoch = timegm(&components)
guard epoch >= 0 else { return nil }
return Date(timeIntervalSince1970: TimeInterval(epoch - time_t(offset)) + fraction)
private static let legacyDateFormatters: [ISO8601DateFormatter] = {
let formats: [ISO8601DateFormatter.Options] = [
[.withInternetDateTime, .withFractionalSeconds],
[.withInternetDateTime]
]
return formats.map { options in
let formatter = ISO8601DateFormatter()
formatter.formatOptions = options
return formatter
}
}
}()
private func decimal(_ pointer: UnsafePointer<CChar>, _ byteCount: Int, _ start: Int, _ length: Int) -> Int? {
guard start + length <= byteCount else { return nil }
var result = 0
for index in start..<(start + length) {
let digit = byte(pointer, index)
guard digit >= 48, digit <= 57 else { return nil }
result = (result * 10) + Int(digit - 48)
}
return result
}
private func byte(_ pointer: UnsafePointer<CChar>, _ index: Int) -> UInt8 {
UInt8(bitPattern: pointer[index])
}
private func isDatabaseEmpty() -> Bool {
guard let db else { return true }
@@ -901,7 +845,6 @@ final class ClipboardStore {
private func applyPersistence(_ mutation: PersistenceMutation) {
guard let db else { return }
DiagnosticsService.shared.incrementDatabaseMutation()
let insertSQL = """
INSERT OR REPLACE INTO clipboard_items (
id, kind, display_text, payload, payload_hash,

View File

@@ -1,61 +0,0 @@
import Foundation
final class DiagnosticsService {
static let shared = DiagnosticsService()
struct Snapshot: Equatable {
var monitorTicks: Int
var pasteboardChanges: Int
var extractionAttempts: Int
var databaseMutations: Int
var cachePurges: Int
}
private let queue = DispatchQueue(label: "clipboard.diagnostics", qos: .utility)
private var snapshot = Snapshot(
monitorTicks: 0,
pasteboardChanges: 0,
extractionAttempts: 0,
databaseMutations: 0,
cachePurges: 0
)
private init() {}
func incrementMonitorTick() {
queue.async { self.snapshot.monitorTicks += 1 }
}
func incrementPasteboardChange() {
queue.async { self.snapshot.pasteboardChanges += 1 }
}
func incrementExtractionAttempt() {
queue.async { self.snapshot.extractionAttempts += 1 }
}
func incrementDatabaseMutation() {
queue.async { self.snapshot.databaseMutations += 1 }
}
func incrementCachePurge() {
queue.async { self.snapshot.cachePurges += 1 }
}
func currentSnapshot() -> Snapshot {
queue.sync { snapshot }
}
func reset() {
queue.sync {
snapshot = Snapshot(
monitorTicks: 0,
pasteboardChanges: 0,
extractionAttempts: 0,
databaseMutations: 0,
cachePurges: 0
)
}
}
}

View File

@@ -17,369 +17,143 @@ enum SensitiveContentDetector {
case keyword
}
static func detect(_ text: String, sourceBundleId: String? = nil, sourceApp: String? = nil) -> Reason? {
let trimmed = text.clipboardTrimmed
guard !trimmed.isEmpty else { return nil }
let bytes = Array(trimmed.utf8)
private static let tokenPatterns: [(Reason, NSRegularExpression)] = [
(.bearerToken, regex(#"(?i)\bbearer\s+[A-Za-z0-9._+/=-]{20,}(?![A-Za-z0-9_])"#)),
(.githubToken, regex(#"\bgh[porus]_[A-Za-z0-9_]{30,}(?![A-Za-z0-9_])"#)),
(.slackToken, regex(#"\bxox[baprs]-[A-Za-z0-9-]{20,}(?![A-Za-z0-9_])"#)),
(.awsAccessKey, regex(#"\bAKIA[A-Z0-9]{16}(?![A-Za-z0-9_])"#)),
(.stripeKey, regex(#"\b[srp]k_(?:live|test)_[A-Za-z0-9]{16,}(?![A-Za-z0-9_])"#)),
(.openAIToken, regex(#"\bsk-(?:proj-)?[A-Za-z0-9_-]{20,}(?![A-Za-z0-9_])"#)),
(.googleAPIKey, regex(#"\bAIza[A-Za-z0-9_-]{35}(?![A-Za-z0-9_])"#)),
(.jsonWebToken, regex(#"\beyJ[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}(?![A-Za-z0-9_])"#))
]
if containsPrivateKey(trimmed) { return .privateKey }
if containsBearerToken(bytes) { return .bearerToken }
if containsGitHubToken(bytes) { return .githubToken }
if containsSlackToken(bytes) { return .slackToken }
if containsAWSAccessKey(bytes) { return .awsAccessKey }
if containsStripeKey(bytes) { return .stripeKey }
if containsOpenAIToken(bytes) { return .openAIToken }
if containsGoogleAPIKey(bytes) { return .googleAPIKey }
if containsJSONWebToken(bytes) { return .jsonWebToken }
if containsCreditCard(trimmed) { return .creditCard }
if looksLikeOneTimeCode(trimmed, sourceBundleId: sourceBundleId, sourceApp: sourceApp) { return .oneTimeCode }
if looksHighEntropy(trimmed) { return .highEntropyToken }
static func detect(
_ text: String,
sourceBundleId: String? = nil,
sourceApp: String? = nil
) -> Reason? {
let value = text.clipboardTrimmed
guard !value.isEmpty else { return nil }
let lowered = trimmed.lowercased()
if lowered.contains("password") || lowered.contains("secret") || lowered.contains("api_key") || looksLikeSecretAssignment(lowered) {
if value.contains("-----BEGIN "), value.contains("PRIVATE KEY-----") {
return .privateKey
}
if let match = tokenPatterns.first(where: { matches($0.1, in: value) }) {
return match.0
}
if containsCreditCard(value) { return .creditCard }
if looksLikeOneTimeCode(value, sourceBundleId: sourceBundleId, sourceApp: sourceApp) {
return .oneTimeCode
}
if looksHighEntropy(value) { return .highEntropyToken }
let lowered = value.lowercased()
if lowered.contains("password")
|| lowered.contains("secret")
|| lowered.contains("api_key")
|| looksLikeSecretAssignment(lowered) {
return .keyword
}
return nil
}
static func isLikelySensitive(_ text: String, sourceBundleId: String? = nil, sourceApp: String? = nil) -> Bool {
static func isLikelySensitive(
_ text: String,
sourceBundleId: String? = nil,
sourceApp: String? = nil
) -> Bool {
detect(text, sourceBundleId: sourceBundleId, sourceApp: sourceApp) != nil
}
private static func containsPrivateKey(_ text: String) -> Bool {
text.contains("-----BEGIN ") && text.contains("PRIVATE KEY-----")
private static func regex(_ pattern: String) -> NSRegularExpression {
try! NSRegularExpression(pattern: pattern)
}
private static func matches(_ regex: NSRegularExpression, in text: String) -> Bool {
regex.firstMatch(in: text, range: NSRange(text.startIndex..., in: text)) != nil
}
private static func looksHighEntropy(_ text: String) -> Bool {
let candidate = text.clipboardTrimmed
guard candidate.count >= 32, candidate.count <= 256 else { return false }
guard !candidate.contains(where: { $0.isWhitespace }) else { return false }
var hasLower = false
var hasUpper = false
var hasDigit = false
var symbolCount = 0
for scalar in candidate.unicodeScalars {
let value = scalar.value
if value >= 48, value <= 57 {
hasDigit = true
} else if value >= 65, value <= 90 {
hasUpper = true
} else if value >= 97, value <= 122 {
hasLower = true
} else if value == 95 || value == 45 || value == 46 || value == 43 || value == 47 || value == 61 {
symbolCount += 1
} else {
return false
}
guard (32...256).contains(text.count),
!text.contains(where: \.isWhitespace) else {
return false
}
let classCount = (hasLower ? 1 : 0) + (hasUpper ? 1 : 0) + (hasDigit ? 1 : 0)
return classCount >= 2 && symbolCount > 0
var characterClasses = 0
var hasSymbol = false
for scalar in text.unicodeScalars {
switch scalar.value {
case 48...57: characterClasses |= 1
case 65...90: characterClasses |= 2
case 97...122: characterClasses |= 4
case 43, 45, 46, 47, 61, 95: hasSymbol = true
default: return false
}
}
return characterClasses.nonzeroBitCount >= 2 && hasSymbol
}
private static func looksLikeOneTimeCode(_ text: String, sourceBundleId: String?, sourceApp: String?) -> Bool {
let value = text.clipboardTrimmed
guard value.count >= 6, value.count <= 8, value.allSatisfy({ $0.isNumber }) else { return false }
let source = ((sourceBundleId ?? "") + " " + (sourceApp ?? "")).lowercased()
guard !source.isEmpty else { return false }
return source.contains("auth") ||
source.contains("1password") ||
source.contains("bitwarden") ||
source.contains("lastpass") ||
source.contains("keeper") ||
source.contains("dashlane")
private static func looksLikeOneTimeCode(
_ text: String,
sourceBundleId: String?,
sourceApp: String?
) -> Bool {
guard (6...8).contains(text.count), text.allSatisfy(\.isNumber) else {
return false
}
let source = "\(sourceBundleId ?? "") \(sourceApp ?? "")".lowercased()
return ["auth", "1password", "bitwarden", "lastpass", "keeper", "dashlane"]
.contains(where: source.contains)
}
private static func containsCreditCard(_ text: String) -> Bool {
var digits: [Int] = []
func isCard(_ digits: [Int]) -> Bool {
guard (13...19).contains(digits.count),
let first = digits.first,
digits.contains(where: { $0 != first }) else {
return false
}
var sum = 0
for (index, digit) in digits.reversed().enumerated() {
let doubled = index.isMultiple(of: 2) ? digit : digit * 2
sum += doubled > 9 ? doubled - 9 : doubled
}
return sum.isMultiple(of: 10)
}
for char in text {
if char.isNumber, let digit = char.wholeNumberValue {
for character in text {
if let digit = character.wholeNumberValue {
digits.append(digit)
} else if (character == " " || character == "-"), !digits.isEmpty {
continue
} else {
if isCreditCardGroup(digits) {
return true
}
if isCard(digits) { return true }
digits.removeAll(keepingCapacity: true)
}
}
return isCreditCardGroup(digits)
return isCard(digits)
}
private static func isCreditCardGroup(_ digits: [Int]) -> Bool {
guard digits.count >= 13, digits.count <= 19, let first = digits.first else {
return false
}
guard digits.contains(where: { $0 != first }) else {
return false
}
return passesLuhn(digits)
}
private static func passesLuhn(_ digits: [Int]) -> Bool {
var sum = 0
var shouldDouble = false
for digit in digits.reversed() {
var value = digit
if shouldDouble {
value *= 2
if value > 9 {
value -= 9
}
}
sum += value
shouldDouble.toggle()
}
return sum % 10 == 0
}
private static func containsBearerToken(_ bytes: [UInt8]) -> Bool {
guard bytes.count >= 27 else { return false }
for index in 0...(bytes.count - 6) where isWordBoundaryBefore(bytes, index) {
guard matchesBearer(bytes, index) else { continue }
var cursor = index + 6
guard cursor < bytes.count, isWhitespace(bytes[cursor]) else { continue }
while cursor < bytes.count, isWhitespace(bytes[cursor]) {
cursor += 1
}
let start = cursor
while cursor < bytes.count, isBearerByte(bytes[cursor]) {
cursor += 1
}
if cursor - start >= 20, isWordBoundaryAfter(bytes, cursor) {
return true
}
}
return false
}
private static func containsGitHubToken(_ bytes: [UInt8]) -> Bool {
guard bytes.count >= 34 else { return false }
for index in 0..<(bytes.count - 3) where isWordBoundaryBefore(bytes, index) {
let marker = bytes[index + 2]
guard bytes[index] == 103, bytes[index + 1] == 104, (marker == 112 || marker == 111 || marker == 117 || marker == 115 || marker == 114), bytes[index + 3] == 95 else {
continue
}
var cursor = index + 4
while cursor < bytes.count, isAlphaNumeric(bytes[cursor]) || bytes[cursor] == 95 {
cursor += 1
}
if cursor - (index + 4) >= 30, isWordBoundaryAfter(bytes, cursor) {
return true
}
}
return false
}
private static func containsSlackToken(_ bytes: [UInt8]) -> Bool {
guard bytes.count >= 25 else { return false }
for index in 0..<(bytes.count - 4) where isWordBoundaryBefore(bytes, index) {
let marker = bytes[index + 3]
guard bytes[index] == 120, bytes[index + 1] == 111, bytes[index + 2] == 120, (marker == 98 || marker == 97 || marker == 112 || marker == 114 || marker == 115), bytes[index + 4] == 45 else {
continue
}
var cursor = index + 5
while cursor < bytes.count, isAlphaNumeric(bytes[cursor]) || bytes[cursor] == 45 {
cursor += 1
}
if cursor - (index + 5) >= 20, isWordBoundaryAfter(bytes, cursor) {
return true
}
}
return false
}
private static func containsAWSAccessKey(_ bytes: [UInt8]) -> Bool {
guard bytes.count >= 20 else { return false }
for index in 0...(bytes.count - 20) where isWordBoundaryBefore(bytes, index) {
guard bytes[index] == 65, bytes[index + 1] == 75, bytes[index + 2] == 73, bytes[index + 3] == 65 else { continue }
var cursor = index + 4
while cursor < index + 20, isUpperAlphaNumeric(bytes[cursor]) {
cursor += 1
}
if cursor == index + 20, isWordBoundaryAfter(bytes, cursor) {
return true
}
}
return false
}
private static func containsStripeKey(_ bytes: [UInt8]) -> Bool {
guard bytes.count >= 24 else { return false }
for index in 0..<(bytes.count - 8) where isWordBoundaryBefore(bytes, index) {
let prefix = bytes[index]
guard (prefix == 115 || prefix == 114 || prefix == 112), bytes[index + 1] == 107, bytes[index + 2] == 95 else { continue }
let live = bytes[index + 3] == 108 && bytes[index + 4] == 105 && bytes[index + 5] == 118 && bytes[index + 6] == 101 && bytes[index + 7] == 95
let test = bytes[index + 3] == 116 && bytes[index + 4] == 101 && bytes[index + 5] == 115 && bytes[index + 6] == 116 && bytes[index + 7] == 95
guard live || test else { continue }
var cursor = index + 8
while cursor < bytes.count, isAlphaNumeric(bytes[cursor]) {
cursor += 1
}
if cursor - (index + 8) >= 16, isWordBoundaryAfter(bytes, cursor) {
return true
}
}
return false
}
private static func containsOpenAIToken(_ bytes: [UInt8]) -> Bool {
guard bytes.count >= 24 else { return false }
for index in 0..<(bytes.count - 3) where isWordBoundaryBefore(bytes, index) {
guard bytes[index] == 115, bytes[index + 1] == 107, bytes[index + 2] == 45 else { continue }
var cursor = index + 3
if cursor + 5 <= bytes.count,
bytes[cursor] == 112,
bytes[cursor + 1] == 114,
bytes[cursor + 2] == 111,
bytes[cursor + 3] == 106,
bytes[cursor + 4] == 45 {
cursor += 5
}
let tokenStart = cursor
while cursor < bytes.count, isTokenByte(bytes[cursor]) {
cursor += 1
}
if cursor - tokenStart >= 20, isWordBoundaryAfter(bytes, cursor) {
return true
}
}
return false
}
private static func containsGoogleAPIKey(_ bytes: [UInt8]) -> Bool {
guard bytes.count >= 39 else { return false }
for index in 0...(bytes.count - 39) where isWordBoundaryBefore(bytes, index) {
guard bytes[index] == 65, bytes[index + 1] == 73, bytes[index + 2] == 122, bytes[index + 3] == 97 else { continue }
var cursor = index + 4
while cursor < index + 39, isTokenByte(bytes[cursor]) {
cursor += 1
}
if cursor == index + 39, isWordBoundaryAfter(bytes, cursor) {
return true
}
}
return false
}
private static func containsJSONWebToken(_ bytes: [UInt8]) -> Bool {
guard bytes.count >= 32 else { return false }
var index = 0
while index + 3 < bytes.count {
guard isWordBoundaryBefore(bytes, index), bytes[index] == 101, bytes[index + 1] == 121, bytes[index + 2] == 74 else {
index += 1
continue
}
var cursor = index
let firstStart = cursor
while cursor < bytes.count, isBase64URLByte(bytes[cursor]) {
cursor += 1
}
guard cursor - firstStart >= 8, cursor < bytes.count, bytes[cursor] == 46 else {
index += 1
continue
}
cursor += 1
let secondStart = cursor
while cursor < bytes.count, isBase64URLByte(bytes[cursor]) {
cursor += 1
}
guard cursor - secondStart >= 8, cursor < bytes.count, bytes[cursor] == 46 else {
index += 1
continue
}
cursor += 1
let thirdStart = cursor
while cursor < bytes.count, isBase64URLByte(bytes[cursor]) {
cursor += 1
}
if cursor - thirdStart >= 8, isWordBoundaryAfter(bytes, cursor) {
return true
}
index += 1
}
return false
}
private static func looksLikeSecretAssignment(_ lowered: String) -> Bool {
private static func looksLikeSecretAssignment(_ text: String) -> Bool {
let keys = [
"api_key",
"apikey",
"access_token",
"auth_token",
"client_secret",
"private_token",
"refresh_token",
"secret_key",
"passwd"
"api_key", "apikey", "access_token", "auth_token", "client_secret",
"private_token", "refresh_token", "secret_key", "passwd"
]
for key in keys {
guard let range = lowered.range(of: key) else { continue }
let suffix = lowered[range.upperBound...].drop(while: { $0.isWhitespace })
guard let separator = suffix.first, separator == "=" || separator == ":" else { continue }
let value = suffix.dropFirst().drop(while: { $0.isWhitespace || $0 == "\"" || $0 == "'" })
let valueLength = value.prefix { !$0.isWhitespace && $0 != "\"" && $0 != "'" && $0 != "," }.count
if valueLength >= 8 {
guard let range = text.range(of: key) else { continue }
let suffix = text[range.upperBound...].drop(while: \.isWhitespace)
guard suffix.first == "=" || suffix.first == ":" else { continue }
let value = suffix.dropFirst().drop {
$0.isWhitespace || $0 == "\"" || $0 == "'"
}
if value.prefix(while: {
!$0.isWhitespace && $0 != "\"" && $0 != "'" && $0 != ","
}).count >= 8 {
return true
}
}
return false
}
private static func matchesBearer(_ bytes: [UInt8], _ index: Int) -> Bool {
(bytes[index] == 98 || bytes[index] == 66) &&
(bytes[index + 1] == 101 || bytes[index + 1] == 69) &&
(bytes[index + 2] == 97 || bytes[index + 2] == 65) &&
(bytes[index + 3] == 114 || bytes[index + 3] == 82) &&
(bytes[index + 4] == 101 || bytes[index + 4] == 69) &&
(bytes[index + 5] == 114 || bytes[index + 5] == 82)
}
private static func isWordBoundaryBefore(_ bytes: [UInt8], _ index: Int) -> Bool {
index == 0 || !isWordByte(bytes[index - 1])
}
private static func isWordBoundaryAfter(_ bytes: [UInt8], _ index: Int) -> Bool {
index >= bytes.count || !isWordByte(bytes[index])
}
private static func isWordByte(_ byte: UInt8) -> Bool {
isAlphaNumeric(byte) || byte == 95
}
private static func isAlphaNumeric(_ byte: UInt8) -> Bool {
(byte >= 48 && byte <= 57) || (byte >= 65 && byte <= 90) || (byte >= 97 && byte <= 122)
}
private static func isUpperAlphaNumeric(_ byte: UInt8) -> Bool {
(byte >= 48 && byte <= 57) || (byte >= 65 && byte <= 90)
}
private static func isBearerByte(_ byte: UInt8) -> Bool {
isAlphaNumeric(byte) || byte == 46 || byte == 95 || byte == 45 || byte == 43 || byte == 47 || byte == 61
}
private static func isTokenByte(_ byte: UInt8) -> Bool {
isAlphaNumeric(byte) || byte == 95 || byte == 45
}
private static func isBase64URLByte(_ byte: UInt8) -> Bool {
isAlphaNumeric(byte) || byte == 95 || byte == 45
}
private static func isWhitespace(_ byte: UInt8) -> Bool {
byte == 32 || byte == 9 || byte == 10 || byte == 13
}
}

View File

@@ -261,10 +261,6 @@ final class ShortcutManager {
modifierFlags: NSEvent.ModifierFlags([.command, .shift]).rawValue
)
static func globalShortcutBindings(openShortcut: ShortcutBinding) -> [ShortcutBinding] {
[openShortcut, stackCaptureShortcut]
}
private func osStatusMessage(_ status: OSStatus) -> String {
"OSStatus \(status)"
}

View File

@@ -1,13 +1,6 @@
import AppKit
import QuickLookUI
struct ClipboardPanelAnimationProfile {
let showDuration: TimeInterval
let hideDuration: TimeInterval
let reflowDuration: TimeInterval
let easing: CAMediaTimingFunctionName
}
struct ClipboardPanelReflowPlan {
let frame: NSRect
let bottomSafeInset: CGFloat
@@ -360,10 +353,6 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel
currentPanel ?? lastKnown ?? preferred ?? pointer ?? fallback
}
static func panelFrames(forScreenFrame screenFrame: CGRect) -> (shown: NSRect, hidden: NSRect) {
return panelFrames(forScreenFrame: screenFrame, visibleFrame: screenFrame)
}
static func panelFrames(
forScreenFrame screenFrame: CGRect,
visibleFrame: CGRect,
@@ -422,27 +411,10 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel
return inset > Metrics.hiddenDockRevealInsetLimit ? inset : 0
}
static var animationProfile: ClipboardPanelAnimationProfile {
ClipboardPanelAnimationProfile(
showDuration: Animation.showDuration,
hideDuration: Animation.hideDuration,
reflowDuration: Animation.reflowDuration,
easing: Animation.easing
)
}
static var panelCollectionBehavior: NSWindow.CollectionBehavior {
[.moveToActiveSpace, .fullScreenAuxiliary, .transient]
}
static func reflowPlan(forScreenFrame screenFrame: CGRect, visibleFrame: CGRect) -> ClipboardPanelReflowPlan {
let frames = panelFrames(forScreenFrame: screenFrame, visibleFrame: visibleFrame)
return ClipboardPanelReflowPlan(
frame: frames.shown,
bottomSafeInset: contentBottomInset(forScreenFrame: screenFrame, visibleFrame: visibleFrame)
)
}
static func reflowPlan(
forScreenFrame screenFrame: CGRect,
visibleFrame: CGRect,
@@ -848,47 +820,6 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel
quickLookURL as NSURL?
}
#if DEBUG
var debugPanelFrame: NSRect {
panel.frame
}
var debugPanelAlpha: CGFloat {
panel.alphaValue
}
var debugIsAnimating: Bool {
isAnimating
}
func debugSetSearchFieldText(_ text: String) {
panelView.debugSetSearchFieldText(text)
}
var debugSearchFieldText: String {
panelView.debugSearchFieldText
}
var debugSearchFieldWidth: CGFloat {
panelView.debugSearchFieldWidth
}
var debugSearchFieldPlaceholderText: String {
panelView.debugSearchFieldPlaceholderText
}
var debugSearchFieldIsVisible: Bool {
panelView.debugSearchFieldIsVisible
}
var debugSearchIconButtonIsVisible: Bool {
panelView.debugSearchIconButtonIsVisible
}
var debugIsSearchFieldEditing: Bool {
panelView.isSearchFieldEditing
}
#endif
private func installClickMonitor() {
removeClickMonitor()

File diff suppressed because it is too large Load Diff

View File

@@ -136,6 +136,18 @@ final class ClipboardPanelViewModel {
case original
}
private enum TransferIntent {
case copy
case paste
}
private enum TransferContext {
case single
case selected
case stackItem
case stackText
}
private(set) var visibleItems: [ClipboardItem] = [] {
didSet {
var nextItemByID: [UUID: ClipboardItem] = [:]
@@ -324,18 +336,6 @@ final class ClipboardPanelViewModel {
var onStackChanged: (() -> Void)?
var onCaptureStatusChanged: (() -> Void)?
#if DEBUG
private(set) var debugVisibleItemsFullScanCount = 0
private(set) var debugVisibleItemsIndexedLookupCount = 0
private(set) var debugCollectionCountFullScanCount = 0
private(set) var debugCollectionCountIndexedLookupCount = 0
private(set) var debugSearchItemEvaluationCount = 0
private(set) var debugSearchMatchCacheHitCount = 0
private(set) var debugSearchDocumentBuildCount = 0
private(set) var debugSearchDocumentCacheHitCount = 0
private(set) var debugCategoryFilterSelectionBuildCount = 0
private(set) var debugStackPruneScanCount = 0
#endif
init(
store: ClipboardStore,
@@ -392,20 +392,6 @@ final class ClipboardPanelViewModel {
}
}
#if DEBUG
func debugResetVisibleItemsPerformanceCounters() {
debugVisibleItemsFullScanCount = 0
debugVisibleItemsIndexedLookupCount = 0
debugCollectionCountFullScanCount = 0
debugCollectionCountIndexedLookupCount = 0
debugSearchItemEvaluationCount = 0
debugSearchMatchCacheHitCount = 0
debugSearchDocumentBuildCount = 0
debugSearchDocumentCacheHitCount = 0
debugCategoryFilterSelectionBuildCount = 0
debugStackPruneScanCount = 0
}
#endif
var selectedItem: ClipboardItem? {
guard selectedIndex >= 0, selectedIndex < visibleItems.count else { return nil }
@@ -445,10 +431,6 @@ final class ClipboardPanelViewModel {
stackItemIDs.count
}
var stackTitle: String {
"Stack"
}
var collectionNames: [String] {
if let collectionNamesCache {
return collectionNamesCache
@@ -473,14 +455,6 @@ final class ClipboardPanelViewModel {
return names
}
var searchFilterSourceAppNames: [String] {
uniqueSearchFacetValues(items.compactMap(\.sourceApp))
}
var searchFilterDeviceNames: [String] {
uniqueSearchFacetValues(items.map { Optional(effectiveSourceDeviceName(for: $0)) })
}
func collectionCount(for sortMode: ClipboardSortMode) -> Int {
collectionCountSummary().count(for: sortMode)
}
@@ -505,9 +479,6 @@ final class ClipboardPanelViewModel {
for (collectionKey, indexedItems) in indexedItemsByCollectionKey {
collectionCounts[collectionKey] = indexedItems.count
}
#if DEBUG
debugCollectionCountIndexedLookupCount += 1
#endif
} else {
for indexedItem in indexedItemsMatchingSearch(query) {
let item = indexedItem.item
@@ -516,9 +487,6 @@ final class ClipboardPanelViewModel {
collectionCounts[collectionName.lowercased(), default: 0] += 1
}
}
#if DEBUG
debugCollectionCountFullScanCount += 1
#endif
}
let summary = ClipboardCollectionCountSummary(
@@ -673,83 +641,35 @@ final class ClipboardPanelViewModel {
}
func pasteSelected() {
if selectedItemCount > 1 {
pasteSelectedItems()
return
}
guard let item = selectedItem else { return }
let result = pasteService.paste(item, targetApp: targetApplicationProvider())
if case .pasted = result {
willPasteToTarget()
}
if case .failed = result {} else {
store.markUsed(item.id)
selectedItemID = item.id
}
statusMessage = result.message
settings.setPasteStatus(message: result.message)
performSelectedTransfer(.paste)
}
func pasteSelectedPlainText() {
if selectedItemCount > 1 {
pasteSelectedItemsAsText()
return
}
guard let item = selectedItem else { return }
let result = pasteService.pastePlainText(item, targetApp: targetApplicationProvider())
if case .pastedPlainText = result {
willPasteToTarget()
}
if case .failed = result {} else {
store.markUsed(item.id)
selectedItemID = item.id
}
statusMessage = result.message
settings.setPasteStatus(message: result.message)
performSelectedTransfer(.paste, asPlainText: true)
}
func pasteItem(at index: Int) {
guard index >= 0 && index < visibleItems.count else { return }
guard visibleItems.indices.contains(index) else { return }
selectItem(at: index)
pasteSelected()
}
func pasteItemPlainText(at index: Int) {
guard index >= 0 && index < visibleItems.count else { return }
guard visibleItems.indices.contains(index) else { return }
selectItem(at: index)
pasteSelectedPlainText()
}
func copySelected() {
if selectedItemCount > 1 {
copySelectedItems()
return
}
guard let item = selectedItem else { return }
let result = pasteService.copy(item)
if case .failed = result {} else {
store.markUsed(item.id)
selectedItemID = item.id
}
statusMessage = result.message
settings.setPasteStatus(message: result.message)
performSelectedTransfer(.copy)
}
func copySelectedPlainText() {
if selectedItemCount > 1 {
copySelectedItemsAsText()
return
}
guard let item = selectedItem else { return }
let result = pasteService.copyPlainText(item)
if case .failed = result {} else {
store.markUsed(item.id)
selectedItemID = item.id
}
statusMessage = result.message
settings.setPasteStatus(message: result.message)
performSelectedTransfer(.copy, asPlainText: true)
}
func isItemStacked(at index: Int) -> Bool {
guard index >= 0 && index < visibleItems.count else { return false }
return stackItemIDSet.contains(visibleItems[index].id)
@@ -830,11 +750,6 @@ final class ClipboardPanelViewModel {
}
}
func clearStackSelection() {
guard isStackFilterSelected else { return }
isStackFilterSelected = false
}
func clearStack() {
guard !stackItemIDs.isEmpty else {
statusMessage = "Stack is empty"
@@ -846,51 +761,23 @@ final class ClipboardPanelViewModel {
}
func copyNextStackItem() {
guard let item = nextStackItem() else {
statusMessage = "Stack is empty"
return
}
let result = pasteService.copy(item)
handleStackActionResult(result, item: item)
performNextStackTransfer(.copy)
}
func pasteNextStackItem() {
guard let item = nextStackItem() else {
statusMessage = "Stack is empty"
return
}
let result = pasteService.paste(item, targetApp: targetApplicationProvider())
if case .pasted = result {
willPasteToTarget()
}
handleStackActionResult(result, item: item)
performNextStackTransfer(.paste)
}
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)
performStackTextTransfer(.copy)
}
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)
performStackTextTransfer(.paste)
}
func addSelectedItemsToStack() {
pruneStackItems()
let selectedItems = selectedItemsInSelectionOrder()
@@ -912,54 +799,16 @@ final class ClipboardPanelViewModel {
statusMessage = "Added \(newIDs.count) selected \(noun) to Stack"
}
func copySelectedItems() {
let selectedItems = selectedItemsInSelectionOrder()
guard selectedItems.count > 1 else {
copySelected()
return
}
let result = pasteService.copy(selectedItems)
handleSelectedActionResult(result, items: selectedItems)
}
func pasteSelectedItems() {
let selectedItems = selectedItemsInSelectionOrder()
guard selectedItems.count > 1 else {
pasteSelected()
return
}
let result = pasteService.paste(selectedItems, targetApp: targetApplicationProvider())
if case .pasted = result {
willPasteToTarget()
}
handleSelectedActionResult(result, items: selectedItems)
}
func copySelectedItemsAsText() {
guard let package = selectedPlainTextPackage() else {
statusMessage = "Selection has no text to copy"
return
}
let result = pasteService.copyPlainText(package.text)
handleSelectedPlainTextActionResult(result, items: package.items)
performSelectedTransfer(.copy, asPlainText: true, forceGroup: true)
}
func pasteSelectedItemsAsText() {
guard let package = selectedPlainTextPackage() else {
statusMessage = "Selection has no text to paste"
return
}
let result = pasteService.pastePlainText(package.text, targetApp: targetApplicationProvider())
if case .pastedPlainText = result {
willPasteToTarget()
}
handleSelectedPlainTextActionResult(result, items: package.items)
performSelectedTransfer(.paste, asPlainText: true, forceGroup: true)
}
func pasteboardWriters(forItemAt index: Int) -> [NSPasteboardWriting] {
guard index >= 0 && index < visibleItems.count else { return [] }
return pasteService.pasteboardWriters(for: visibleItems[index])
@@ -970,13 +819,6 @@ final class ClipboardPanelViewModel {
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 || item.kind == .code else { return nil }
return item.payload
}
func editableTitleForSelected() -> String? {
guard let item = selectedItem else { return nil }
return item.customTitle ?? ""
@@ -1540,10 +1382,6 @@ final class ClipboardPanelViewModel {
statusMessage = "Deleted \(normalizedName)"
}
func clearSearch() {
searchText = ""
}
func showSelectedInClipboard() {
guard canShowSelectedInClipboard, let item = selectedItem else { return }
selectedItemID = item.id
@@ -1762,156 +1600,198 @@ final class ClipboardPanelViewModel {
}
}
private func uniqueSearchFacetValues(_ values: [String?]) -> [String] {
var seen = Set<String>()
var result: [String] = []
for value in values {
guard let normalized = value?.clipboardTrimmed, !normalized.isEmpty else { continue }
let key = Self.normalizedSearchValue(normalized)
guard seen.insert(key).inserted else { continue }
result.append(normalized)
}
return result.sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending }
}
private func selectedItemsInSelectionOrder() -> [ClipboardItem] {
let selectedItems = selectedItemIDs.compactMap { visibleItemByID[$0] }
if !selectedItems.isEmpty {
return selectedItems
}
return selectedItem.map { [$0] } ?? []
let items = selectedItemIDs.compactMap { visibleItemByID[$0] }
return items.isEmpty ? selectedItem.map { [$0] } ?? [] : items
}
private func nextStackItem() -> ClipboardItem? {
pruneStackItems()
guard let id = stackItemIDs.first else { return nil }
return itemByID[id]
return stackItemIDs.first.flatMap { itemByID[$0] }
}
private func stackPlainTextPackage() -> (text: String, items: [ClipboardItem])? {
pruneStackItems()
let pairs: [(item: ClipboardItem, text: String)] = stackItemIDs.compactMap { id in
guard let item = itemByID[id],
let text = pasteService.plainText(for: item)?.clipboardTrimmed,
private func plainTextPackage(for items: [ClipboardItem]) -> (text: String, items: [ClipboardItem])? {
let pairs = items.compactMap { item -> (ClipboardItem, String)? in
guard 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))
return (pairs.map(\.1).joined(separator: "\n\n"), pairs.map(\.0))
}
private func selectedPlainTextPackage() -> (text: String, items: [ClipboardItem])? {
let pairs: [(item: ClipboardItem, text: String)] = selectedItemsInSelectionOrder().compactMap { item in
guard let text = pasteService.plainText(for: item)?.clipboardTrimmed, !text.isEmpty else {
return nil
private func stackPlainTextPackage() -> (text: String, items: [ClipboardItem])? {
pruneStackItems()
return plainTextPackage(for: stackItemIDs.compactMap { itemByID[$0] })
}
private func performSelectedTransfer(
_ intent: TransferIntent,
asPlainText: Bool = false,
forceGroup: Bool = false
) {
let items = selectedItemsInSelectionOrder()
let isGroup = forceGroup || items.count > 1
if asPlainText && isGroup {
guard let package = plainTextPackage(for: items) else {
statusMessage = "Selection has no text to \(intent == .copy ? "copy" : "paste")"
return
}
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
completeTransfer(
plainTextTransfer(intent, value: package.text),
items: package.items,
context: .selected
)
return
}
consumeStackItem(item.id)
store.markUsed(item.id)
selectedItemID = item.id
switch result {
case .copiedNeedsPermission:
statusMessage = "Copied from Stack. Grant Accessibility access to paste automatically."
case .pasted:
statusMessage = "Pasted from Stack"
case .copied:
statusMessage = "Copied from Stack"
default:
statusMessage = result.message
}
settings.setPasteStatus(message: statusMessage)
guard let item = items.first else { return }
let result = asPlainText
? plainTextTransfer(intent, item: item)
: originalTransfer(intent, items: items)
completeTransfer(result, items: items, context: isGroup ? .selected : .single)
}
private func handleStackPlainTextActionResult(_ result: PasteActionService.PasteActionResult, items: [ClipboardItem]) {
private func performNextStackTransfer(_ intent: TransferIntent) {
guard let item = nextStackItem() else {
statusMessage = "Stack is empty"
return
}
completeTransfer(originalTransfer(intent, items: [item]), items: [item], context: .stackItem)
}
private func performStackTextTransfer(_ intent: TransferIntent) {
guard let package = stackPlainTextPackage() else {
statusMessage = "Stack has no text to \(intent == .copy ? "copy" : "paste")"
return
}
completeTransfer(
plainTextTransfer(intent, value: package.text),
items: package.items,
context: .stackText
)
}
private func originalTransfer(
_ intent: TransferIntent,
items: [ClipboardItem]
) -> PasteActionService.PasteActionResult {
if let item = items.first, items.count == 1 {
return intent == .copy
? pasteService.copy(item)
: pasteService.paste(item, targetApp: targetApplicationProvider())
}
return intent == .copy
? pasteService.copy(items)
: pasteService.paste(items, targetApp: targetApplicationProvider())
}
private func plainTextTransfer(
_ intent: TransferIntent,
item: ClipboardItem
) -> PasteActionService.PasteActionResult {
intent == .copy
? pasteService.copyPlainText(item)
: pasteService.pastePlainText(item, targetApp: targetApplicationProvider())
}
private func plainTextTransfer(
_ intent: TransferIntent,
value: String
) -> PasteActionService.PasteActionResult {
intent == .copy
? pasteService.copyPlainText(value)
: pasteService.pastePlainText(value, targetApp: targetApplicationProvider())
}
private func completeTransfer(
_ result: PasteActionService.PasteActionResult,
items: [ClipboardItem],
context: TransferContext
) {
if case .failed(let message) = result {
statusMessage = message
if case .single = context {
settings.setPasteStatus(message: message)
}
return
}
for item in items {
store.markUsed(item.id)
consumeStackItem(item.id, refreshActiveStackFilter: false)
if case .pasted = result {
willPasteToTarget()
} else if case .pastedPlainText = result {
willPasteToTarget()
}
if stackItemIDs.isEmpty {
isStackFilterSelected = false
} else if isStackFilterSelected {
recomputeVisibleItems()
switch context {
case .stackItem:
if let item = items.first {
consumeStackItem(item.id)
store.markUsed(item.id)
}
case .stackText:
for item in items {
store.markUsed(item.id)
consumeStackItem(item.id, refreshActiveStackFilter: false)
}
if stackItemIDs.isEmpty {
isStackFilterSelected = false
} else if isStackFilterSelected {
recomputeVisibleItems()
}
case .single, .selected:
for item in items {
store.markUsed(item.id)
}
}
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
}
statusMessage = transferStatus(result, count: items.count, context: context)
settings.setPasteStatus(message: statusMessage)
}
private func handleSelectedActionResult(_ result: PasteActionService.PasteActionResult, items: [ClipboardItem]) {
if case .failed(let message) = result {
statusMessage = message
return
}
for item in items {
store.markUsed(item.id)
}
selectedItemID = items.first?.id
let noun = items.count == 1 ? "clip" : "clips"
switch result {
case .pasted:
statusMessage = "Pasted \(items.count) selected \(noun)"
case .copiedNeedsPermission:
statusMessage = "Copied \(items.count) selected \(noun). Grant Accessibility access to paste automatically."
case .copied:
statusMessage = "Copied \(items.count) selected \(noun)"
private func transferStatus(
_ result: PasteActionService.PasteActionResult,
count: Int,
context: TransferContext
) -> String {
let noun = count == 1 ? "clip" : "clips"
switch (context, result) {
case (.single, _):
return result.message
case (.stackItem, .pasted):
return "Pasted from Stack"
case (.stackItem, .copied):
return "Copied from Stack"
case (.stackItem, .copiedNeedsPermission):
return "Copied from Stack. Grant Accessibility access to paste automatically."
case (.selected, .pasted):
return "Pasted \(count) selected \(noun)"
case (.selected, .copied):
return "Copied \(count) selected \(noun)"
case (.selected, .copiedNeedsPermission):
return "Copied \(count) selected \(noun). Grant Accessibility access to paste automatically."
case (.selected, .pastedPlainText):
return "Pasted \(count) selected \(noun) as Text"
case (.selected, .copiedPlainText):
return "Copied \(count) selected \(noun) as Text"
case (.selected, .copiedPlainTextNeedsPermission):
return "Copied \(count) selected \(noun) as Text. Grant Accessibility access to paste automatically."
case (.stackText, .pastedPlainText):
return "Pasted \(count) Stack \(noun) as Text"
case (.stackText, .copiedPlainText):
return "Copied \(count) Stack \(noun) as Text"
case (.stackText, .copiedPlainTextNeedsPermission):
return "Copied \(count) Stack \(noun) as Text. Grant Accessibility access to paste automatically."
default:
statusMessage = result.message
return result.message
}
settings.setPasteStatus(message: statusMessage)
}
private func handleSelectedPlainTextActionResult(_ result: PasteActionService.PasteActionResult, items: [ClipboardItem]) {
if case .failed(let message) = result {
statusMessage = message
return
}
for item in items {
store.markUsed(item.id)
}
selectedItemID = items.first?.id
let noun = items.count == 1 ? "clip" : "clips"
switch result {
case .pastedPlainText:
statusMessage = "Pasted \(items.count) selected \(noun) as Text"
case .copiedPlainTextNeedsPermission:
statusMessage = "Copied \(items.count) selected \(noun) as Text. Grant Accessibility access to paste automatically."
case .copiedPlainText:
statusMessage = "Copied \(items.count) selected \(noun) as Text"
default:
statusMessage = result.message
}
settings.setPasteStatus(message: statusMessage)
}
private func consumeStackItem(_ id: UUID, refreshActiveStackFilter: Bool = true) {
guard let index = stackItemIDs.firstIndex(of: id) else { return }
@@ -1925,9 +1805,6 @@ final class ClipboardPanelViewModel {
guard stackItemsNeedPruning else { return }
stackItemsNeedPruning = false
guard !stackItemIDs.isEmpty else { return }
#if DEBUG
debugStackPruneScanCount += 1
#endif
let pruned = stackItemIDs.filter { itemIDSet.contains($0) }
if pruned != stackItemIDs {
stackItemIDs = pruned
@@ -1960,9 +1837,6 @@ final class ClipboardPanelViewModel {
private func indexedItemsMatchingSearch(_ query: String) -> [IndexedClipboardItem] {
if let searchMatchCache, searchMatchCache.query == query {
#if DEBUG
debugSearchMatchCacheHitCount += 1
#endif
return searchMatchCache.indexedItems
}
@@ -1975,9 +1849,6 @@ final class ClipboardPanelViewModel {
var matches: [IndexedClipboardItem] = []
matches.reserveCapacity(allIndexedItems.count)
for indexedItem in allIndexedItems {
#if DEBUG
debugSearchItemEvaluationCount += 1
#endif
if matchesSearchQuery(indexedItem.item, query: parsedQuery) {
matches.append(indexedItem)
}
@@ -2019,9 +1890,6 @@ final class ClipboardPanelViewModel {
ordering: sortOrdering(sortMode: sortMode, collectionName: collectionName, categoryFilters: categoryFilters)
)
computed = indexedItems.map(\.item)
#if DEBUG
debugVisibleItemsIndexedLookupCount += 1
#endif
} else {
computed = filterAndSortVisibleItems(
indexedItemsMatchingSearch(query),
@@ -2029,9 +1897,6 @@ final class ClipboardPanelViewModel {
collectionName: collectionName,
categoryFilters: categoryFilters
)
#if DEBUG
debugVisibleItemsFullScanCount += 1
#endif
}
if visibleItemsCache.count > 24 {
visibleItemsCache.removeAll(keepingCapacity: true)
@@ -2201,9 +2066,6 @@ final class ClipboardPanelViewModel {
)
}
categoryFilterSelectionCache = selection
#if DEBUG
debugCategoryFilterSelectionBuildCount += 1
#endif
return selection
}
@@ -2344,9 +2206,6 @@ final class ClipboardPanelViewModel {
ocrText: item.ocrText
)
if let cached = searchDocumentsByItemID[item.id], cached.fingerprint == fingerprint {
#if DEBUG
debugSearchDocumentCacheHitCount += 1
#endif
return cached
}
@@ -2368,9 +2227,6 @@ final class ClipboardPanelViewModel {
collection: item.collectionName.map(normalizedStructuredValue) ?? ""
)
searchDocumentsByItemID[item.id] = document
#if DEBUG
debugSearchDocumentBuildCount += 1
#endif
return document
}

View File

@@ -352,53 +352,4 @@ final class LinkPreviewWindowController: NSWindowController, WKNavigationDelegat
decisionHandler(.allow)
}
#if DEBUG
var debugTitleText: String {
titleLabel.stringValue
}
var debugAddressText: String {
addressLabel.stringValue
}
var debugStatusText: String {
statusLabel.stringValue
}
var debugTitleTooltip: String? {
titleLabel.toolTip
}
var debugAddressTooltip: String? {
addressLabel.toolTip
}
var debugStatusTooltip: String? {
statusLabel.toolTip
}
func debugPrepareForPreview(_ request: LinkPreviewRequest) {
prepareForPreview(request)
}
func debugSetDisplayedPageURL(_ url: URL) {
setAddress(url)
}
func debugOpenInBrowser() {
openInBrowser()
}
func debugAllowObservedPageTitles() {
acceptsObservedPageTitles = true
}
func debugApplyObservedPageTitle(_ title: String?) {
applyObservedPageTitle(title)
}
func debugApplyNavigationFailure(_ error: Error) {
handleNavigationFailure(error)
}
#endif
}

View File

@@ -355,13 +355,4 @@ final class OnboardingWindowController: NSObject, NSWindowDelegate {
settings.iCloudSyncEnabled = iCloudSyncButton.state == .on
}
#if DEBUG
var debugShowMenuBarIconIsEnabled: Bool {
showMenuBarIconButton.state == .on
}
var debugShowDockIconIsEnabled: Bool {
showDockIconButton.state == .on
}
#endif
}

View File

@@ -13,6 +13,15 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel
static let settingsContentMinimumWidth: CGFloat = 440
static let settingsLabelWidth: CGFloat = 128
}
private enum ControlCommand: Int {
case historyLength, historyRetention, pruneDuplicates, keepFirstImage, defaultSort
case launchAtLogin, showMenuBarIcon, showDockIcon, panelSide
case pauseCapture, excludeSensitive, includeImageText
case clearHistoryOnQuit, hideFromScreenCapture, requestAccessibility, refreshAccessibility
case pollProfile, cacheLimit
case iCloudSync, pushICloudArchive, pullICloudArchive, revealICloudFile
case openHistoryFolder, exportArchive, importArchive, clearHistory, clearCache
}
private static let tabTitles = ["General", "Shortcuts", "Capture", "Privacy", "Performance", "Data"]
private static let allowedContentTypesValidationMessage = "At least one content type must stay enabled."
private static let allowedContentTypesUpdatedMessage = "Allowed content types updated."
@@ -82,11 +91,6 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel
private let exportArchiveButton = NSButton()
private let importArchiveButton = NSButton()
#if DEBUG
private var debugFullRefreshCountValue = 0
private var debugIgnoredAppsRefreshCountValue = 0
private var debugDestructiveActionConfirmationOverride: Bool?
#endif
init(
settings: SettingsModel,
@@ -180,10 +184,6 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel
return item
}
private func tabTitle(for item: NSTabViewItem) -> String {
item.label.clipboardTrimmed
}
private func scrollContainer(for content: NSView) -> NSView {
let scrollView = NSScrollView()
scrollView.hasVerticalScroller = true
@@ -216,26 +216,25 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel
historyStepper.minValue = Double(AppConfiguration.minHistoryLength)
historyStepper.maxValue = Double(AppConfiguration.maxHistoryLength)
historyStepper.increment = 25
historyStepper.target = self
historyStepper.action = #selector(historyLengthChanged)
bind(historyStepper, to: .historyLength)
historyStepper.setAccessibilityLabel("History length")
configurePopup(historyRetentionPopup, action: #selector(historyRetentionChanged))
configurePopup(historyRetentionPopup, command: .historyRetention)
historyRetentionPopup.setAccessibilityLabel("Keep history")
for retention in HistoryRetention.allCases {
addPopupItem(retention.title, retention.rawValue, to: historyRetentionPopup)
}
configureCheckbox(pruneDuplicatesButton, title: "Ignore duplicate items", action: #selector(pruneDuplicatesChanged))
configureCheckbox(keepFirstImageButton, title: "Keep first image copy", action: #selector(keepFirstImageChanged))
configurePopup(defaultSortPopup, action: #selector(defaultSortChanged))
configureCheckbox(pruneDuplicatesButton, title: "Ignore duplicate items", command: .pruneDuplicates)
configureCheckbox(keepFirstImageButton, title: "Keep first image copy", command: .keepFirstImage)
configurePopup(defaultSortPopup, command: .defaultSort)
defaultSortPopup.setAccessibilityLabel("Default sort")
for mode in ClipboardSortMode.allCases {
addPopupItem(mode.title, mode.rawValue, to: defaultSortPopup)
}
configureCheckbox(launchAtLoginButton, title: "Launch at login", action: #selector(launchAtLoginChanged))
configureCheckbox(showMenuBarIconButton, title: "Show ClipBored in the menu bar", action: #selector(showMenuBarIconChanged))
configureCheckbox(showDockIconButton, title: "Show ClipBored in the Dock", action: #selector(showDockIconChanged))
configurePopup(panelSidePopup, action: #selector(panelSideChanged))
configureCheckbox(launchAtLoginButton, title: "Launch at login", command: .launchAtLogin)
configureCheckbox(showMenuBarIconButton, title: "Show ClipBored in the menu bar", command: .showMenuBarIcon)
configureCheckbox(showDockIconButton, title: "Show ClipBored in the Dock", command: .showDockIcon)
configurePopup(panelSidePopup, command: .panelSide)
panelSidePopup.setAccessibilityLabel("Shelf side")
for side in ClipboardPanelSide.allCases {
addPopupItem(side.title, side.rawValue, to: panelSidePopup)
@@ -278,9 +277,9 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel
}
private func captureSettingsView() -> NSView {
configureCheckbox(pauseCaptureButton, title: "Pause clipboard capture", action: #selector(pauseCaptureChanged))
configureCheckbox(excludeSensitiveButton, title: "Exclude likely secrets", action: #selector(excludeSensitiveChanged))
configureCheckbox(includeImageTextButton, title: "Search in image labels", action: #selector(includeImageTextChanged))
configureCheckbox(pauseCaptureButton, title: "Pause clipboard capture", command: .pauseCapture)
configureCheckbox(excludeSensitiveButton, title: "Exclude likely secrets", command: .excludeSensitive)
configureCheckbox(includeImageTextButton, title: "Search in image labels", command: .includeImageText)
configureStatusLabel(captureStatusLabel)
let allowedRows = [
@@ -333,11 +332,11 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel
let storageLabel = caption("History stays in Application Support. Text and managed media are encrypted with Keychain or an owner-only fallback key.")
let screenPrivacyLabel = caption("When enabled, the clipboard panel is hidden from screenshots, screen sharing, and screen recordings.")
let permissionHelpLabel = caption("Clipboard capture works without this permission. Grant Accessibility only for direct paste.")
configureCheckbox(clearHistoryOnQuitButton, title: "Clear history on quit", action: #selector(clearHistoryOnQuitChanged))
configureCheckbox(hideFromScreenCaptureButton, title: "Hide panel from screen sharing and recordings", action: #selector(hideFromScreenCaptureChanged))
configureCheckbox(clearHistoryOnQuitButton, title: "Clear history on quit", command: .clearHistoryOnQuit)
configureCheckbox(hideFromScreenCaptureButton, title: "Hide panel from screen sharing and recordings", command: .hideFromScreenCapture)
configureStatusLabel(accessibilityStatusLabel)
let requestButton = button("Open Accessibility Settings", #selector(requestAccessibilityAccess))
let refreshButton = button("Refresh Permission Status", #selector(refreshAccessibilityPermissionStatus))
let requestButton = button("Open Accessibility Settings", .requestAccessibility)
let refreshButton = button("Refresh Permission Status", .refreshAccessibility)
configureStatusLabel(pasteStatusLabel)
return page([
@@ -361,7 +360,7 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel
}
private func performanceSettingsView() -> NSView {
configurePopup(pollProfilePopup, action: #selector(pollProfileChanged))
configurePopup(pollProfilePopup, command: .pollProfile)
pollProfilePopup.setAccessibilityLabel("Polling profile")
for profile in AppConfiguration.PollProfile.allCases {
addPopupItem(profile.title, profile.rawValue, to: pollProfilePopup)
@@ -370,8 +369,7 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel
cacheSlider.maxValue = Double(AppConfiguration.maxCacheMaxBytes) / 1024 / 1024
cacheSlider.numberOfTickMarks = 9
cacheSlider.allowsTickMarkValuesOnly = true
cacheSlider.target = self
cacheSlider.action = #selector(cacheLimitChanged)
bind(cacheSlider, to: .cacheLimit)
cacheSlider.setAccessibilityLabel("Image cache cap in megabytes")
configureStatusLabel(cacheLabel)
@@ -389,12 +387,12 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel
private func dataSettingsView() -> NSView {
configureStatusLabel(dataStatusLabel)
configureStatusLabel(cloudSyncStatusLabel)
configureCheckbox(iCloudSyncButton, title: "Sync history with iCloud", action: #selector(iCloudSyncChanged))
configureButton(iCloudSyncNowButton, title: "Sync Now", action: #selector(pushICloudSyncArchive))
configureButton(iCloudRestoreButton, title: "Restore from iCloud", action: #selector(pullICloudSyncArchive))
configureButton(iCloudRevealButton, title: "Reveal Sync File", action: #selector(revealICloudSyncFile))
configureButton(exportArchiveButton, title: "Export Archive...", action: #selector(exportClipboardArchive))
configureButton(importArchiveButton, title: "Import Archive...", action: #selector(importClipboardArchive))
configureCheckbox(iCloudSyncButton, title: "Sync history with iCloud", command: .iCloudSync)
configureButton(iCloudSyncNowButton, title: "Sync Now", command: .pushICloudArchive)
configureButton(iCloudRestoreButton, title: "Restore from iCloud", command: .pullICloudArchive)
configureButton(iCloudRevealButton, title: "Reveal Sync File", command: .revealICloudFile)
configureButton(exportArchiveButton, title: "Export Archive...", command: .exportArchive)
configureButton(importArchiveButton, title: "Import Archive...", command: .importArchive)
let archiveLabel = caption("Export a portable archive for history, Pinboards, and managed attachments. Archives are not encrypted; file references stay path-based.")
let cloudLabel = caption("Uses the same archive in ClipBored's private iCloud container when iCloud signing and iCloud Drive are available.")
return page([
@@ -416,9 +414,9 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel
])
]),
section("Data", [
button("Open History Folder", #selector(openHistoryFolder)),
button("Clear Clipboard History", #selector(clearClipboardHistory)),
button("Clear Thumbnail Cache", #selector(clearThumbnailCache)),
button("Open History Folder", .openHistoryFolder),
button("Clear Clipboard History", .clearHistory),
button("Clear Thumbnail Cache", .clearCache),
dataStatusLabel
])
])
@@ -472,20 +470,24 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel
return label
}
private func button(_ title: String, _ action: Selector) -> NSButton {
private func button(_ title: String, _ command: ControlCommand) -> NSButton {
let control = NSButton()
configureButton(control, title: title, action: action)
configureButton(control, title: title, command: command)
return control
}
private func configureButton(_ control: NSButton, title: String, action: Selector) {
private func configureButton(_ control: NSButton, title: String, command: ControlCommand) {
control.title = title
control.target = self
control.action = action
bind(control, to: command)
control.bezelStyle = .rounded
control.setAccessibilityLabel(title)
}
private func configureCheckbox(_ control: NSButton, title: String, command: ControlCommand) {
configureCheckbox(control, title: title, action: #selector(performControlCommand(_:)))
control.tag = command.rawValue
}
private func configureCheckbox(_ control: NSButton, title: String, action: Selector) {
control.setButtonType(.switch)
control.title = title
@@ -504,10 +506,15 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel
label.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
}
private func configurePopup(_ popup: NSPopUpButton, action: Selector) {
private func configurePopup(_ popup: NSPopUpButton, command: ControlCommand) {
popup.removeAllItems()
popup.target = self
popup.action = action
bind(popup, to: command)
}
private func bind(_ control: NSControl, to command: ControlCommand) {
control.tag = command.rawValue
control.target = self
control.action = #selector(performControlCommand(_:))
}
private func addPopupItem(_ title: String, _ rawValue: Int, to popup: NSPopUpButton) {
@@ -628,9 +635,6 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel
}
private func refreshFromSettings(refreshCloudSyncStatus: Bool = false) {
#if DEBUG
debugFullRefreshCountValue += 1
#endif
refreshHistoryLimitControls()
refreshHistoryRetentionControl()
@@ -969,9 +973,6 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel
}
private func refreshIgnoredAppsTextView(force: Bool = false) {
#if DEBUG
debugIgnoredAppsRefreshCountValue += 1
#endif
guard force || !isEditingIgnoredApps else { return }
@@ -1048,67 +1049,98 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel
}
}
@objc private func historyLengthChanged() {
settings.maxHistoryItems = historyStepper.integerValue
refreshHistoryLimitControls()
}
@objc private func historyRetentionChanged() {
if let rawValue = historyRetentionPopup.selectedItem?.representedObject as? Int,
let retention = HistoryRetention(rawValue: rawValue) {
settings.historyRetention = retention
}
}
@objc private func pruneDuplicatesChanged() {
settings.pruneDuplicates = pruneDuplicatesButton.state == .on
}
@objc private func keepFirstImageChanged() {
settings.keepFirstImage = keepFirstImageButton.state == .on
}
@objc private func defaultSortChanged() {
if let rawValue = defaultSortPopup.selectedItem?.representedObject as? Int,
let mode = ClipboardSortMode(rawValue: rawValue) {
settings.defaultSortMode = mode
}
}
@objc private func launchAtLoginChanged() {
let enabled = launchAtLoginButton.state == .on
settings.launchAtLogin = enabled
if !enabled {
settings.setLaunchAtLoginStatus(message: "")
}
refreshLaunchAtLoginControls()
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in
self?.refreshLaunchAtLoginControls()
}
}
@objc private func showMenuBarIconChanged() {
let shouldShowMenuBarIcon = showMenuBarIconButton.state == .on
settings.showMenuBarIcon = shouldShowMenuBarIcon
if !shouldShowMenuBarIcon && !settings.showDockIcon {
settings.showDockIcon = true
}
refreshVisibilityControls()
}
@objc private func showDockIconChanged() {
let shouldShowDockIcon = showDockIconButton.state == .on
settings.showDockIcon = shouldShowDockIcon
if !shouldShowDockIcon && !settings.showMenuBarIcon {
settings.showMenuBarIcon = true
}
refreshVisibilityControls()
}
@objc private func panelSideChanged() {
if let rawValue = panelSidePopup.selectedItem?.representedObject as? Int,
let side = ClipboardPanelSide(rawValue: rawValue) {
settings.panelSide = side
@objc private func performControlCommand(_ sender: NSControl) {
guard let command = ControlCommand(rawValue: sender.tag) else { return }
switch command {
case .historyLength:
settings.maxHistoryItems = historyStepper.integerValue
refreshHistoryLimitControls()
case .historyRetention:
if let rawValue = historyRetentionPopup.selectedItem?.representedObject as? Int,
let retention = HistoryRetention(rawValue: rawValue) {
settings.historyRetention = retention
}
case .pruneDuplicates:
settings.pruneDuplicates = pruneDuplicatesButton.state == .on
case .keepFirstImage:
settings.keepFirstImage = keepFirstImageButton.state == .on
case .defaultSort:
if let rawValue = defaultSortPopup.selectedItem?.representedObject as? Int,
let mode = ClipboardSortMode(rawValue: rawValue) {
settings.defaultSortMode = mode
}
case .launchAtLogin:
let enabled = launchAtLoginButton.state == .on
settings.launchAtLogin = enabled
if !enabled {
settings.setLaunchAtLoginStatus(message: "")
}
refreshLaunchAtLoginControls()
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in
self?.refreshLaunchAtLoginControls()
}
case .showMenuBarIcon:
let shouldShow = showMenuBarIconButton.state == .on
settings.showMenuBarIcon = shouldShow
if !shouldShow && !settings.showDockIcon {
settings.showDockIcon = true
}
refreshVisibilityControls()
case .showDockIcon:
let shouldShow = showDockIconButton.state == .on
settings.showDockIcon = shouldShow
if !shouldShow && !settings.showMenuBarIcon {
settings.showMenuBarIcon = true
}
refreshVisibilityControls()
case .panelSide:
if let rawValue = panelSidePopup.selectedItem?.representedObject as? Int,
let side = ClipboardPanelSide(rawValue: rawValue) {
settings.panelSide = side
}
case .pauseCapture:
if pauseCaptureButton.state == .on {
settings.pauseCaptureUntil = nil
settings.pauseCapture = true
} else {
settings.pauseCapture = false
settings.pauseCaptureUntil = nil
}
case .excludeSensitive:
settings.excludeSensitive = excludeSensitiveButton.state == .on
case .includeImageText:
settings.includeImageTextInSearch = includeImageTextButton.state == .on
case .clearHistoryOnQuit:
settings.clearHistoryOnQuit = clearHistoryOnQuitButton.state == .on
case .hideFromScreenCapture:
settings.hideFromScreenCapture = hideFromScreenCaptureButton.state == .on
case .requestAccessibility: requestAccessibilityAccess()
case .refreshAccessibility: refreshAccessibilityPermissionStatus()
case .pollProfile:
if let rawValue = pollProfilePopup.selectedItem?.representedObject as? Int,
let profile = AppConfiguration.PollProfile(rawValue: rawValue) {
settings.pollProfileRaw = profile
}
case .cacheLimit:
let megabytes = Int(cacheSlider.doubleValue.rounded())
settings.imageCacheMaxBytes = Int64(megabytes * 1024 * 1024)
refreshCacheControls()
case .iCloudSync:
let enabled = iCloudSyncButton.state == .on
settings.iCloudSyncEnabled = enabled
settings.setCloudSyncStatus(message: "")
if !enabled {
cachedCloudSyncStatus = nil
}
refreshCloudSyncControls(refreshStatus: enabled && cachedCloudSyncStatus == nil)
case .openHistoryFolder: openHistoryFolder()
case .exportArchive: exportClipboardArchive()
case .importArchive: importClipboardArchive()
case .pushICloudArchive: pushICloudSyncArchive()
case .pullICloudArchive: pullICloudSyncArchive()
case .revealICloudFile: revealICloudSyncFile()
case .clearHistory: clearClipboardHistory()
case .clearCache: clearThumbnailCache()
}
}
@@ -1171,24 +1203,6 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel
}
}
@objc private func pauseCaptureChanged() {
if pauseCaptureButton.state == .on {
settings.pauseCaptureUntil = nil
settings.pauseCapture = true
} else {
settings.pauseCapture = false
settings.pauseCaptureUntil = nil
}
}
@objc private func excludeSensitiveChanged() {
settings.excludeSensitive = excludeSensitiveButton.state == .on
}
@objc private func includeImageTextChanged() {
settings.includeImageTextInSearch = includeImageTextButton.state == .on
}
@objc private func allowedKindChanged(_ sender: NSButton) {
guard let kind = ClipboardItemKind(rawValue: sender.tag) else { return }
var ignored = settings.ignoredItemKindsRaw
@@ -1228,15 +1242,7 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel
commitIgnoredAppsDraftIfNeeded()
}
@objc private func clearHistoryOnQuitChanged() {
settings.clearHistoryOnQuit = clearHistoryOnQuitButton.state == .on
}
@objc private func hideFromScreenCaptureChanged() {
settings.hideFromScreenCapture = hideFromScreenCaptureButton.state == .on
}
@objc private func requestAccessibilityAccess() {
private func requestAccessibilityAccess() {
_ = AccessibilityPermissionService.requestPromptIfNeeded()
if !AccessibilityPermissionService.isTrusted {
AccessibilityPermissionService.openSystemSettings()
@@ -1247,7 +1253,7 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel
refreshAccessibilityPermissionStatus()
}
@objc private func refreshAccessibilityPermissionStatus() {
private func refreshAccessibilityPermissionStatus() {
settings.setAccessibilityPermissionStatus(
message: AccessibilityPermissionService.isTrusted
? ""
@@ -1256,34 +1262,11 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel
refreshAccessibilityPermissionStatusLabel()
}
@objc private func pollProfileChanged() {
if let rawValue = pollProfilePopup.selectedItem?.representedObject as? Int,
let profile = AppConfiguration.PollProfile(rawValue: rawValue) {
settings.pollProfileRaw = profile
}
}
@objc private func cacheLimitChanged() {
let megabytes = Int(cacheSlider.doubleValue.rounded())
settings.imageCacheMaxBytes = Int64(megabytes * 1024 * 1024)
refreshCacheControls()
}
@objc private func iCloudSyncChanged() {
let enabled = iCloudSyncButton.state == .on
settings.iCloudSyncEnabled = enabled
settings.setCloudSyncStatus(message: "")
if !enabled {
cachedCloudSyncStatus = nil
}
refreshCloudSyncControls(refreshStatus: enabled && cachedCloudSyncStatus == nil)
}
@objc private func openHistoryFolder() {
private func openHistoryFolder() {
NSWorkspace.shared.open(ClipboardStore.storageDirectory())
}
@objc private func exportClipboardArchive() {
private func exportClipboardArchive() {
let panel = NSSavePanel()
panel.title = "Export ClipBored Archive"
panel.nameFieldStringValue = defaultArchiveFileName()
@@ -1303,7 +1286,7 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel
}
}
@objc private func importClipboardArchive() {
private func importClipboardArchive() {
let panel = NSOpenPanel()
panel.title = "Import ClipBored Archive"
panel.canChooseFiles = true
@@ -1329,7 +1312,7 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel
}
}
@objc private func pushICloudSyncArchive() {
private func pushICloudSyncArchive() {
guard settings.iCloudSyncEnabled else {
settings.setCloudSyncStatus(message: "Turn on iCloud Sync before syncing.")
return
@@ -1352,7 +1335,7 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel
}
}
@objc private func pullICloudSyncArchive() {
private func pullICloudSyncArchive() {
guard settings.iCloudSyncEnabled else {
settings.setCloudSyncStatus(message: "Turn on iCloud Sync before restoring.")
return
@@ -1400,7 +1383,7 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel
}
}
@objc private func revealICloudSyncFile() {
private func revealICloudSyncFile() {
do {
let url = try cloudSyncService.syncArchiveURL()
if FileManager.default.fileExists(atPath: url.path) {
@@ -1415,7 +1398,7 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel
}
}
@objc private func clearClipboardHistory() {
private func clearClipboardHistory() {
guard confirmDestructiveAction(
title: "Clear Clipboard History?",
message: "This permanently removes saved clipboard items, app-managed attachments, temporary decrypted previews, and the local fallback encryption key when present. The current system clipboard is not changed.",
@@ -1426,7 +1409,7 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel
setDataStatus("Cleared clipboard history.")
}
@objc private func clearThumbnailCache() {
private func clearThumbnailCache() {
guard confirmDestructiveAction(
title: "Clear Thumbnail Cache?",
message: "This removes cached image previews and temporary decrypted previews. ClipBored will recreate previews as needed.",
@@ -1437,11 +1420,6 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel
}
private func confirmDestructiveAction(title: String, message: String, buttonTitle: String) -> Bool {
#if DEBUG
if let override = debugDestructiveActionConfirmationOverride {
return override
}
#endif
let alert = NSAlert()
alert.messageText = title
@@ -1551,447 +1529,6 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel
return nil
}
#if DEBUG
var debugWindowStyleMask: NSWindow.StyleMask {
window?.styleMask ?? []
}
var debugWindowMinSize: NSSize {
window?.minSize ?? .zero
}
var debugWindowContentSize: NSSize {
window?.contentView?.bounds.size ?? .zero
}
var debugRawSettingsTabLabels: [String] {
tabView.tabViewItems.map(\.label)
}
func debugSetWindowContentSize(_ size: NSSize) {
window?.setContentSize(size)
window?.contentView?.layoutSubtreeIfNeeded()
}
var debugSettingsTabLayoutMetrics: [(label: String, viewport: NSSize, document: NSSize, hasHorizontalScroller: Bool)] {
let originalSelection = tabView.selectedTabViewItem
defer {
if let originalSelection {
tabView.selectTabViewItem(originalSelection)
}
window?.contentView?.layoutSubtreeIfNeeded()
}
return tabView.tabViewItems.map { item in
tabView.selectTabViewItem(item)
window?.contentView?.layoutSubtreeIfNeeded()
item.view?.layoutSubtreeIfNeeded()
guard let scrollView = item.view as? NSScrollView else {
return (tabTitle(for: item), .zero, .zero, true)
}
scrollView.documentView?.layoutSubtreeIfNeeded()
return (
tabTitle(for: item),
scrollView.contentView.bounds.size,
scrollView.documentView?.frame.size ?? .zero,
scrollView.hasHorizontalScroller
)
}
}
var debugSettingsTabLayoutAuditMetrics: [(label: String, overflowingViewCount: Int, zeroSizedControlCount: Int)] {
let originalSelection = tabView.selectedTabViewItem
defer {
if let originalSelection {
tabView.selectTabViewItem(originalSelection)
}
window?.contentView?.layoutSubtreeIfNeeded()
}
func visibleDescendants(of root: NSView) -> [NSView] {
var result: [NSView] = []
func visit(_ view: NSView) {
guard !view.isHidden else { return }
result.append(view)
for subview in view.subviews {
visit(subview)
}
}
for subview in root.subviews {
visit(subview)
}
return result
}
func shouldAudit(_ view: NSView) -> Bool {
!(view is NSScroller)
}
func canCollapseToZero(_ view: NSView) -> Bool {
view is NSControl || view is NSTextView
}
return tabView.tabViewItems.map { item in
tabView.selectTabViewItem(item)
window?.contentView?.layoutSubtreeIfNeeded()
item.view?.layoutSubtreeIfNeeded()
guard let scrollView = item.view as? NSScrollView,
let documentView = scrollView.documentView else {
return (tabTitle(for: item), 1, 1)
}
documentView.layoutSubtreeIfNeeded()
let auditedViews = visibleDescendants(of: documentView).filter(shouldAudit)
let documentBounds = documentView.bounds
let overflowing = auditedViews.filter { view in
let frame = view.convert(view.bounds, to: documentView)
return frame.minX < -1 || frame.maxX > documentBounds.width + 1
}
let zeroSizedControls = auditedViews.filter { view in
guard canCollapseToZero(view) else { return false }
let frame = view.convert(view.bounds, to: documentView)
return frame.width <= 0.5 || frame.height <= 0.5
}
return (tabTitle(for: item), overflowing.count, zeroSizedControls.count)
}
}
var debugSettingsTabContentPlacementMetrics: [(label: String, contentBounds: NSRect, document: NSSize)] {
let originalSelection = tabView.selectedTabViewItem
defer {
if let originalSelection {
tabView.selectTabViewItem(originalSelection)
}
window?.contentView?.layoutSubtreeIfNeeded()
}
func visibleControlsAndLabels(of root: NSView) -> [NSView] {
var result: [NSView] = []
func visit(_ view: NSView) {
guard !view.isHidden else { return }
if view is NSControl || view is NSTextView {
result.append(view)
}
for subview in view.subviews {
visit(subview)
}
}
for subview in root.subviews {
visit(subview)
}
return result
}
return tabView.tabViewItems.map { item in
tabView.selectTabViewItem(item)
window?.contentView?.layoutSubtreeIfNeeded()
item.view?.layoutSubtreeIfNeeded()
guard let scrollView = item.view as? NSScrollView,
let documentView = scrollView.documentView else {
return (tabTitle(for: item), .zero, .zero)
}
documentView.layoutSubtreeIfNeeded()
let bounds = visibleControlsAndLabels(of: documentView)
.map { $0.convert($0.bounds, to: documentView) }
.reduce(NSRect.null) { $0.union($1) }
return (tabTitle(for: item), bounds.isNull ? .zero : bounds, documentView.frame.size)
}
}
var debugCloudSyncStatusText: String {
cloudSyncStatusLabel.stringValue
}
var debugCloudSyncActionButtonsAreEnabled: [Bool] {
[
iCloudSyncNowButton.isEnabled,
iCloudRestoreButton.isEnabled,
iCloudRevealButton.isEnabled
]
}
var debugHistoryText: String {
historyLabel.stringValue
}
var debugHistoryStepperValue: Int {
historyStepper.integerValue
}
var debugCacheStatusText: String {
cacheLabel.stringValue
}
var debugCacheSliderMegabytes: Int {
Int(cacheSlider.doubleValue.rounded())
}
var debugDataStatusText: String {
dataStatusLabel.stringValue
}
var debugDataStatusColor: NSColor {
dataStatusLabel.textColor ?? .clear
}
var debugDataStatusSectionTitle: String {
guard let section = dataStatusLabel.superview as? NSStackView,
let titleLabel = section.arrangedSubviews.first as? NSTextField else {
return ""
}
return titleLabel.stringValue
}
var debugPasteStatusText: String {
pasteStatusLabel.stringValue
}
var debugAccessibilityStatusText: String {
accessibilityStatusLabel.stringValue
}
var debugCaptureStatusText: String {
captureStatusLabel.stringValue
}
var debugCaptureStatusColor: NSColor {
captureStatusLabel.textColor ?? .clear
}
var debugStatusLabelsAllowWrapping: Bool {
[
launchStatusLabel,
shortcutStatusLabel,
captureStatusLabel,
accessibilityStatusLabel,
pasteStatusLabel,
cacheLabel,
dataStatusLabel,
cloudSyncStatusLabel
].allSatisfy { label in
label.lineBreakMode == .byWordWrapping
&& label.maximumNumberOfLines == 0
&& !label.usesSingleLineMode
&& (label.cell?.wraps ?? false)
}
}
var debugDefaultSortTitle: String {
defaultSortPopup.selectedItem?.title ?? ""
}
var debugPruneDuplicatesIsEnabled: Bool {
pruneDuplicatesButton.state == .on
}
var debugKeepFirstImageIsEnabled: Bool {
keepFirstImageButton.state == .on
}
var debugIncludeImageTextIsEnabled: Bool {
includeImageTextButton.state == .on
}
var debugExcludeSensitiveIsEnabled: Bool {
excludeSensitiveButton.state == .on
}
var debugClearHistoryOnQuitIsEnabled: Bool {
clearHistoryOnQuitButton.state == .on
}
var debugLaunchAtLoginIsEnabled: Bool {
launchAtLoginButton.state == .on
}
var debugShowMenuBarIconIsEnabled: Bool {
showMenuBarIconButton.state == .on
}
var debugShowDockIconIsEnabled: Bool {
showDockIconButton.state == .on
}
var debugLaunchStatusText: String {
launchStatusLabel.stringValue
}
var debugOpenShortcutKeyText: String {
openShortcutControls?.keyField.stringValue ?? ""
}
var debugSettingsShortcutKeyText: String {
settingsShortcutControls?.keyField.stringValue ?? ""
}
var debugShortcutStatusText: String {
shortcutStatusLabel.stringValue
}
var debugOpenShortcutModifierAccessibilityLabels: [String] {
guard let controls = openShortcutControls else { return [] }
return [controls.command, controls.option, controls.control, controls.shift]
.compactMap { $0.accessibilityLabel() }
}
var debugOpenShortcutModifierAccessibilityHelps: [String] {
guard let controls = openShortcutControls else { return [] }
return [controls.command, controls.option, controls.control, controls.shift]
.compactMap { $0.accessibilityHelp() }
}
var debugSettingsContentView: NSView? {
window?.contentView
}
func debugSelectSettingsTab(at index: Int) {
guard index >= 0, index < tabView.numberOfTabViewItems else { return }
tabSelector.selectedSegment = index
tabView.selectTabViewItem(at: index)
window?.contentView?.layoutSubtreeIfNeeded()
}
func debugPrepareWindowForSnapshot() {
window?.setFrameOrigin(.zero)
window?.orderFront(nil)
window?.contentView?.layoutSubtreeIfNeeded()
window?.contentView?.displayIfNeeded()
}
var debugFullRefreshCount: Int {
debugFullRefreshCountValue
}
var debugIgnoredAppsRefreshCount: Int {
debugIgnoredAppsRefreshCountValue
}
var debugIgnoredAppsText: String {
ignoredAppsTextView.string
}
var debugIgnoredAppsEditorIsFocused: Bool {
isEditingIgnoredApps
}
@discardableResult
func debugFocusIgnoredAppsEditor() -> Bool {
textDidBeginEditing(Notification(name: NSText.didBeginEditingNotification, object: ignoredAppsTextView))
_ = window?.makeFirstResponder(ignoredAppsTextView)
return isEditingIgnoredApps
}
func debugSetIgnoredAppsText(_ text: String) {
ignoredAppsTextView.string = text
textDidChange(Notification(name: NSText.didChangeNotification, object: ignoredAppsTextView))
}
func debugSetHistoryStepperValue(_ value: Int) {
historyStepper.integerValue = value
historyLengthChanged()
}
func debugSetCacheSliderMegabytes(_ value: Int) {
cacheSlider.doubleValue = Double(value)
cacheLimitChanged()
}
func debugEndIgnoredAppsEditing() {
window?.makeFirstResponder(nil)
textDidEndEditing(Notification(name: NSText.didEndEditingNotification, object: ignoredAppsTextView))
}
func debugCloseWindow() {
windowWillClose(Notification(name: NSWindow.willCloseNotification, object: window))
window?.makeFirstResponder(nil)
}
@discardableResult
func debugBeginOpenShortcutKeyEditing() -> Bool {
guard let field = openShortcutControls?.keyField else { return false }
controlTextDidBeginEditing(Notification(name: NSControl.textDidBeginEditingNotification, object: field))
_ = window?.makeFirstResponder(field)
return isEditingShortcutKeyField(field)
}
func debugSetOpenShortcutKeyDraft(_ text: String) {
openShortcutControls?.keyField.stringValue = text
}
func debugEndOpenShortcutKeyEditing() {
guard let field = openShortcutControls?.keyField else { return }
window?.makeFirstResponder(nil)
controlTextDidEndEditing(Notification(name: NSControl.textDidEndEditingNotification, object: field))
}
func debugCommitOpenShortcutKeyText(_ text: String) {
guard let controls = openShortcutControls else { return }
controls.keyField.stringValue = text
shortcutChanged(controls.keyField)
}
func debugSetOpenShortcutModifiers(command: Bool, option: Bool, control: Bool, shift: Bool) {
guard let controls = openShortcutControls else { return }
controls.command.state = command ? .on : .off
controls.option.state = option ? .on : .off
controls.control.state = control ? .on : .off
controls.shift.state = shift ? .on : .off
shortcutChanged(controls.command)
}
func debugCommitSettingsShortcutKeyText(_ text: String) {
guard let controls = settingsShortcutControls else { return }
controls.keyField.stringValue = text
shortcutChanged(controls.keyField)
}
func debugSetLaunchAtLoginEnabled(_ enabled: Bool) {
launchAtLoginButton.state = enabled ? .on : .off
launchAtLoginChanged()
}
func debugSetShowMenuBarIconEnabled(_ enabled: Bool) {
showMenuBarIconButton.state = enabled ? .on : .off
showMenuBarIconChanged()
}
func debugSetShowDockIconEnabled(_ enabled: Bool) {
showDockIconButton.state = enabled ? .on : .off
showDockIconChanged()
}
func debugSetICloudSyncEnabled(_ enabled: Bool) {
iCloudSyncButton.state = enabled ? .on : .off
iCloudSyncChanged()
}
func debugSetDestructiveActionConfirmation(_ confirmed: Bool?) {
debugDestructiveActionConfirmationOverride = confirmed
}
func debugClearClipboardHistory() {
clearClipboardHistory()
}
func debugClearThumbnailCache() {
clearThumbnailCache()
}
func debugAllowedKindIsEnabled(_ kind: ClipboardItemKind) -> Bool {
allowedKindButtons.first { $0.0 == kind }?.1.state == .on
}
func debugSetAllowedKindEnabled(_ kind: ClipboardItemKind, _ enabled: Bool) {
guard let button = allowedKindButtons.first(where: { $0.0 == kind })?.1 else { return }
button.state = enabled ? .on : .off
allowedKindChanged(button)
}
func debugRefreshAccessibilityPermissionStatus() {
refreshAccessibilityPermissionStatus()
}
#endif
}
private struct ShortcutControlSet {

View File

@@ -4,21 +4,6 @@ import XCTest
@testable import ClipBored
final class ClipboardPanelControllerTests: XCTestCase {
private var tempURLs: [URL] = []
private var defaultsSuites: [String] = []
override func tearDown() {
for url in tempURLs {
try? FileManager.default.removeItem(at: url)
}
tempURLs.removeAll()
for suite in defaultsSuites {
UserDefaults(suiteName: suite)?.removePersistentDomain(forName: suite)
}
defaultsSuites.removeAll()
super.tearDown()
}
func testPanelFrameUsesRightSideShelfByDefault() {
let screenFrame = CGRect(x: -1200, y: -200, width: 1200, height: 800)
let frames = ClipboardPanelController.panelFrames(forScreenFrame: screenFrame, visibleFrame: screenFrame)
@@ -31,7 +16,7 @@ final class ClipboardPanelControllerTests: XCTestCase {
XCTAssertEqual(frames.hidden.minY, frames.shown.minY)
}
func testOpenScreenSelectionUsesStatusClickScreenWhenProvided() {
func testOpenScreenSelectionPrefersExplicitThenPointerScreen() {
XCTAssertEqual(
ClipboardPanelController.selectedOpenScreen(
explicit: "status-item-screen",
@@ -41,9 +26,6 @@ final class ClipboardPanelControllerTests: XCTestCase {
),
"status-item-screen"
)
}
func testOpenScreenSelectionFallsBackToPointerForGlobalShortcut() {
XCTAssertEqual(
ClipboardPanelController.selectedOpenScreen(
explicit: Optional<String>.none,
@@ -141,31 +123,6 @@ final class ClipboardPanelControllerTests: XCTestCase {
XCTAssertEqual(frames.hidden.minX, screenFrame.maxX + 1)
}
func testPanelFramePlanningIsDeterministicAcrossRepeatedToggles() {
let screenFrame = CGRect(x: -1512, y: -120, width: 1512, height: 982)
let visibleFrame = CGRect(x: -1512, y: -24, width: 1512, height: 861)
let first = ClipboardPanelController.panelFrames(forScreenFrame: screenFrame, visibleFrame: visibleFrame)
for _ in 0..<50 {
let frames = ClipboardPanelController.panelFrames(forScreenFrame: screenFrame, visibleFrame: visibleFrame)
XCTAssertEqual(frames.shown, first.shown)
XCTAssertEqual(frames.hidden, first.hidden)
XCTAssertEqual(frames.hidden.minX, frames.shown.maxX + 1)
}
}
func testPanelAnimationProfileStaysShortForSixtyFpsFeel() {
let profile = ClipboardPanelController.animationProfile
XCTAssertEqual(profile.showDuration, 0.22)
XCTAssertEqual(profile.hideDuration, 0.16)
XCTAssertEqual(profile.reflowDuration, 0.18)
XCTAssertLessThanOrEqual(profile.showDuration * 60, 14)
XCTAssertLessThanOrEqual(profile.hideDuration * 60, 10)
XCTAssertLessThanOrEqual(profile.reflowDuration * 60, 11)
XCTAssertEqual(profile.easing, .easeInEaseOut)
}
func testPanelCollectionBehaviorStaysLocalToActiveSpaceAndSupportsFullscreen() {
let behavior = ClipboardPanelController.panelCollectionBehavior
@@ -175,69 +132,6 @@ final class ClipboardPanelControllerTests: XCTestCase {
XCTAssertFalse(behavior.contains(.canJoinAllSpaces))
}
func testShowingPanelClearsAndCollapsesStaleSearchState() throws {
let screen = try XCTUnwrap(NSScreen.screens.first)
let (controller, _) = makeController(preferredScreen: screen)
controller.debugSetSearchFieldText("stale query")
drainMainQueue()
XCTAssertEqual(controller.debugSearchFieldText, "stale query")
XCTAssertEqual(controller.debugSearchFieldWidth, 238, accuracy: 1)
XCTAssertEqual(controller.debugSearchFieldPlaceholderText, "Search clips")
XCTAssertTrue(controller.debugSearchFieldIsVisible)
XCTAssertTrue(controller.debugSearchIconButtonIsVisible)
controller.show(preferredScreen: screen)
defer { controller.hide(immediate: true) }
drainMainQueue()
XCTAssertEqual(controller.debugSearchFieldText, "")
XCTAssertEqual(controller.debugSearchFieldWidth, 30, accuracy: 1)
XCTAssertEqual(controller.debugSearchFieldPlaceholderText, "")
XCTAssertFalse(controller.debugSearchFieldIsVisible)
XCTAssertTrue(controller.debugSearchIconButtonIsVisible)
XCTAssertFalse(controller.debugIsSearchFieldEditing)
}
func testReflowPlanKeepsOpenPanelOnRightSideWhenBottomDockIsVisible() {
let screenFrame = CGRect(x: 0, y: 0, width: 1512, height: 982)
let visibleFrame = CGRect(x: 0, y: 112, width: 1512, height: 845)
let plan = ClipboardPanelController.reflowPlan(forScreenFrame: screenFrame, visibleFrame: visibleFrame)
XCTAssertEqual(plan.frame.maxX, visibleFrame.maxX)
XCTAssertEqual(plan.frame.minY, screenFrame.minY)
XCTAssertEqual(plan.frame.maxY, visibleFrame.maxY)
XCTAssertEqual(plan.frame.width, 336)
XCTAssertEqual(plan.bottomSafeInset, 20)
}
func testReflowPlanTouchesScreenBottomWhenBottomDockIsAutoHidden() {
let screenFrame = CGRect(x: 0, y: 0, width: 1512, height: 982)
let visibleFrame = CGRect(x: 0, y: 4, width: 1512, height: 953)
let plan = ClipboardPanelController.reflowPlan(forScreenFrame: screenFrame, visibleFrame: visibleFrame)
XCTAssertEqual(plan.frame.maxX, visibleFrame.maxX)
XCTAssertEqual(plan.frame.minY, screenFrame.minY)
XCTAssertEqual(plan.frame.maxY, visibleFrame.maxY)
XCTAssertEqual(plan.frame.width, 336)
XCTAssertEqual(plan.bottomSafeInset, 18)
}
func testReflowPlanTracksSideDockVisibleFrameWithoutBottomInsetInflation() {
let screenFrame = CGRect(x: 0, y: 0, width: 1512, height: 982)
let visibleFrame = CGRect(x: 86, y: 0, width: 1426, height: 957)
let plan = ClipboardPanelController.reflowPlan(forScreenFrame: screenFrame, visibleFrame: visibleFrame)
XCTAssertEqual(plan.frame.maxX, visibleFrame.maxX)
XCTAssertEqual(plan.frame.minX, visibleFrame.maxX - 336)
XCTAssertEqual(plan.frame.minY, screenFrame.minY)
XCTAssertEqual(plan.frame.height, 957)
XCTAssertEqual(plan.bottomSafeInset, 18)
}
func testPanelFrameUsesConfiguredRightSideShelf() {
let screenFrame = CGRect(x: 0, y: 0, width: 1512, height: 982)
let visibleFrame = CGRect(x: 0, y: 96, width: 1512, height: 861)
@@ -289,22 +183,20 @@ final class ClipboardPanelControllerTests: XCTestCase {
XCTAssertEqual(frames.shown.width, 336)
}
func testContentBottomInsetReservesBottomDockSpace() {
func testContentBottomInsetHandlesBottomAndSideDock() {
let screenFrame = CGRect(x: 0, y: 0, width: 1512, height: 982)
let visibleFrame = CGRect(x: 0, y: 96, width: 1512, height: 861)
let inset = ClipboardPanelController.contentBottomInset(forScreenFrame: screenFrame, visibleFrame: visibleFrame)
XCTAssertEqual(inset, 20)
}
func testContentBottomInsetUsesMinimumWhenDockIsNotAtBottom() {
let screenFrame = CGRect(x: 0, y: 0, width: 1512, height: 982)
let visibleFrame = CGRect(x: 80, y: 0, width: 1432, height: 957)
let inset = ClipboardPanelController.contentBottomInset(forScreenFrame: screenFrame, visibleFrame: visibleFrame)
XCTAssertEqual(inset, 18)
XCTAssertEqual(
ClipboardPanelController.contentBottomInset(
forScreenFrame: screenFrame,
visibleFrame: CGRect(x: 80, y: 0, width: 1432, height: 957)
),
18
)
}
func testPanelSharingTypeHidesWindowFromScreenCaptureWhenEnabled() {
@@ -326,41 +218,38 @@ final class ClipboardPanelControllerTests: XCTestCase {
}
func testCommandNumberShortcutsMapToQuickPasteSlots() {
XCTAssertEqual(ClipboardPanelController.quickPasteIndex(forKeyCode: 18, modifiers: .command), 0)
XCTAssertEqual(ClipboardPanelController.quickPasteIndex(forKeyCode: 19, modifiers: .command), 1)
XCTAssertEqual(ClipboardPanelController.quickPasteIndex(forKeyCode: 20, modifiers: .command), 2)
XCTAssertEqual(ClipboardPanelController.quickPasteIndex(forKeyCode: 21, modifiers: .command), 3)
XCTAssertEqual(ClipboardPanelController.quickPasteIndex(forKeyCode: 23, modifiers: .command), 4)
XCTAssertEqual(ClipboardPanelController.quickPasteIndex(forKeyCode: 22, modifiers: .command), 5)
XCTAssertEqual(ClipboardPanelController.quickPasteIndex(forKeyCode: 26, modifiers: .command), 6)
XCTAssertEqual(ClipboardPanelController.quickPasteIndex(forKeyCode: 28, modifiers: .command), 7)
XCTAssertEqual(ClipboardPanelController.quickPasteIndex(forKeyCode: 25, modifiers: .command), 8)
assertShortcutMappings([
(18, .command, 0), (19, .command, 1), (20, .command, 2),
(21, .command, 3), (23, .command, 4), (22, .command, 5),
(26, .command, 6), (28, .command, 7), (25, .command, 8)
], using: ClipboardPanelController.quickPasteIndex)
}
func testShiftCommandNumberShortcutsMapToPlainTextQuickPasteSlots() {
XCTAssertEqual(ClipboardPanelController.quickPastePlainTextIndex(forKeyCode: 18, modifiers: [.command, .shift]), 0)
XCTAssertEqual(ClipboardPanelController.quickPastePlainTextIndex(forKeyCode: 25, modifiers: [.command, .shift]), 8)
XCTAssertNil(ClipboardPanelController.quickPastePlainTextIndex(forKeyCode: 18, modifiers: .command))
XCTAssertNil(ClipboardPanelController.quickPastePlainTextIndex(forKeyCode: 18, modifiers: [.command, .option, .shift]))
assertShortcutMappings([
(18, [.command, .shift], 0),
(25, [.command, .shift], 8),
(18, .command, nil),
(18, [.command, .option, .shift], nil)
], using: ClipboardPanelController.quickPastePlainTextIndex)
}
func testCommandOptionNumberShortcutsMapToCollections() {
XCTAssertEqual(ClipboardPanelController.collectionShortcutMode(forKeyCode: 18, modifiers: [.command, .option]), .mostRecent)
XCTAssertEqual(ClipboardPanelController.collectionShortcutMode(forKeyCode: 19, modifiers: [.command, .option]), .mostUsed)
XCTAssertEqual(ClipboardPanelController.collectionShortcutMode(forKeyCode: 20, modifiers: [.command, .option]), .text)
XCTAssertEqual(ClipboardPanelController.collectionShortcutMode(forKeyCode: 21, modifiers: [.command, .option]), .links)
XCTAssertEqual(ClipboardPanelController.collectionShortcutMode(forKeyCode: 23, modifiers: [.command, .option]), .images)
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() {
XCTAssertNil(ClipboardPanelController.collectionShortcutMode(forKeyCode: 18, modifiers: []))
XCTAssertNil(ClipboardPanelController.collectionShortcutMode(forKeyCode: 18, modifiers: .command))
XCTAssertNil(ClipboardPanelController.collectionShortcutMode(forKeyCode: 29, modifiers: .command))
assertShortcutMappings([
(18, [.command, .option], .mostRecent),
(19, [.command, .option], .mostUsed),
(20, [.command, .option], .text),
(21, [.command, .option], .links),
(23, [.command, .option], .images),
(22, [.command, .option], .files),
(26, [.command, .option], .pinned),
(28, [.command, .option], .audio),
(25, [.command, .option], .colors),
(29, [.command, .option], .code),
(18, [], nil),
(18, .command, nil),
(29, .command, nil)
], using: ClipboardPanelController.collectionShortcutMode)
}
func testSearchFieldSpacePreviewShortcutRequiresEmptySearchAndNoModifiers() {
@@ -372,60 +261,35 @@ final class ClipboardPanelControllerTests: XCTestCase {
}
func testNavigationShortcutsMapToShelfMovement() {
XCTAssertEqual(ClipboardPanelController.navigationShortcutAction(forKeyCode: 115, modifiers: []), .first)
XCTAssertEqual(ClipboardPanelController.navigationShortcutAction(forKeyCode: 119, modifiers: []), .last)
XCTAssertEqual(ClipboardPanelController.navigationShortcutAction(forKeyCode: 124, modifiers: []), .next)
XCTAssertEqual(ClipboardPanelController.navigationShortcutAction(forKeyCode: 121, modifiers: []), .pageNext)
XCTAssertEqual(ClipboardPanelController.navigationShortcutAction(forKeyCode: 116, modifiers: []), .pagePrevious)
XCTAssertEqual(ClipboardPanelController.navigationShortcutAction(forKeyCode: 123, modifiers: []), .previous)
XCTAssertEqual(ClipboardPanelController.navigationShortcutAction(forKeyCode: 126, modifiers: .command), .first)
XCTAssertEqual(ClipboardPanelController.navigationShortcutAction(forKeyCode: 125, modifiers: .command), .last)
}
func testNavigationShortcutsRejectUnsupportedModifiers() {
XCTAssertNil(ClipboardPanelController.navigationShortcutAction(forKeyCode: 124, modifiers: .command))
XCTAssertNil(ClipboardPanelController.navigationShortcutAction(forKeyCode: 126, modifiers: []))
XCTAssertNil(ClipboardPanelController.navigationShortcutAction(forKeyCode: 125, modifiers: []))
XCTAssertNil(ClipboardPanelController.navigationShortcutAction(forKeyCode: 121, modifiers: .shift))
XCTAssertNil(ClipboardPanelController.navigationShortcutAction(forKeyCode: 35, modifiers: []))
assertShortcutMappings([
(115, [], .first), (119, [], .last), (124, [], .next),
(121, [], .pageNext), (116, [], .pagePrevious), (123, [], .previous),
(126, .command, .first), (125, .command, .last),
(124, .command, nil), (126, [], nil), (125, [], nil),
(121, .shift, nil), (35, [], nil)
], using: ClipboardPanelController.navigationShortcutAction)
}
func testSelectionShortcutsMapToRangeAndSelectAllActions() {
XCTAssertEqual(ClipboardPanelController.selectionShortcutAction(forKeyCode: 0, modifiers: .command), .selectAll)
XCTAssertEqual(ClipboardPanelController.selectionShortcutAction(forKeyCode: 115, modifiers: .shift), .extendFirst)
XCTAssertEqual(ClipboardPanelController.selectionShortcutAction(forKeyCode: 119, modifiers: .shift), .extendLast)
XCTAssertEqual(ClipboardPanelController.selectionShortcutAction(forKeyCode: 124, modifiers: .shift), .extendNext)
XCTAssertEqual(ClipboardPanelController.selectionShortcutAction(forKeyCode: 121, modifiers: .shift), .extendPageNext)
XCTAssertEqual(ClipboardPanelController.selectionShortcutAction(forKeyCode: 116, modifiers: .shift), .extendPagePrevious)
XCTAssertEqual(ClipboardPanelController.selectionShortcutAction(forKeyCode: 123, modifiers: .shift), .extendPrevious)
}
func testSelectionShortcutsRequireExactModifierSets() {
XCTAssertNil(ClipboardPanelController.selectionShortcutAction(forKeyCode: 0, modifiers: []))
XCTAssertNil(ClipboardPanelController.selectionShortcutAction(forKeyCode: 0, modifiers: [.command, .shift]))
XCTAssertNil(ClipboardPanelController.selectionShortcutAction(forKeyCode: 124, modifiers: []))
XCTAssertNil(ClipboardPanelController.selectionShortcutAction(forKeyCode: 124, modifiers: [.command, .shift]))
assertShortcutMappings([
(0, .command, .selectAll),
(115, .shift, .extendFirst), (119, .shift, .extendLast),
(124, .shift, .extendNext), (121, .shift, .extendPageNext),
(116, .shift, .extendPagePrevious), (123, .shift, .extendPrevious),
(0, [], nil), (0, [.command, .shift], nil),
(124, [], nil), (124, [.command, .shift], nil)
], using: ClipboardPanelController.selectionShortcutAction)
}
func testCommandActionShortcutsMapToSelectedClipActions() {
XCTAssertEqual(ClipboardPanelController.commandShortcutAction(forKeyCode: 8, modifiers: .command), .copy)
XCTAssertEqual(ClipboardPanelController.commandShortcutAction(forKeyCode: 14, modifiers: .command), .edit)
XCTAssertEqual(ClipboardPanelController.commandShortcutAction(forKeyCode: 3, modifiers: .command), .focusSearch)
XCTAssertNil(ClipboardPanelController.commandShortcutAction(forKeyCode: 45, modifiers: .command))
XCTAssertEqual(ClipboardPanelController.commandShortcutAction(forKeyCode: 5, modifiers: .command), .showInClipboard)
XCTAssertEqual(ClipboardPanelController.commandShortcutAction(forKeyCode: 16, modifiers: .command), .preview)
XCTAssertEqual(ClipboardPanelController.commandShortcutAction(forKeyCode: 31, modifiers: .command), .open)
XCTAssertEqual(ClipboardPanelController.commandShortcutAction(forKeyCode: 15, modifiers: .command), .rename)
XCTAssertEqual(ClipboardPanelController.commandShortcutAction(forKeyCode: 17, modifiers: .command), .toggleCapturePause)
XCTAssertEqual(ClipboardPanelController.commandShortcutAction(forKeyCode: 6, modifiers: .command), .undoDelete)
XCTAssertEqual(ClipboardPanelController.commandShortcutAction(forKeyCode: 123, modifiers: .command), .previousCollection)
XCTAssertEqual(ClipboardPanelController.commandShortcutAction(forKeyCode: 124, modifiers: .command), .nextCollection)
}
func testCommandActionShortcutsRequireCommandOnlySoSearchTypingIsUntouched() {
XCTAssertNil(ClipboardPanelController.commandShortcutAction(forKeyCode: 8, modifiers: []))
XCTAssertNil(ClipboardPanelController.commandShortcutAction(forKeyCode: 8, modifiers: [.command, .shift]))
XCTAssertNil(ClipboardPanelController.commandShortcutAction(forKeyCode: 9, modifiers: .command))
assertShortcutMappings([
(8, .command, .copy), (14, .command, .edit), (3, .command, .focusSearch),
(45, .command, nil), (5, .command, .showInClipboard),
(16, .command, .preview), (31, .command, .open), (15, .command, .rename),
(17, .command, .toggleCapturePause), (6, .command, .undoDelete),
(123, .command, .previousCollection), (124, .command, .nextCollection),
(8, [], nil), (8, [.command, .shift], nil), (9, .command, nil)
], using: ClipboardPanelController.commandShortcutAction)
}
func testSettingsShortcutMatchesOnlyItsExactLocalBinding() {
@@ -455,67 +319,32 @@ final class ClipboardPanelControllerTests: XCTestCase {
}
func testModifiedShortcutsMapToPanelActions() {
XCTAssertEqual(ClipboardPanelController.modifiedShortcutAction(forKeyCode: 36, modifiers: .shift), .pastePlainText)
XCTAssertEqual(ClipboardPanelController.modifiedShortcutAction(forKeyCode: 1, modifiers: [.command, .shift]), .toggleStack)
XCTAssertEqual(ClipboardPanelController.modifiedShortcutAction(forKeyCode: 8, modifiers: [.command, .shift]), .toggleStackCapture)
XCTAssertEqual(ClipboardPanelController.modifiedShortcutAction(forKeyCode: 9, modifiers: [.command, .shift]), .pastePlainText)
XCTAssertEqual(ClipboardPanelController.modifiedShortcutAction(forKeyCode: 45, modifiers: [.command, .shift]), .newCollection)
XCTAssertEqual(ClipboardPanelController.modifiedShortcutAction(forKeyCode: 36, modifiers: [.command, .shift]), .pasteStackNext)
assertShortcutMappings([
(36, .shift, .pastePlainText),
(1, [.command, .shift], .toggleStack),
(8, [.command, .shift], .toggleStackCapture),
(9, [.command, .shift], .pastePlainText),
(45, [.command, .shift], .newCollection),
(36, [.command, .shift], .pasteStackNext),
(36, [], nil), (8, .shift, nil), (8, .command, nil),
(8, [.command, .option, .shift], nil), (31, [.command, .shift], nil)
], using: ClipboardPanelController.modifiedShortcutAction)
}
func testModifiedShortcutsRequireCommandShiftOnly() {
XCTAssertNil(ClipboardPanelController.modifiedShortcutAction(forKeyCode: 36, modifiers: []))
XCTAssertNil(ClipboardPanelController.modifiedShortcutAction(forKeyCode: 8, modifiers: .shift))
XCTAssertNil(ClipboardPanelController.modifiedShortcutAction(forKeyCode: 8, modifiers: .command))
XCTAssertNil(ClipboardPanelController.modifiedShortcutAction(forKeyCode: 8, modifiers: [.command, .option, .shift]))
XCTAssertNil(ClipboardPanelController.modifiedShortcutAction(forKeyCode: 31, modifiers: [.command, .shift]))
}
private func makeController(preferredScreen: NSScreen) -> (ClipboardPanelController, ClipboardStore) {
let settings = makeSettings()
let encryptionService = ClipboardEncryptionService(keyProvider: { nil })
let cacheService = ClipboardCacheService(
baseURL: makeTempDirectory(),
encryptionService: encryptionService
)
let store = ClipboardStore(
settings: settings,
cacheService: cacheService,
baseURL: makeTempDirectory(),
encryptionService: encryptionService
)
let controller = ClipboardPanelController(
store: store,
settings: settings,
cacheService: cacheService,
preferredScreen: { preferredScreen }
)
return (controller, store)
}
private func makeSettings() -> SettingsModel {
let suite = "com.clipbored.controllertest.\(UUID().uuidString)"
defaultsSuites.append(suite)
let defaults = UserDefaults(suiteName: suite)!
let settings = SettingsModel(defaults: defaults)
settings.maxHistoryItems = 10
settings.historyRetention = .forever
settings.pruneDuplicates = false
return settings
}
private func makeTempDirectory() -> URL {
let directory = FileManager.default.temporaryDirectory
.appendingPathComponent("clipbored-controllertest")
.appendingPathComponent(UUID().uuidString)
try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
tempURLs.append(directory)
return directory
}
private func drainMainQueue() {
for _ in 0..<24 {
RunLoop.main.run(until: Date().addingTimeInterval(0.01))
private func assertShortcutMappings<Value: Equatable>(
_ cases: [(keyCode: UInt16, modifiers: NSEvent.ModifierFlags, expected: Value?)],
using mapping: (UInt16, NSEvent.ModifierFlags) -> Value?,
file: StaticString = #filePath,
line: UInt = #line
) {
for testCase in cases {
XCTAssertEqual(
mapping(testCase.keyCode, testCase.modifiers),
testCase.expected,
"keyCode \(testCase.keyCode), modifiers \(testCase.modifiers.rawValue)",
file: file,
line: line
)
}
}
}

View File

@@ -39,294 +39,6 @@ final class ClipboardPanelViewModelTests: XCTestCase {
XCTAssertEqual(pinnedOnly.map(\.payload), ["four", "one"])
}
func testSelectingCollectionWithEmptySearchUsesIndexedVisibleItems() {
let settings = makeSettings()
settings.maxHistoryItems = 260
let cacheService = makeCacheService()
let store = makeStore(settings: settings, cacheService: cacheService)
for index in 0..<220 {
store.upsert(makeTextItem("outside indexed category \(index)", createdAt: Date(timeIntervalSince1970: Double(index))))
}
for index in 0..<30 {
var item = makeTextItem("client indexed category \(index)", createdAt: Date(timeIntervalSince1970: Double(1_000 + index)))
item.collectionName = "Client Work"
store.upsert(item)
}
store.flushPersistenceForTesting()
let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: cacheService)
waitForVisibleItems(in: viewModel, count: 250)
viewModel.debugResetVisibleItemsPerformanceCounters()
viewModel.selectCollection(named: "Client Work")
XCTAssertEqual(viewModel.visibleItems.count, 30)
XCTAssertEqual(viewModel.debugVisibleItemsFullScanCount, 0)
XCTAssertEqual(viewModel.debugVisibleItemsIndexedLookupCount, 1)
XCTAssertEqual(viewModel.visibleItems.first?.payload, "client indexed category 29")
}
func testRepeatedThirtyItemCollectionSelectionsStayOnIndexedFastPath() {
let collectionCount = 60
let itemsPerCollection = 30
let settings = makeSettings()
settings.maxHistoryItems = collectionCount * itemsPerCollection
let cacheService = makeCacheService()
let store = makeStore(settings: settings, cacheService: cacheService)
let collectionNames = (0..<collectionCount).map { "Client Work \($0)" }
for (collectionIndex, collectionName) in collectionNames.enumerated() {
for itemIndex in 0..<itemsPerCollection {
var item = makeTextItem(
"\(collectionName) clip \(itemIndex)",
createdAt: Date(timeIntervalSince1970: Double((collectionIndex * itemsPerCollection) + itemIndex))
)
item.collectionName = collectionName
store.upsert(item)
}
}
store.flushPersistenceForTesting()
let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: cacheService)
waitForVisibleItems(in: viewModel, count: collectionCount * itemsPerCollection)
viewModel.debugResetVisibleItemsPerformanceCounters()
let start = CFAbsoluteTimeGetCurrent()
for collectionName in collectionNames {
viewModel.selectCollection(named: collectionName)
XCTAssertEqual(viewModel.visibleItems.count, itemsPerCollection)
}
let elapsed = CFAbsoluteTimeGetCurrent() - start
XCTAssertEqual(viewModel.debugVisibleItemsFullScanCount, 0)
XCTAssertEqual(viewModel.debugVisibleItemsIndexedLookupCount, collectionCount)
XCTAssertLessThan(elapsed, 0.20)
}
func testCommandSelectedCategoriesUseIndexedUnionFastPath() {
let settings = makeSettings()
let cacheService = makeCacheService()
let store = makeStore(settings: settings, cacheService: cacheService)
let text = makeTextItem("category text", createdAt: Date(timeIntervalSince1970: 100))
let link = ClipboardItem(
id: UUID(),
kind: .url,
displayText: "category link",
payload: "https://example.com/category",
payloadHash: store.hashString("https://example.com/category"),
createdAt: Date(timeIntervalSince1970: 200),
lastUsedAt: Date(timeIntervalSince1970: 200),
useCount: 0,
sourceApp: nil,
imagePath: nil,
thumbnailPath: nil
)
store.upsert(text)
store.upsert(link)
let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: cacheService)
waitForVisibleItems(in: viewModel, count: 2)
viewModel.debugResetVisibleItemsPerformanceCounters()
viewModel.selectSortMode(.text, extending: true)
viewModel.selectSortMode(.links, extending: true)
XCTAssertEqual(viewModel.visibleItems.map(\.payload), ["https://example.com/category", "category text"])
XCTAssertEqual(viewModel.debugVisibleItemsFullScanCount, 0)
XCTAssertEqual(viewModel.debugVisibleItemsIndexedLookupCount, 2)
XCTAssertTrue(viewModel.isSortModeCategorySelected(.text))
XCTAssertTrue(viewModel.isSortModeCategorySelected(.links))
}
func testCommandSelectedCustomCollectionCombinesWithCategoryFilter() {
let settings = makeSettings()
let cacheService = makeCacheService()
let store = makeStore(settings: settings, cacheService: cacheService)
var client = makeTextItem("client note", createdAt: Date(timeIntervalSince1970: 100))
client.collectionName = "Client Work"
let link = ClipboardItem(
id: UUID(),
kind: .url,
displayText: "category link",
payload: "https://example.com/category",
payloadHash: store.hashString("https://example.com/category"),
createdAt: Date(timeIntervalSince1970: 200),
lastUsedAt: Date(timeIntervalSince1970: 200),
useCount: 0,
sourceApp: nil,
imagePath: nil,
thumbnailPath: nil
)
store.upsert(client)
store.upsert(link)
let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: cacheService)
waitForVisibleItems(in: viewModel, count: 2)
viewModel.debugResetVisibleItemsPerformanceCounters()
viewModel.selectCollection(named: "Client Work")
viewModel.selectSortMode(.links, extending: true)
XCTAssertEqual(viewModel.visibleItems.map(\.payload), ["https://example.com/category", "client note"])
XCTAssertEqual(viewModel.debugVisibleItemsFullScanCount, 0)
XCTAssertEqual(viewModel.debugVisibleItemsIndexedLookupCount, 2)
XCTAssertTrue(viewModel.isCollectionCategorySelected(named: "Client Work"))
XCTAssertTrue(viewModel.isSortModeCategorySelected(.links))
}
func testEmptySearchCollectionCountSummaryUsesIndexedCounts() {
let settings = makeSettings()
settings.maxHistoryItems = 260
let cacheService = makeCacheService()
let store = makeStore(settings: settings, cacheService: cacheService)
for index in 0..<220 {
store.upsert(makeTextItem("outside indexed count \(index)", createdAt: Date(timeIntervalSince1970: Double(index))))
}
for index in 0..<30 {
var item = makeTextItem("client indexed count \(index)", createdAt: Date(timeIntervalSince1970: Double(1_000 + index)))
item.collectionName = "Client Work"
store.upsert(item)
}
store.flushPersistenceForTesting()
let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: cacheService)
waitForVisibleItems(in: viewModel, count: 250)
viewModel.debugResetVisibleItemsPerformanceCounters()
let counts = viewModel.collectionCountSummary()
XCTAssertEqual(counts.count(for: .mostRecent), 250)
XCTAssertEqual(counts.count(for: .text), 250)
XCTAssertEqual(counts.count(named: "Client Work"), 30)
XCTAssertEqual(viewModel.debugCollectionCountFullScanCount, 0)
XCTAssertEqual(viewModel.debugCollectionCountIndexedLookupCount, 1)
}
func testSearchMatchesAreEvaluatedOnceAcrossVisibleItemsCountsAndCategoryChanges() {
let settings = makeSettings()
settings.maxHistoryItems = 120
let cacheService = makeCacheService()
let store = makeStore(settings: settings, cacheService: cacheService)
for index in 0..<100 {
var item = makeTextItem(
index.isMultiple(of: 5) ? "shared search needle \(index)" : "unmatched history \(index)",
createdAt: Date(timeIntervalSince1970: Double(index))
)
if index.isMultiple(of: 2) {
item.collectionName = "Client Work"
}
store.upsert(item)
}
store.flushPersistenceForTesting()
let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: cacheService)
waitForVisibleItems(in: viewModel, count: 100)
viewModel.debugResetVisibleItemsPerformanceCounters()
viewModel.searchText = "needle"
XCTAssertEqual(viewModel.visibleItems.count, 20)
XCTAssertEqual(viewModel.debugSearchItemEvaluationCount, 100)
XCTAssertEqual(viewModel.debugSearchDocumentBuildCount, 100)
let counts = viewModel.collectionCountSummary()
XCTAssertEqual(counts.count(for: .text), 20)
XCTAssertEqual(counts.count(named: "Client Work"), 10)
XCTAssertEqual(viewModel.debugSearchItemEvaluationCount, 100)
XCTAssertEqual(viewModel.debugSearchMatchCacheHitCount, 1)
viewModel.selectSortMode(.text, extending: true)
viewModel.selectSortMode(.links, extending: true)
XCTAssertEqual(viewModel.visibleItems.count, 20)
XCTAssertEqual(viewModel.debugSearchItemEvaluationCount, 100)
XCTAssertEqual(viewModel.debugSearchMatchCacheHitCount, 3)
viewModel.searchText = "shared"
XCTAssertEqual(viewModel.visibleItems.count, 20)
XCTAssertEqual(viewModel.debugSearchItemEvaluationCount, 200)
XCTAssertEqual(viewModel.debugSearchDocumentBuildCount, 100)
XCTAssertEqual(viewModel.debugSearchDocumentCacheHitCount, 100)
}
func testEquivalentDiacriticSearchReusesVisibleAndCollectionCountCaches() {
let settings = makeSettings()
let cacheService = makeCacheService()
let store = makeStore(settings: settings, cacheService: cacheService)
store.upsert(makeTextItem("Résumé draft", createdAt: Date(timeIntervalSince1970: 100)))
store.upsert(makeTextItem("Meeting note", createdAt: Date(timeIntervalSince1970: 200)))
store.flushPersistenceForTesting()
let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: cacheService)
waitForVisibleItems(in: viewModel, count: 2)
viewModel.debugResetVisibleItemsPerformanceCounters()
viewModel.searchText = "resume"
XCTAssertEqual(viewModel.visibleItems.map(\.payload), ["Résumé draft"])
_ = viewModel.collectionCountSummary()
XCTAssertEqual(viewModel.debugSearchItemEvaluationCount, 2)
XCTAssertEqual(viewModel.debugCollectionCountFullScanCount, 1)
viewModel.searchText = " RÉSUMÉ "
_ = viewModel.collectionCountSummary()
XCTAssertEqual(viewModel.visibleItems.map(\.payload), ["Résumé draft"])
XCTAssertEqual(viewModel.debugSearchItemEvaluationCount, 2)
XCTAssertEqual(viewModel.debugCollectionCountFullScanCount, 1)
}
func testCategoryFilterSelectionIsBuiltOncePerMutationAndReusedForChipStateQueries() {
let settings = makeSettings()
let cacheService = makeCacheService()
let store = makeStore(settings: settings, cacheService: cacheService)
store.upsert(makeTextItem("category state", createdAt: Date(timeIntervalSince1970: 100)))
store.flushPersistenceForTesting()
let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: cacheService)
waitForVisibleItems(in: viewModel, count: 1)
viewModel.debugResetVisibleItemsPerformanceCounters()
viewModel.selectSortMode(.text, extending: true)
XCTAssertEqual(viewModel.debugCategoryFilterSelectionBuildCount, 1)
for _ in 0..<20 {
for mode in ClipboardSortMode.allCases {
_ = viewModel.isSortModeCategorySelected(mode)
}
_ = viewModel.isCollectionCategorySelected(named: "Client Work")
_ = viewModel.canShowVisibleItemsInClipboard
}
XCTAssertEqual(viewModel.debugCategoryFilterSelectionBuildCount, 1)
}
func testRepeatedRecomputesDoNotRescanUnchangedStackMembership() {
let settings = makeSettings()
let cacheService = makeCacheService()
let store = makeStore(settings: settings, cacheService: cacheService)
store.upsert(makeTextItem("stacked needle", createdAt: Date(timeIntervalSince1970: 100)))
store.upsert(makeTextItem("outside note", createdAt: Date(timeIntervalSince1970: 200)))
store.flushPersistenceForTesting()
let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: cacheService)
waitForVisibleItems(in: viewModel, count: 2)
viewModel.selectItem(at: 1)
viewModel.toggleSelectedStackMembership()
viewModel.debugResetVisibleItemsPerformanceCounters()
viewModel.searchText = "needle"
viewModel.clearSearch()
viewModel.selectSortMode(.text)
viewModel.selectSortMode(.mostRecent)
XCTAssertEqual(viewModel.stackCount, 1)
XCTAssertEqual(viewModel.debugStackPruneScanCount, 0)
}
func testRepeatedHoverSelectionDoesNotNotifyAnUnchangedSelection() {
let settings = makeSettings()
@@ -405,139 +117,64 @@ final class ClipboardPanelViewModelTests: XCTestCase {
XCTAssertEqual(completionMainThreadValues, [true, true])
}
func testComputeVisibleItemsFiltersColorClipsAndStructuredType() {
func testContentKindsSupportCategoryStructuredAndTextSearch() {
let settings = makeSettings()
let store = makeStore(settings: settings)
let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: makeCacheService())
let color = ClipboardItem(
id: UUID(),
let color = makeItem(
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
timestamp: 100,
sourceApp: "Design Tool"
)
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: makeCacheService())
let code = ClipboardItem(
id: UUID(),
let code = makeItem(
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
timestamp: 300,
sourceApp: "Xcode"
)
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: makeCacheService())
let video = ClipboardItem(
id: UUID(),
let video = makeItem(
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
timestamp: 200,
sourceApp: "QuickTime Player"
)
let image = ClipboardItem(
id: UUID(),
let text = makeItem(
kind: .text,
displayText: "Meeting note",
payload: "Meeting note",
timestamp: 100,
sourceApp: "Notes"
)
let image = makeItem(
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]
timestamp: 100,
sourceApp: "Preview"
)
let items = [color, code, video, text, image]
let cases: [(ClipboardSortMode, String, [ClipboardItemKind])] = [
(.colors, "", [.color]),
(.mostRecent, "type:swatch", [.color]),
(.mostRecent, "hex 0a84ff", [.color]),
(.code, "", [.code]),
(.text, "", [.code, .text]),
(.mostRecent, "type:snippet greet", [.code]),
(.videos, "", [.video]),
(.mostRecent, "type:movie", [.video]),
(.mostRecent, "mp4", [.video])
]
for (sortMode, query, expectedKinds) in cases {
XCTAssertEqual(
viewModel.computeVisibleItems(from: items, query: query, sortMode: sortMode).map(\.kind),
expectedKinds,
"sort=\(sortMode), query=\(query)"
)
}
}
func testSearchMatchesIndependentTokensCaseInsensitively() {
@@ -2547,7 +2184,7 @@ final class ClipboardPanelViewModelTests: XCTestCase {
viewModel.searchText = "second"
XCTAssertEqual(viewModel.visibleItems.map(\.payload), ["second queue note"])
viewModel.clearSearch()
viewModel.searchText = ""
viewModel.sortMode = .text
XCTAssertFalse(viewModel.isStackFilterSelected)
XCTAssertEqual(viewModel.visibleItems.map(\.payload), ["outside note", "second queue note", "first queue note"])
@@ -2566,7 +2203,7 @@ final class ClipboardPanelViewModelTests: XCTestCase {
viewModel.searchText = "draft"
XCTAssertEqual(viewModel.visibleItems.map(\.id), [item.id])
viewModel.clearSearch()
viewModel.searchText = ""
XCTAssertEqual(viewModel.editableTextForSelected(), "draft meeting note")
viewModel.updateSelectedText(to: "final launch note")
@@ -2634,8 +2271,8 @@ final class ClipboardPanelViewModelTests: XCTestCase {
let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: cacheService)
waitForVisibleItems(in: viewModel, count: 2)
XCTAssertNil(viewModel.editableTextForItem(at: 0))
viewModel.selectItem(at: 0)
XCTAssertNil(viewModel.editableTextForSelected())
viewModel.updateSelectedText(to: "should not apply")
XCTAssertEqual(store.items.first?.payload, file.payload)
@@ -2963,18 +2600,32 @@ final class ClipboardPanelViewModelTests: XCTestCase {
}
private func makeTextItem(_ text: String, createdAt: Date) -> ClipboardItem {
makeItem(kind: .text, displayText: text, payload: text, timestamp: createdAt.timeIntervalSince1970)
}
private func makeItem(
kind: ClipboardItemKind,
displayText: String,
payload: String,
timestamp: TimeInterval,
lastUsedTimestamp: TimeInterval? = nil,
useCount: Int = 0,
sourceApp: String? = nil,
isPinned: Bool = false
) -> ClipboardItem {
ClipboardItem(
id: UUID(),
kind: .text,
displayText: text,
payload: text,
payloadHash: hash(text),
createdAt: createdAt,
lastUsedAt: createdAt,
useCount: 0,
sourceApp: nil,
kind: kind,
displayText: displayText,
payload: payload,
payloadHash: hash(payload),
createdAt: Date(timeIntervalSince1970: timestamp),
lastUsedAt: Date(timeIntervalSince1970: lastUsedTimestamp ?? timestamp),
useCount: useCount,
sourceApp: sourceApp,
imagePath: nil,
thumbnailPath: nil
thumbnailPath: nil,
isPinned: isPinned
)
}
@@ -3031,88 +2682,56 @@ final class ClipboardPanelViewModelTests: XCTestCase {
private func makeSampleItems() -> [ClipboardItem] {
[
ClipboardItem(
id: UUID(),
makeItem(
kind: .text,
displayText: "Project notes",
payload: "one",
payloadHash: hash("one"),
createdAt: Date(timeIntervalSince1970: 1000),
lastUsedAt: Date(timeIntervalSince1970: 1000),
timestamp: 1000,
useCount: 2,
sourceApp: nil,
imagePath: nil,
thumbnailPath: nil,
isPinned: true
),
ClipboardItem(
id: UUID(),
makeItem(
kind: .richText,
displayText: "Two",
payload: "two",
payloadHash: hash("two"),
createdAt: Date(timeIntervalSince1970: 1100),
lastUsedAt: Date(timeIntervalSince1970: 1080),
timestamp: 1100,
lastUsedTimestamp: 1080,
useCount: 4,
sourceApp: "Mail",
imagePath: nil,
thumbnailPath: nil,
isPinned: false
sourceApp: "Mail"
),
ClipboardItem(
id: UUID(),
makeItem(
kind: .url,
displayText: "Apple",
payload: "https://apple.com",
payloadHash: hash("https://apple.com"),
createdAt: Date(timeIntervalSince1970: 1030),
lastUsedAt: Date(timeIntervalSince1970: 1050),
timestamp: 1030,
lastUsedTimestamp: 1050,
useCount: 1,
sourceApp: "Safari",
imagePath: nil,
thumbnailPath: nil,
isPinned: false
sourceApp: "Safari"
),
ClipboardItem(
id: UUID(),
makeItem(
kind: .file,
displayText: "report.pdf",
payload: "/tmp/report.pdf",
payloadHash: hash("/tmp/report.pdf"),
createdAt: Date(timeIntervalSince1970: 1060),
lastUsedAt: Date(timeIntervalSince1970: 1070),
timestamp: 1060,
lastUsedTimestamp: 1070,
useCount: 3,
sourceApp: "Finder",
imagePath: nil,
thumbnailPath: nil,
isPinned: false
sourceApp: "Finder"
),
ClipboardItem(
id: UUID(),
makeItem(
kind: .audio,
displayText: "Voice memo",
payload: "/tmp/voice.sound",
payloadHash: hash("/tmp/voice.sound"),
createdAt: Date(timeIntervalSince1970: 1040),
lastUsedAt: Date(timeIntervalSince1970: 1060),
timestamp: 1040,
lastUsedTimestamp: 1060,
useCount: 2,
sourceApp: "Voice Memos",
imagePath: nil,
thumbnailPath: nil,
isPinned: false
sourceApp: "Voice Memos"
),
ClipboardItem(
id: UUID(),
makeItem(
kind: .text,
displayText: "Four",
payload: "four",
payloadHash: hash("four"),
createdAt: Date(timeIntervalSince1970: 1200),
lastUsedAt: Date(timeIntervalSince1970: 1200),
useCount: 0,
timestamp: 1200,
sourceApp: "Notes",
imagePath: nil,
thumbnailPath: nil,
isPinned: true
)
]

File diff suppressed because it is too large Load Diff

View File

@@ -1,26 +0,0 @@
import XCTest
@testable import ClipBored
final class DiagnosticsServiceTests: XCTestCase {
func testCountersCanBeIncrementedAndReset() {
let diagnostics = DiagnosticsService.shared
diagnostics.reset()
diagnostics.incrementMonitorTick()
diagnostics.incrementPasteboardChange()
diagnostics.incrementExtractionAttempt()
diagnostics.incrementDatabaseMutation()
diagnostics.incrementCachePurge()
// The snapshot read synchronizes with the serial diagnostics queue.
let snapshot = diagnostics.currentSnapshot()
XCTAssertEqual(snapshot.monitorTicks, 1)
XCTAssertEqual(snapshot.pasteboardChanges, 1)
XCTAssertEqual(snapshot.extractionAttempts, 1)
XCTAssertEqual(snapshot.databaseMutations, 1)
XCTAssertEqual(snapshot.cachePurges, 1)
diagnostics.reset()
XCTAssertEqual(diagnostics.currentSnapshot(), .init(monitorTicks: 0, pasteboardChanges: 0, extractionAttempts: 0, databaseMutations: 0, cachePurges: 0))
}
}

View File

@@ -1,81 +0,0 @@
import XCTest
@testable import ClipBored
final class LinkPreviewWindowControllerTests: XCTestCase {
func testReusedPreviewIgnoresStaleObservedTitleUntilNewNavigationStarts() throws {
let controller = LinkPreviewWindowController()
let firstURL = try XCTUnwrap(URL(string: "https://example.com/old"))
let secondURL = try XCTUnwrap(URL(string: "https://example.com/new"))
controller.debugPrepareForPreview(LinkPreviewRequest(url: firstURL, title: "Old request title"))
controller.debugAllowObservedPageTitles()
controller.debugApplyObservedPageTitle("Old loaded title")
XCTAssertEqual(controller.debugTitleText, "Old loaded title")
controller.debugPrepareForPreview(LinkPreviewRequest(url: secondURL, title: "New request title"))
controller.debugApplyObservedPageTitle("Old loaded title")
XCTAssertEqual(controller.debugTitleText, "New request title")
XCTAssertEqual(controller.debugAddressText, "https://example.com/new")
XCTAssertEqual(controller.debugStatusText, "Loading")
controller.debugAllowObservedPageTitles()
controller.debugApplyObservedPageTitle("New loaded title")
XCTAssertEqual(controller.debugTitleText, "New loaded title")
}
func testCancelledNavigationDoesNotShowFalseLoadFailure() throws {
let controller = LinkPreviewWindowController()
let url = try XCTUnwrap(URL(string: "https://example.com/new"))
controller.debugPrepareForPreview(LinkPreviewRequest(url: url, title: "New request title"))
controller.debugApplyNavigationFailure(URLError(.cancelled))
XCTAssertEqual(controller.debugStatusText, "Loading")
controller.debugApplyNavigationFailure(URLError(.timedOut))
XCTAssertEqual(controller.debugStatusText, "Could not load")
}
func testToolbarTooltipsTrackFullVisibleText() throws {
let controller = LinkPreviewWindowController()
let url = try XCTUnwrap(URL(string: "https://example.com/articles/a-very-long-release-note-title?ref=clipbored"))
controller.debugPrepareForPreview(LinkPreviewRequest(url: url, title: "Release note with a long title"))
XCTAssertEqual(controller.debugTitleTooltip, "Release note with a long title")
XCTAssertEqual(controller.debugAddressTooltip, url.absoluteString)
XCTAssertEqual(controller.debugStatusTooltip, "Loading")
controller.debugAllowObservedPageTitles()
controller.debugApplyObservedPageTitle("Loaded page title that may truncate in the toolbar")
XCTAssertEqual(controller.debugTitleTooltip, "Loaded page title that may truncate in the toolbar")
controller.debugApplyNavigationFailure(URLError(.timedOut))
XCTAssertEqual(controller.debugStatusTooltip, "Could not load")
}
func testOpenInBrowserUsesDisplayedPageURLAndResetsForReusedPreview() throws {
var openedURLs: [URL] = []
let controller = LinkPreviewWindowController { openedURLs.append($0) }
let firstURL = try XCTUnwrap(URL(string: "https://example.com/old"))
let navigatedURL = try XCTUnwrap(URL(string: "https://example.com/old/details"))
let secondURL = try XCTUnwrap(URL(string: "https://example.com/new"))
controller.debugPrepareForPreview(LinkPreviewRequest(url: firstURL, title: "Old request title"))
controller.debugSetDisplayedPageURL(navigatedURL)
controller.debugOpenInBrowser()
XCTAssertEqual(openedURLs, [navigatedURL])
controller.debugPrepareForPreview(LinkPreviewRequest(url: secondURL, title: "New request title"))
controller.debugOpenInBrowser()
XCTAssertEqual(openedURLs, [navigatedURL, secondURL])
}
}

View File

@@ -51,25 +51,4 @@ final class OnboardingWindowControllerTests: XCTestCase {
XCTAssertTrue(presentation.showDockIcon)
}
func testDisplayedEntryPointSelectionIsNormalizedOnRefresh() {
let settings = makeSettings()
settings.showMenuBarIcon = false
settings.showDockIcon = false
let controller = OnboardingWindowController(
settings: settings,
onOpenAccessibility: {},
onFinish: {}
)
XCTAssertTrue(controller.debugShowMenuBarIconIsEnabled)
XCTAssertFalse(controller.debugShowDockIconIsEnabled)
}
private func makeSettings() -> SettingsModel {
let suiteName = "com.clipbored.onboarding.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suiteName)!
defaults.removePersistentDomain(forName: suiteName)
return SettingsModel(defaults: defaults)
}
}

View File

@@ -43,6 +43,7 @@ final class SensitiveContentDetectorTests: XCTestCase {
func testDetectsCreditCardWithLuhnCheck() {
XCTAssertEqual(SensitiveContentDetector.detect("4242424242424242"), .creditCard)
XCTAssertEqual(SensitiveContentDetector.detect("Card: 4242 4242 4242 4242"), .creditCard)
XCTAssertNil(SensitiveContentDetector.detect("4242424242424241"))
}

View File

@@ -0,0 +1,117 @@
import AppKit
import XCTest
@testable import ClipBored
final class SettingsPresentationTests: XCTestCase {
func testPasteAndDataStatusColors() {
let pasteCases: [(String, NSColor)] = [
("", .secondaryLabelColor),
("Pasted", .systemGreen),
("Copied. Grant Accessibility access to paste automatically.", .systemOrange),
("Could not write item to clipboard.", .systemRed)
]
for (message, color) in pasteCases {
XCTAssertEqual(SettingsWindowController.pasteStatusPresentation(storedStatus: message).textColor, color)
}
let dataCases: [(String, NSColor)] = [
("", .secondaryLabelColor),
("Exported 3 clips.", .systemGreen),
("Imported 3 clips. Skipped 1 clip.", .systemOrange),
("The archive couldn't be opened.", .systemRed)
]
for (message, color) in dataCases {
XCTAssertEqual(SettingsWindowController.dataStatusPresentation(storedStatus: message).textColor, color)
}
}
func testCaptureStatusColors() {
let cases: [(String, NSColor)] = [
("", .secondaryLabelColor),
("Captured text from Safari.", .systemGreen),
("Skipped: Audio items are ignored.", .systemOrange),
("At least one content type must stay enabled.", .systemOrange),
("Error: Clipboard read failed.", .systemRed)
]
for (message, color) in cases {
XCTAssertEqual(SettingsWindowController.captureStatusPresentation(storedStatus: message).textColor, color)
}
}
func testShortcutPermissionAndLifecycleStatusColors() {
XCTAssertEqual(
SettingsWindowController.shortcutStatusPresentation(storedStatus: "").textColor,
.systemGreen
)
XCTAssertEqual(
SettingsWindowController.shortcutStatusPresentation(storedStatus: "Unsupported shortcut").textColor,
.systemRed
)
XCTAssertEqual(
SettingsWindowController.accessibilityPermissionStatusPresentation(
storedStatus: "",
isTrusted: true
).textColor,
.systemGreen
)
XCTAssertEqual(
SettingsWindowController.accessibilityPermissionStatusPresentation(
storedStatus: "Permission not granted",
isTrusted: true
).textColor,
.systemOrange
)
XCTAssertEqual(
SettingsWindowController.launchAtLoginStatusPresentation(storedStatus: "Service unavailable").textColor,
.systemRed
)
}
func testCloudSyncStatusPrecedence() {
let ready = ClipboardCloudSyncStatus(
isAvailable: true,
archiveURL: nil,
lastModifiedAt: nil,
message: "iCloud is ready."
)
let unavailable = ClipboardCloudSyncStatus(
isAvailable: false,
archiveURL: nil,
lastModifiedAt: nil,
message: "iCloud is unavailable."
)
XCTAssertEqual(
SettingsWindowController.cloudSyncStatusPresentation(
storedStatus: "",
isSyncEnabled: false,
cloudStatus: ready
).message,
"iCloud Sync is off."
)
XCTAssertEqual(
SettingsWindowController.cloudSyncStatusPresentation(
storedStatus: "",
isSyncEnabled: true,
cloudStatus: unavailable
).textColor,
.systemOrange
)
XCTAssertEqual(
SettingsWindowController.cloudSyncStatusPresentation(
storedStatus: "Synced 3 clips.",
isSyncEnabled: true,
cloudStatus: ready
).textColor,
.systemGreen
)
XCTAssertEqual(
SettingsWindowController.cloudSyncStatusPresentation(
storedStatus: "iCloud Sync failed.",
isSyncEnabled: true,
cloudStatus: ready
).textColor,
.systemRed
)
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -36,13 +36,6 @@ final class ShortcutManagerTests: XCTestCase {
manager.stop()
}
func testGlobalRegistrationExcludesLocalSettingsShortcut() {
let bindings = ShortcutManager.globalShortcutBindings(openShortcut: AppConfiguration.defaultOpenShortcut)
XCTAssertEqual(bindings, [AppConfiguration.defaultOpenShortcut, ShortcutManager.stackCaptureShortcut])
XCTAssertFalse(bindings.contains(AppConfiguration.defaultSettingsShortcut))
}
func testRejectsConfiguredShortcutConflictWithFixedStackCaptureShortcut() {
let manager = makeManager(openShortcut: ShortcutManager.stackCaptureShortcut)