This commit is contained in:
@@ -22,6 +22,7 @@ let package = Package(
|
||||
.linkedFramework("QuickLookUI"),
|
||||
.linkedFramework("Security"),
|
||||
.linkedFramework("Vision"),
|
||||
.linkedFramework("WebKit"),
|
||||
.linkedLibrary("sqlite3")
|
||||
]
|
||||
),
|
||||
|
||||
48
README.md
48
README.md
@@ -1,33 +1,51 @@
|
||||
# ClipBored
|
||||
|
||||
ClipBored is a small native macOS clipboard manager. It captures local clipboard history and opens a keyboard-first responsive bottom panel for search, sorting, copy, paste, pinning, and deletion. It runs as a dockless menu-bar utility by default, with an optional Dock icon mode.
|
||||
ClipBored is a small native macOS clipboard manager. It captures local clipboard history and opens a keyboard-first side panel for search, sorting, copy, paste, pinning, and deletion. It runs as a dockless menu-bar utility by default, with an optional Dock icon mode.
|
||||
|
||||
The project is intentionally dependency-light: Swift Package Manager, AppKit, Carbon hotkeys, SQLite, and system frameworks only.
|
||||
|
||||
## Features
|
||||
|
||||
- Dockless menu-bar utility by default (`LSUIElement=true`), with a Settings toggle for normal Dock presence
|
||||
- Right-click menu-bar status menu with capture state, history count, settings, pause/resume, and quit
|
||||
- First-run setup assistant for the open shortcut, Keep History retention, menu-bar/Dock presence, launch-at-login, iCloud sync, and Accessibility permission
|
||||
- Right-click menu-bar status menu with capture state, history count, settings, manual or timed pause/resume, and quit
|
||||
- Global shortcuts:
|
||||
- `Command + Option + V` toggles the clipboard panel
|
||||
- `Shift + Command + V` or the configured open shortcut toggles the clipboard panel
|
||||
- `Command + ,` opens settings
|
||||
- `Command + F` focuses search; press it again while search is active to show filters
|
||||
- `Command + 1` through `Command + 9` paste the numbered visible card; add `Shift` to paste that card as plain text
|
||||
- `Return` pastes the selected clip; `Shift + Return` or `Command + Shift + V` pastes it as plain text
|
||||
- `Command + C` copies the selected clip or selected clips
|
||||
- `Command + E` edits the selected text or code clip
|
||||
- `Command + R` renames the selected clip
|
||||
- `Delete` removes selected clips, and `Command + Z` restores the last deleted batch
|
||||
- `Command + G` shows a filtered result back in the full clipboard history
|
||||
- `Command + O` opens the selected link, file, or media clip when possible
|
||||
- `Command + N` creates a new text clip
|
||||
- `Shift + Command + N` creates a new collection
|
||||
- `Space` previews the selected card when the focused search field is empty
|
||||
- `Shift + Command + C` toggles Stack capture mode for queued multi-paste workflows
|
||||
- `Command + Left` and `Command + Right` move between collections
|
||||
- `Command + Up` and `Command + Down` jump to the first or last visible clip
|
||||
- `Command + T` pauses or resumes clipboard capture
|
||||
- `Command + A` selects the visible shelf from a focused card; `Shift` + shelf navigation extends the selected range
|
||||
- `Space` or `Command + Y` previews the selected card when the focused search field is empty
|
||||
- Clipboard history for text, URLs with local preview thumbnails when available, images, audio, video with movie thumbnails when available, RTF/HTML rich text, PDFs, and file references
|
||||
- Keyboard-focusable cards and collection chips with type-to-search, Return-to-paste/select, Space-to-preview for text, links, files, and media, vertical wheel/trackpad panning and overflow edge fades in horizontal rails, visible focus chrome, and VoiceOver action hints
|
||||
- Shelf navigation keys for focused cards: Left/Right, Page Up/Page Down, Home, and End
|
||||
- Keyboard-focusable compact rows and collection chips with type-to-search, Return-to-paste/select, Space-to-preview for text, built-in browser previews for links, Quick Look for files and media, remote copied-on device metadata, image rotation and text extraction quick actions, vertical wheel/trackpad panning, visible focus chrome, and VoiceOver action hints
|
||||
- Shelf navigation keys for focused cards: Left/Right, Page Up/Page Down, Home, and End; add `Shift` to extend the selected range
|
||||
- Shelf navigation keys for focused collection chips: Left/Right, Home, and End
|
||||
- SQLite persistence with bounded history, pinned-item retention, and encrypted app-managed payloads
|
||||
- Search with independent token matching, structured filters such as `app:Safari`, `type:image,pdf`, `pinboard:"Client Work","Read Later"`, `date:2026-06-30`, result jump-back to full history, and optional local OCR for copied images
|
||||
- SQLite persistence with bounded history, time-based retention, pinned-item and Pinboard retention, and encrypted app-managed payloads
|
||||
- Search with independent token matching, a Paste-style filter menu, structured filters such as `app:Safari`, `type:image,pdf`, `device:MacBook`, `pinboard:"Client Work","Read Later"`, `date:2026-06-30`, result jump-back to full history, optional local OCR for copied images, and on-demand OCR from image cards
|
||||
- Sort modes for recent, most used, images, links, text, files, audio, and pinned items
|
||||
- Custom named collections, including empty color-coded collections, for organizing clips from the card Collect control, context menu, keyboard-focusable collection rail, or by dragging cards onto collection chips; collection chips can be edited or deleted from their context menu
|
||||
- Custom named collections that behave as durable Pinboards, including empty color-coded collections, for organizing clips from the card Collect control, context menu, keyboard-focusable collection rail, direct text-clip creation, or by dragging cards onto collection chips; collection chips can be edited or deleted from their context menu
|
||||
- Pinboard-level archive export from a collection chip for sharing one Pinboard, including empty Pinboards and color metadata
|
||||
- Plain-text clip creation and editing with native macOS Writing Tools support when available
|
||||
- Searchable custom titles for clips, so media, files, links, PDFs, audio, and text can be renamed without changing the copied payload
|
||||
- Copy and paste actions with Accessibility permission fallback, including Stack next-item actions and Stack-as-text batch copy/paste
|
||||
- Copy and paste actions with Accessibility permission fallback, including Command/Shift multi-select original-format or plain-text batching, Stack capture mode, Stack next-item actions, and Stack-as-text batch copy/paste
|
||||
- Image thumbnail cache with byte and file-count pruning
|
||||
- Configurable history length, cache limit, polling profile, ignored apps, content kinds, launch-at-login, Dock/menu-bar presence, and clear-on-quit behavior, with card-level capture rules for ignoring a source app or content type
|
||||
- Local-only storage, with optional sensitive-content exclusion for common secrets
|
||||
- Portable local archive export/import for moving history, Pinboards, and app-managed image/audio/video/rich-text/PDF attachments between Macs; external file references stay path-based
|
||||
- Optional iCloud archive sync for signed builds with an iCloud entitlement, using ClipBored's app-private ubiquity container and the same portable archive format
|
||||
- Configurable left or right shelf side, Keep History retention, history length, cache limit, polling profile, ignored apps, content kinds, launch-at-login, Dock/menu-bar presence, screen-capture privacy, temporary capture pause, and clear-on-quit behavior, with card-level capture rules for ignoring a source app or content type
|
||||
- Local-first storage, with optional sensitive-content exclusion for common secrets and iCloud sync disabled by default
|
||||
|
||||
## Requirements
|
||||
|
||||
@@ -42,7 +60,7 @@ swift test
|
||||
open build/ClipBored.app
|
||||
```
|
||||
|
||||
The build script packages `build/ClipBored.app`, strips the executable, applies an ad-hoc hardened-runtime signature, and enforces a 1 MiB executable gate plus a 1.8 MB bundle gate.
|
||||
The build script packages `build/ClipBored.app`, strips the executable, applies an ad-hoc hardened-runtime signature, and enforces 2 MiB gates for both the executable and app bundle.
|
||||
|
||||
## Development
|
||||
|
||||
@@ -78,9 +96,9 @@ Project layout:
|
||||
|
||||
## Privacy And Security
|
||||
|
||||
ClipBored does not use network APIs or telemetry. Clipboard history is stored locally under Application Support.
|
||||
ClipBored does not use telemetry or background networking. Clipboard history is stored locally under Application Support unless iCloud Sync is explicitly enabled in a signed build with an iCloud entitlement. User-triggered link previews load the selected web page in an ephemeral built-in WebKit view.
|
||||
|
||||
Textual SQLite fields, image cache files, audio clips, video clips, rich text sidecars, and PDF attachments are encrypted with AES-GCM using a Keychain-held key when Keychain access is available. If Keychain access blocks or fails, ClipBored uses an owner-only app-local fallback key so capture does not stall. Full history clears remove the local fallback key when present and reset cached key state for future captures. Temporary decrypted preview files may be created when thumbnailing, opening, or revealing encrypted media; stale previews are cleared on launch, cache/history clear, and quit. Use sensitive-content exclusion and ignored app settings for high-risk sources. See [docs/SECURITY.md](docs/SECURITY.md) for details and responsible disclosure.
|
||||
Textual SQLite fields, image cache files, audio clips, video clips, rich text sidecars, and PDF attachments are encrypted with AES-GCM using a Keychain-held key when Keychain access is available. If Keychain access blocks or fails, ClipBored uses an owner-only app-local fallback key so capture does not stall. Full history clears remove the local fallback key when present and reset cached key state for future captures. Portable archive exports and iCloud sync archives are not encrypted by ClipBored, so treat them like sensitive backups. Temporary decrypted preview files may be created when thumbnailing, opening, or revealing encrypted media; stale previews are cleared on launch, cache/history clear, and quit. Use sensitive-content exclusion, ignored app settings, and the screen-sharing privacy toggle for high-risk sources or calls. See [docs/SECURITY.md](docs/SECURITY.md) for details and responsible disclosure.
|
||||
|
||||
## Contributing
|
||||
|
||||
|
||||
@@ -9,9 +9,12 @@ ClipBored is a single-process AppKit utility built with Swift Package Manager.
|
||||
- `ClipboardMonitorService` polls `NSPasteboard.changeCount` on a utility queue with adaptive active/idle intervals.
|
||||
- `ClipboardStore` keeps the in-memory item list and persists rows to SQLite on a serial queue.
|
||||
- `ClipboardCacheService` stores bounded image previews under Application Support and keeps a small `NSCache`.
|
||||
- `ClipboardCloudSyncService` resolves the app-private iCloud ubiquity container when sync is enabled and pushes or pulls the portable archive file.
|
||||
- `ShortcutManager` registers Carbon hotkeys for app-wide commands.
|
||||
- `ClipboardPanelController` owns the bottom panel lifecycle and target-app tracking.
|
||||
- `ClipboardPanelController` owns the side panel lifecycle, Dock-aware frame planning, configured left/right placement, and target-app tracking.
|
||||
- `ClipboardPanelViewModel` filters, sorts, selects, copies, pastes, pins, organizes, deletes, opens, and reveals items.
|
||||
- `LinkPreviewWindowController` opens selected HTTP(S) links in an ephemeral WebKit preview window instead of handing them to another browser.
|
||||
- `OnboardingWindowController` shows the first-run setup assistant for shortcut, retention, system entry points, launch-at-login, iCloud sync, and Accessibility permission choices.
|
||||
- `SettingsWindowController` exposes native controls for capture, privacy, performance, shortcuts, and data management.
|
||||
|
||||
## Data Flow
|
||||
@@ -20,7 +23,7 @@ ClipBored is a single-process AppKit utility built with Swift Package Manager.
|
||||
2. Source app metadata is checked against ignored apps.
|
||||
3. Pasteboard content is normalized into a `ClipboardItem`.
|
||||
4. Sensitive text is skipped when exclusion is enabled.
|
||||
5. Copied images run local Vision OCR only when `Search in image labels` is enabled.
|
||||
5. Copied images run local Vision OCR only when `Search in image labels` is enabled; image cards can also run the same local OCR on demand from their quick actions.
|
||||
6. The store deduplicates, preserves pinned and collection-assigned items, enforces limits, and persists the mutation.
|
||||
7. The panel view model receives store updates and recomputes the visible list.
|
||||
|
||||
@@ -46,10 +49,16 @@ Restorable non-image payloads such as audio clips, rich text, and PDFs are store
|
||||
|
||||
Legacy JSON import still exists for migration from early builds.
|
||||
|
||||
Textual SQLite fields, including optional collection names and image OCR text, are encrypted and decrypted at the `ClipboardStore` boundary. App-managed image cache files, URL preview thumbnails, audio clips, rich text sidecars, and PDF attachments are encrypted and decrypted at the `ClipboardCacheService` boundary. The encryption key is stored in Keychain when available, with an owner-only app-local fallback key if Keychain access blocks or fails. Full history clears remove the local fallback key when present and reset cached key state after SQLite deletion succeeds. Runtime `ClipboardItem` values remain plaintext in memory so search, duplicate detection, copy, paste, organization, and cache cleanup operate normally. Opening or revealing encrypted media creates a temporary decrypted copy for macOS handoff; stale temporary previews are cleared on launch, cache/history clear, and quit.
|
||||
Portable `.clipboredarchive` files are JSON exports created by `ClipboardArchiveService`. They preserve item metadata and include decrypted bytes for app-managed image, URL-thumbnail, audio, video, rich-text, and PDF sidecars so a different Mac can re-cache them under its own storage directory and encryption key. External file references remain path-based and are not copied into the archive.
|
||||
|
||||
Optional iCloud Sync uses that same archive format at `Documents/ClipBored/ClipBored.clipboredarchive` inside the app-private ubiquity container. It is disabled by default, requires a signed build with iCloud entitlement access, pulls once when enabled at launch, and debounces pushes after local store changes. Shared Pinboard collaboration is not implemented.
|
||||
|
||||
Textual SQLite fields, including optional collection names and image OCR text, are encrypted and decrypted at the `ClipboardStore` boundary. App-managed image cache files, URL preview thumbnails, audio clips, rich text sidecars, and PDF attachments are encrypted and decrypted at the `ClipboardCacheService` boundary. The encryption key is stored in Keychain when available, with an owner-only app-local fallback key if Keychain access blocks or fails. Full history clears remove the local fallback key when present and reset cached key state after SQLite deletion succeeds. Runtime `ClipboardItem` values remain plaintext in memory so search, duplicate detection, copy, paste, organization, archive export/import, and cache cleanup operate normally. Opening or revealing encrypted media creates a temporary decrypted copy for macOS handoff; stale temporary previews are cleared on launch, cache/history clear, and quit. Link previews are user-triggered and use a non-persistent WebKit data store.
|
||||
|
||||
## Size And Power Constraints
|
||||
|
||||
The release build intentionally avoids SwiftUI, Combine, Swift Concurrency, third-party packages, bundled media, and app resources beyond `Info.plist`.
|
||||
|
||||
The build script uses `-Osize`, whole-module optimization, disabled reflection metadata, linker dead stripping, symbol stripping, and hardened-runtime signing. The current public targets, enforced by `scripts/build-macos-app.sh`, are a 1 MiB executable and a 1.8 MB app bundle.
|
||||
The side shelf is anchored to the current screen's visible frame and can be placed on the left or right edge. It uses compact horizontal rows in a vertical list; the active or hovered row expands in place while the panel stays clear of side Docks and reserves bottom Dock space for content padding.
|
||||
|
||||
The build script uses `-Osize`, whole-module optimization, disabled reflection metadata, linker dead stripping, symbol stripping, and hardened-runtime signing. The current public targets, enforced by `scripts/build-macos-app.sh`, are 2 MiB gates for both the executable and app bundle.
|
||||
|
||||
@@ -10,7 +10,7 @@ Run:
|
||||
./scripts/check.sh
|
||||
```
|
||||
|
||||
This runs the unit test suite, builds `build/ClipBored.app`, applies an ad-hoc hardened-runtime signature, enforces size gates, and verifies the app signature.
|
||||
This runs the unit test suite, builds `build/ClipBored.app`, applies an ad-hoc hardened-runtime signature, enforces the 2 MiB executable and bundle size gates, and verifies the app signature.
|
||||
|
||||
## Local Archive
|
||||
|
||||
@@ -40,6 +40,10 @@ export DEVELOPER_ID_APPLICATION="Developer ID Application: Example, Inc. (TEAMID
|
||||
|
||||
The script rebuilds the app, re-signs it with hardened runtime and timestamping, verifies the signature, and writes `build/ClipBored.zip`.
|
||||
|
||||
## iCloud Sync Entitlements
|
||||
|
||||
The default local and release scripts do not add iCloud entitlements. `Sync history with iCloud` will report unavailable in those builds. To ship iCloud Sync, sign with an entitlement file that grants the app's ubiquity container, then repeat the signature, smoke, and notarization checks.
|
||||
|
||||
## Notarization
|
||||
|
||||
Preferred: configure a notarytool keychain profile once:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Roadmap
|
||||
|
||||
This roadmap keeps future work aligned with the project's constraints: small executable, low idle power, local-only storage, native macOS UI, and no feature regressions.
|
||||
This roadmap keeps future work aligned with the project's constraints: small executable, low idle power, local-first storage, native macOS UI, and no feature regressions.
|
||||
|
||||
## Near Term
|
||||
|
||||
@@ -10,12 +10,14 @@ This roadmap keeps future work aligned with the project's constraints: small exe
|
||||
## Privacy And Security
|
||||
|
||||
- Keep improving secure cleanup semantics for cleared cache/history/key material where macOS storage behavior allows it.
|
||||
- Keep the current no-network/no-telemetry posture unless the project explicitly changes direction.
|
||||
- Keep the current no-telemetry posture. Keep remote movement limited to explicit user-controlled sync/export paths.
|
||||
- Add encrypted archive and iCloud-sync payload options before treating sync archives as safe for high-risk clipboard history.
|
||||
|
||||
## Product Polish
|
||||
|
||||
- Improve keyboard focus states and VoiceOver labels.
|
||||
- Add import/export only if the storage and privacy story remains clear.
|
||||
- Consider optional password-protected archive exports if migration needs outgrow owner-only local archive files.
|
||||
- Design true shared Pinboard collaboration separately from private iCloud archive sync.
|
||||
|
||||
## Performance
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
# Security Notes
|
||||
|
||||
ClipBored is designed as a local macOS utility. Its primary privacy promise is that clipboard data stays on the machine.
|
||||
ClipBored is designed as a local-first macOS utility. Its default privacy promise is that clipboard data stays on the machine unless iCloud Sync is explicitly enabled.
|
||||
|
||||
## Current Protections
|
||||
|
||||
- No networking or telemetry in production source.
|
||||
- No telemetry or background networking in production source.
|
||||
- No shell/process execution.
|
||||
- No Apple Events scripting.
|
||||
- Hardened runtime is applied by the local build script, and the release script supports Developer ID signing plus notarization when credentials are configured.
|
||||
@@ -14,7 +14,10 @@ ClipBored is designed as a local macOS utility. Its primary privacy promise is t
|
||||
- If Keychain access blocks or fails, ClipBored uses an owner-only app-local fallback key so clipboard capture and persistence continue without a Keychain UI stall.
|
||||
- Full history clears remove the app-local fallback key when present and reset cached key state after the database clear succeeds.
|
||||
- App-owned storage directories are restricted to the current user, and saved history/cache files are written with owner-only permissions where the filesystem supports POSIX modes.
|
||||
- Archive exports are written with owner-only permissions where supported.
|
||||
- iCloud Sync is off by default and uses the app-private ubiquity container only when entitlement access is available.
|
||||
- ClipBored marks its own pasteboard writes so copy/paste actions from history are not re-captured as new clipboard events.
|
||||
- The clipboard panel can be configured to opt out of screenshots, screen sharing, and screen recordings.
|
||||
- Sensitive-content exclusion can skip common high-risk values:
|
||||
- private key blocks
|
||||
- bearer tokens
|
||||
@@ -37,10 +40,14 @@ ClipBored is designed as a local macOS utility. Its primary privacy promise is t
|
||||
- The app-local fallback key prevents plaintext app-managed history/media files, but it does not protect against a process or user account that can read the full ClipBored Application Support directory before history is cleared.
|
||||
- Thumbnailing, opening, or revealing encrypted images, audio clips, video clips, or PDFs creates temporary decrypted preview files so macOS can hand them to system media APIs or other apps. ClipBored clears stale preview files on launch, cache/history clear, and quit.
|
||||
- Existing plaintext SQLite rows and legacy sidecar files are migrated when encryption becomes available, but system snapshots, backups, live temporary previews, or filesystem remnants may retain older plaintext copies.
|
||||
- Portable `.clipboredarchive` files and iCloud sync archives are not encrypted by ClipBored. They include recoverable clipboard metadata and app-managed attachment bytes so they can be imported on another Mac; store and transmit them like sensitive backups.
|
||||
- iCloud Sync relies on the user's private iCloud account and Apple's ubiquity container transport/storage. ClipBored does not add end-to-end archive encryption, conflict resolution beyond whole-archive import, or shared Pinboard access control.
|
||||
- The local development build is ad-hoc signed; use `scripts/release-macos-app.sh` with Developer ID credentials for notarized distribution builds.
|
||||
- Accessibility permission is required for automatic paste simulation.
|
||||
- Screen-sharing privacy applies to ClipBored's panel window, not to other apps, system clipboard state, or filesystem history.
|
||||
- Sensitive-content detection is heuristic and can miss novel formats or produce false positives.
|
||||
- Local image OCR is opt-in through `Search in image labels`; recognized text stays local but can still contain sensitive clipboard-derived content.
|
||||
- Automatic local image OCR is opt-in through `Search in image labels`; users can also run local OCR explicitly from an image card. Recognized text stays local but can still contain sensitive clipboard-derived content.
|
||||
- User-triggered link preview loads the selected HTTP(S) URL in a non-persistent WebKit view. The destination site can still receive the request and normal browser-visible metadata for that preview load.
|
||||
- Local filesystem access by another process or user account with sufficient permissions can expose metadata, fallback keys, and live temporary decrypted previews.
|
||||
|
||||
## Release Hardening Checklist
|
||||
@@ -50,5 +57,5 @@ ClipBored is designed as a local macOS utility. Its primary privacy promise is t
|
||||
- Verify `codesign --verify --deep --strict --verbose=2 build/ClipBored.app`.
|
||||
- Verify hardened runtime appears in `codesign -d --verbose=4 build/ClipBored.app`.
|
||||
- For distribution, verify `xcrun stapler validate build/ClipBored.app` and `spctl --assess --type execute --verbose=4 build/ClipBored.app`.
|
||||
- Confirm no new `URLSession`, process execution, Apple Events, telemetry, or remote sync APIs were introduced.
|
||||
- Confirm no new `URLSession`, process execution, Apple Events, or telemetry APIs were introduced; keep WebKit use limited to explicit link preview and keep sync limited to app-private iCloud ubiquity APIs.
|
||||
- Review any new persistence paths for unencrypted sensitive data.
|
||||
|
||||
@@ -12,18 +12,20 @@ Use this checklist before a release or after changes to panel, pasteboard, setti
|
||||
|
||||
2. Quit any running ClipBored copy.
|
||||
3. Open `build/ClipBored.app`.
|
||||
4. Confirm ClipBored appears in the menu bar when `Show ClipBored in the menu bar` is enabled.
|
||||
4. With a fresh preferences profile, confirm the setup assistant appears before the clipboard panel; choose the open shortcut, Keep History retention, menu-bar/Dock presence, launch-at-login, iCloud sync, and Accessibility option, then finish setup.
|
||||
5. Reopen ClipBored and confirm the setup assistant does not reappear.
|
||||
6. Confirm ClipBored appears in the menu bar when `Show ClipBored in the menu bar` is enabled.
|
||||
|
||||
## Capture
|
||||
|
||||
1. Copy plain text from TextEdit, Notes, or a browser.
|
||||
2. Open the panel with `Command + Option + V`.
|
||||
2. Open the panel with the configured open shortcut.
|
||||
3. Confirm the copied text appears in Most Recent.
|
||||
4. Copy a URL and confirm it appears as a Link; if the source provides a local preview image, confirm the Link card uses that preview.
|
||||
5. Copy an image and confirm it appears as an Image with a thumbnail.
|
||||
4. Copy a URL and confirm it appears as a Link with a browser-style site preview; if the source provides a local preview image, confirm the Link card uses that image preview instead and that tall shelves give the preview more room.
|
||||
5. Copy an image and confirm it appears as an Image with a thumbnail and a centered dimension overlay.
|
||||
6. Enable `Search in image labels`, copy an image containing readable text, and confirm searching for that text finds the Image.
|
||||
7. Copy a sound clip and confirm it appears as Audio.
|
||||
8. Copy a movie or video clip and confirm it appears as Video.
|
||||
7. Copy a sound clip and confirm it appears as Audio with an album-style artwork tile.
|
||||
8. Copy a movie or video clip and confirm it appears as Video with a player-style preview and centered format pill.
|
||||
9. Copy a PDF or PDF selection and confirm it appears as a PDF.
|
||||
10. Copy one Finder file and confirm it appears as a File.
|
||||
11. Copy multiple Finder files at once and confirm they appear as one grouped File item with the file count.
|
||||
@@ -34,44 +36,54 @@ Use this checklist before a release or after changes to panel, pasteboard, setti
|
||||
|
||||
1. Open the panel and confirm the search field is focused.
|
||||
2. Type a query and confirm results filter immediately.
|
||||
3. Type a structured query such as `pinboard:"Client Work","Read Later" type:image,pdf` and confirm only clips from those collections and content types remain.
|
||||
4. Clear the search field, press `Space`, and confirm the selected previewable clip opens in Quick Look instead of inserting a blank query.
|
||||
3. Press `Command + F` to focus search, press it again to show filters, then add Type, Device, and Pinboard filters and confirm they appear as structured tokens. Type a structured query such as `pinboard:"Client Work","Read Later" type:image,pdf device:<part of this Mac's name>` and confirm only clips from those collections, content types, and the copied-on Mac remain.
|
||||
4. Clear the search field, press `Space`, and confirm a selected web link opens in ClipBored's built-in browser preview while non-link previewable clips open in Quick Look instead of inserting a blank query.
|
||||
5. Use arrow keys to move selection while the search field is focused.
|
||||
6. Tab to collection chips and press `Space` or `Return`; confirm the focused chip is selected and the visible focus state is clear. Use Left/Right, Home, and End to move through the chip rail, including custom collections and Stack when present.
|
||||
7. Tab to cards; confirm the focused card gets a clear focus border, `Return` pastes or copies it, and `Space` opens Quick Look for text, links, files, and media.
|
||||
8. With a card focused, use Left/Right, Page Up/Page Down, Home, and End; confirm selection and focus move together across the shelf.
|
||||
9. With a card or collection chip focused, type a normal character and confirm focus returns to search with that character inserted and results filtered.
|
||||
10. Use a mouse wheel or two-finger vertical scroll over the card shelf and a crowded collection rail; confirm each pans horizontally, clamps at both ends, and shows subtle edge fades only where more content is hidden.
|
||||
11. Right-click a filtered result and choose Show in Clipboard, or press `Command + G`, and confirm search clears while the same card stays selected in Most Recent.
|
||||
12. Press `Esc` once with a non-empty search while the search field, a card, or a collection chip is focused and confirm search clears without closing the panel.
|
||||
13. Press `Esc` again and confirm the panel closes.
|
||||
14. Reopen the panel, change sort segments, and confirm each segment updates results.
|
||||
15. Press `Shift + Command + N` or the collection rail `+`, enter `Client Work`, choose a color, and confirm a Client Work chip appears with 0 clips and an empty collection view.
|
||||
16. Return to Clipboard, select a card, use its Collect button to choose Client Work, and confirm the Client Work chip count increases.
|
||||
17. Select the Client Work chip and confirm the rail filters to assigned items, cards use the Client Work name/color in their headers, and the collection/color/assignment persists after quitting and reopening ClipBored.
|
||||
18. Right-click the Client Work chip, choose Edit Collection..., rename it, change its color, and confirm the chip and assigned card headers update.
|
||||
19. Confirm collection chips with 0 clips do not show a visible count pill, while chips with clips still show their counts.
|
||||
20. Right-click a media, file, link, PDF, audio, or text card, choose Rename..., give it a title, and confirm the card title and search results use the custom title while paste/copy still uses the original payload.
|
||||
21. Double-click an item and confirm it attempts to paste or falls back to copy without creating a duplicate history entry.
|
||||
22. Right-click a card, use Capture Rules to ignore its source app, copy from that app again, and confirm the new item is skipped.
|
||||
23. Drag an unassigned card onto the renamed collection chip and confirm the chip count increases and the card appears when that collection is selected.
|
||||
24. Resize or test on a narrow display and confirm the bottom shelf switches to compact cards that still show two recent clips cleanly.
|
||||
25. Select a file, rich text, or URL card and confirm the selected-card rail exposes `Paste Plain Text`, the corner source/kind badge remains visible, and on a narrow shelf secondary actions collapse behind `More` instead of overflowing the card.
|
||||
26. Confirm card footers do not show `Unknown` for clips without a source app, and confirm used clips show their usage count beside the source app.
|
||||
27. Confirm card headers use readable relative ages such as `3 minutes ago` or `2 hours ago`, including when viewing a named collection.
|
||||
28. Confirm the selected card shows a green corner Stack control, the action rail does not duplicate Stack, and clips added to Stack keep a visible corner indicator when selection moves away.
|
||||
29. Confirm single-line text cards do not repeat the same text in both title and body, while multi-line text cards show the remaining lines below the first line.
|
||||
30. Confirm the Pinned empty state points to the Pin action instead of a plain-key shortcut.
|
||||
31. Confirm each card's source or type badge reads as an attached header-corner tile instead of a small floating icon.
|
||||
32. Confirm built-in collection chips use recognizable glyphs, while custom collection chips keep color-dot swatches.
|
||||
33. Confirm cards from known apps show app identity in the header tile, falling back to source initials when an app icon is unavailable.
|
||||
34. Confirm multi-file cards show a stacked file preview, while single-file cards keep the regular file layout.
|
||||
35. Confirm the shelf chrome uses one row with compact search, collection chips, and utility buttons; typing a search expands the search field without pushing cards out of view.
|
||||
36. Copy a color swatch from a design tool and confirm it appears as a Color card, can be filtered with the Colors chip, and copies back as both a color and hex text.
|
||||
37. Copy a code snippet from an editor and confirm it appears as a Code card, remains visible in the Text chip, can be isolated with the Code chip or `type:code`, and copies back as plain text.
|
||||
38. Copy a video/movie clip and confirm it appears as a Video card, uses a movie-frame thumbnail when available, filters with the Videos chip or `type:video`, `type:movie`, and `mp4`, previews/opens as a temp movie, and copies back as movie data.
|
||||
39. Filter to a few clips, right-click a card or the Stack chip, choose Add Visible Clips to Stack, and confirm only the visible clips are queued once in shelf order.
|
||||
40. With multiple text-like clips in Stack, choose Copy Stack as Text or Paste Stack as Text and confirm the queued text is written in stack order with blank lines between clips and consumed from Stack.
|
||||
6. Tab to the search controls, collection `+`, toolbar buttons, and collection chips; confirm VoiceOver help explains the matching actions and shortcuts. Press `Space` or `Return` on a focused chip and confirm it is selected and the visible focus state is clear. Use Left/Right, Home, and End to move through the chip rail, including custom collections and Stack when present, and confirm VoiceOver help mentions those keys plus Pinboard/Stack context actions where available.
|
||||
7. Tab to cards; confirm the focused card gets a clear focus border, `Return` pastes or copies it, `Command + C` copies it, `Shift + Return` and `Command + Shift + V` paste or copy it as plain text, and `Space` or `Command + Y` opens the built-in browser for web links or Quick Look for text, files, and media.
|
||||
8. With a card focused, press `Command + F` and confirm focus returns to search; press `Command + E` on a text/code clip and confirm the edit dialog opens. On a Mac with Writing Tools available, confirm the dialog offers Writing Tools and keeps the saved clip as plain text. Press `Command + R` and confirm the rename dialog opens.
|
||||
9. With a card focused, use Left/Right, Page Up/Page Down, Home, End, `Command + Up`, and `Command + Down`; confirm selection and focus move together across the shelf. Select two clips, press `Delete`, confirm both disappear, then press `Command + Z` and confirm both return selected.
|
||||
10. With a card or collection chip focused, type a normal character and confirm focus returns to search with that character inserted and results filtered.
|
||||
11. Use a mouse wheel or two-finger vertical scroll over the card shelf and a crowded collection rail; confirm each pans horizontally, clamps at both ends, and shows subtle edge fades only where more content is hidden, with collection-chip labels fading cleanly instead of hard-clipping at the utility buttons.
|
||||
12. Right-click a filtered result and choose Show in Clipboard, or press `Command + G` with the result card focused, and confirm search clears while the same card stays selected in Most Recent. Press `Command + O` on a focused link, file, or media card and confirm it opens.
|
||||
13. Press `Esc` once with a non-empty search while the search field, a card, or a collection chip is focused and confirm search clears without closing the panel.
|
||||
14. Press `Esc` again and confirm the panel closes.
|
||||
15. Reopen the panel, change sort segments, and confirm each segment updates results.
|
||||
16. Press `Shift + Command + N` or the collection rail `+`, enter `Client Work`, choose a color, and confirm a Client Work chip appears with 0 clips and an empty collection view.
|
||||
17. Press `Command + N` or the shelf pencil button, enter text, and confirm a new text clip appears selected in the active shelf; on a Mac with Writing Tools available, confirm the new-text dialog offers Writing Tools. Repeat while Client Work is selected and confirm the clip is created in that collection.
|
||||
18. Return to Clipboard, select a card, use its Collect button to choose Client Work, and confirm the Client Work chip count increases.
|
||||
19. Select the Client Work chip and confirm the rail filters to assigned items, cards use the Client Work name/color in their headers, and the collection/color/assignment persists after quitting and reopening ClipBored. Confirm collection-assigned clips stay in Client Work even as normal clipboard history rolls over.
|
||||
20. Create a second collection, then press `Command + Right` and `Command + Left`; confirm selection moves between collections and wraps around.
|
||||
21. Right-click the Client Work chip, choose Edit Collection..., rename it, change its color, and confirm the chip and assigned card headers update.
|
||||
22. Right-click a custom Pinboard chip, choose Export Pinboard..., save a `.clipboredarchive`, import it into a fresh test profile from Settings > Data, and confirm only that Pinboard's clips, empty Pinboard state, and color are restored.
|
||||
23. Confirm collection chips with 0 clips do not show a visible count pill, while chips with clips still show their counts.
|
||||
24. Right-click a media, file, link, PDF, audio, or text card, choose Rename..., give it a title, and confirm the card title and search results use the custom title while paste/copy still uses the original payload.
|
||||
25. Right-click an image card or focus its action rail, choose `Rotate Image`, and confirm the preview updates while the title, Pinboard, source app, and searchable image text remain. Then choose `Extract Text` on an image containing text and confirm the card shows the extracted text and `Copy Plain Text` can copy it.
|
||||
26. Double-click an item and confirm it attempts to paste or falls back to copy without creating a duplicate history entry.
|
||||
27. Right-click a card, use Capture Rules to ignore its source app, copy from that app again, and confirm the new item is skipped.
|
||||
28. Drag an unassigned card onto the renamed collection chip and confirm the chip count increases and the card appears when that collection is selected.
|
||||
29. Open the panel and confirm it appears as a vertical side shelf with compact horizontal rows; hover a row and confirm it expands in place. On a multi-display setup, open from the global shortcut with the pointer on each display and confirm the shelf opens on that display; click the menu-bar icon and confirm it opens on the menu-bar icon's display. Switch Spaces or use a full-screen app and confirm the shelf opens on the active Space instead of following every desktop.
|
||||
30. Switch Settings > General > Panel > Shelf side between Left and Right. Reopen the panel after each change and confirm it slides from the configured side and keeps the category row centered.
|
||||
31. Select a file, rich text, or URL card, then hover or keyboard-focus it and confirm the action rail exposes `Paste Plain Text`, the corner source/kind badge remains visible, and on a narrow shelf secondary actions collapse behind `More` instead of overflowing the card.
|
||||
32. Confirm card footers do not show `Unknown` for clips without a source app, confirm used clips show their usage count beside the source app, and confirm clips synced or imported from another Mac show that copied-on device in the footer.
|
||||
33. Confirm card headers use readable relative ages such as `3 minutes ago` or `2 hours ago`, including when viewing a named collection.
|
||||
34. Confirm the selected card shows a green corner Stack control, the hover/focus action rail does not duplicate Stack, and clips added to Stack keep a visible corner indicator when selection moves away.
|
||||
35. Confirm text cards use a quiet paper-style body, single-line text cards do not repeat the same text in both title and body, and multi-line text cards show the remaining lines below the first line with a subtle bottom fade.
|
||||
36. Confirm the Pinned empty state points to the Pin action instead of a plain-key shortcut.
|
||||
37. Confirm each card's source or type badge reads as an attached header-corner tile instead of a small floating icon.
|
||||
38. Confirm built-in collection chips use recognizable glyphs, while custom collection chips keep color-dot swatches.
|
||||
39. Confirm cards from known apps show app identity in the header tile, falling back to source initials when an app icon is unavailable.
|
||||
40. Confirm single-file and PDF cards show a centered document-cover preview with a file-type pill, while multi-file cards show a stacked file preview.
|
||||
41. Confirm the shelf chrome uses one row with compact search, collection chips, and separate soft utility icon buttons instead of a heavy grouped block; typing a search expands the search field without pushing cards out of view.
|
||||
42. Confirm the top collection rail reads as a quiet translucent strip, the selected collection is a subtle pill, and the active card visibly floats above neighboring cards while the rest stay docked in the shelf.
|
||||
43. Copy a color swatch from a design tool and confirm it appears as a centered paint-chip Color card, can be filtered with the Colors chip, and copies back as both a color and hex text.
|
||||
44. Copy a code snippet from an editor and confirm it appears as a Code card, remains visible in the Text chip, can be isolated with the Code chip or `type:code`, and copies back as plain text.
|
||||
45. Copy a video/movie clip and confirm it appears as a Video card, uses a movie-frame thumbnail when available, filters with the Videos chip or `type:video`, `type:movie`, and `mp4`, previews/opens as a temp movie, and copies back as movie data.
|
||||
46. Press `Shift + Command + C`, confirm the Stack chip appears active with 0 clips, copy two text snippets, and confirm Stack count increments and the Stack view shows them in copy order. Press `Shift + Command + C` again and confirm capture stops.
|
||||
47. Filter to a few clips, right-click a card or the Stack chip, choose Add Visible Clips to Stack, and confirm only the visible clips are queued once in shelf order.
|
||||
48. With multiple text-like clips in Stack, choose Copy Stack as Text or Paste Stack as Text and confirm the queued text is written in stack order with blank lines between clips and consumed from Stack.
|
||||
49. Command-click non-adjacent cards and Shift-click a range; confirm the status count changes to selected clips, selected cards remain highlighted while hovering, and the card menu offers Paste Selection, Copy Selection, Paste Selection as Text, Copy Selection as Text, and Add Selection to Stack.
|
||||
50. With a card focused, press `Shift` with Left/Right, Page Up/Page Down, Home, or End to extend the range, then press `Command + A` and confirm every visible card is selected without changing the active card.
|
||||
|
||||
## Copy And Paste
|
||||
|
||||
@@ -89,12 +101,15 @@ Use this checklist before a release or after changes to panel, pasteboard, setti
|
||||
## Settings
|
||||
|
||||
1. Open Settings with `Command + ,`.
|
||||
2. Change history length, default sort, polling profile, cache limit, ignored apps, and allowed content types; quit and reopen the app; confirm settings persist.
|
||||
2. Change Shelf side, Keep History, history length, default sort, polling profile, cache limit, ignored apps, and allowed content types; quit and reopen the app; confirm settings persist.
|
||||
3. Change the open-panel shortcut and confirm the old shortcut no longer opens the panel and the new shortcut does.
|
||||
4. Toggle `Pause clipboard capture`, copy text, and confirm paused capture does not record it.
|
||||
4. Toggle `Pause clipboard capture` or press `Command + T`, copy text, and confirm paused capture does not record it; press `Command + T` again and confirm capture resumes.
|
||||
5. Toggle `Exclude likely secrets`, copy a representative token, and confirm it is not recorded.
|
||||
6. Use `Open Accessibility Settings` and confirm System Settings opens to the permission area or fallback settings app.
|
||||
7. Use `Clear Clipboard History` and `Clear Thumbnail Cache`; confirm each shows a warning confirmation before deleting data.
|
||||
6. Toggle `Hide panel from screen sharing and recordings`, open the clipboard panel, and confirm it is omitted from a macOS screenshot or screen recording; disable it and confirm capture works normally again.
|
||||
7. Use `Open Accessibility Settings` and confirm System Settings opens to the permission area or fallback settings app.
|
||||
8. Use Settings > Data > `Export Archive...`, save a `.clipboredarchive`, then import it into a fresh or cleared test profile and confirm text, Pinboard assignments, image previews, and PDF/audio/video/rich-text clips reappear; confirm external file-reference clips still point at their original paths.
|
||||
9. In an ad-hoc local build, turn on Settings > Data > `Sync history with iCloud` and confirm the status reports iCloud Sync unavailable. In a signed build with an iCloud entitlement, use `Sync Now`, then restore into a fresh test profile and confirm text, Pinboard assignments, and app-managed attachments reappear.
|
||||
10. Use `Clear Clipboard History` and `Clear Thumbnail Cache`; confirm each shows a warning confirmation before deleting data.
|
||||
|
||||
## Storage And Privacy
|
||||
|
||||
@@ -102,18 +117,21 @@ Use this checklist before a release or after changes to panel, pasteboard, setti
|
||||
2. Confirm `history.sqlite` exists after capture.
|
||||
3. Copy unique text and confirm `strings ~/Library/Application\ Support/ClipBored/history.sqlite | grep "unique text"` does not find it.
|
||||
4. Copy uniquely identifiable rich text/audio/video/PDF data and confirm `strings ~/Library/Application\ Support/ClipBored/attachments/* | grep "unique text"` does not find it.
|
||||
5. If `history-encryption.key` exists, confirm it is readable only by the current user.
|
||||
6. Confirm image files are under `images/` and rich text/audio/video/PDF attachments are under `attachments/`.
|
||||
7. Confirm app storage is local to `~/Library/Application Support/ClipBored`.
|
||||
8. Open or reveal an encrypted image/audio/video/PDF, then quit ClipBored and confirm `/tmp/ClipBored/Previews` is removed.
|
||||
9. Use `Clear Clipboard History` and confirm saved history, app-managed attachments, temporary previews, and `history-encryption.key` are removed when that fallback key exists.
|
||||
10. Confirm quitting with `Clear history on quit` enabled removes history and app-managed cache/attachment files.
|
||||
5. Export an archive with unique test content and confirm the archive file is treated as sensitive backup material because it is portable and not encrypted by ClipBored.
|
||||
6. If iCloud Sync is enabled in a signed build, confirm `ClipBored.clipboredarchive` in the app-private iCloud container is treated as sensitive backup material because it is portable and not encrypted by ClipBored.
|
||||
7. If `history-encryption.key` exists, confirm it is readable only by the current user.
|
||||
8. Confirm image files are under `images/` and rich text/audio/video/PDF attachments are under `attachments/`.
|
||||
9. Confirm app storage is local to `~/Library/Application Support/ClipBored` when iCloud Sync is off.
|
||||
10. Open or reveal an encrypted image/audio/video/PDF, then quit ClipBored and confirm `/tmp/ClipBored/Previews` is removed.
|
||||
11. Use `Clear Clipboard History` and confirm saved history, app-managed attachments, temporary previews, and `history-encryption.key` are removed when that fallback key exists.
|
||||
12. Confirm quitting with `Clear history on quit` enabled removes history and app-managed cache/attachment files.
|
||||
|
||||
## Launch And Lifecycle
|
||||
|
||||
1. Enable Launch at Login, log out and back in, and confirm ClipBored starts.
|
||||
2. Disable Launch at Login and confirm it no longer starts after the next login.
|
||||
3. Right-click the menu-bar icon and confirm the status menu opens with capture state, clip count, Show Clipboard, Settings, Pause/Resume Capture, and Quit.
|
||||
3. Right-click the menu-bar icon and confirm the status menu opens with capture state, clip count, Show Clipboard, Settings, Pause/Resume Capture, Pause for 5 Minutes, Pause for 30 Minutes, Pause for 1 Hour, and Quit.
|
||||
4. Control-click the menu-bar icon and confirm the same status menu opens without toggling the panel.
|
||||
5. Toggle Pause/Resume Capture from the status menu and confirm the status row changes.
|
||||
6. Quit ClipBored from the menu bar and confirm no `ClipBored` process remains.
|
||||
5. Choose `Pause for 5 Minutes`, confirm the status detail shows a timed pause and copied text is not recorded, then choose `Resume Capture`.
|
||||
6. Toggle manual Pause/Resume Capture from the status menu and confirm the status row changes.
|
||||
7. Quit ClipBored from the menu bar and confirm no `ClipBored` process remains.
|
||||
|
||||
11
docs/thingstofix.md
Normal file
11
docs/thingstofix.md
Normal file
@@ -0,0 +1,11 @@
|
||||
1. When hovering over a copied item, the options that appear on hover obscure the text.
|
||||
2. When hovering over an item, key board arrow navigation breaks.
|
||||
3. There needs to be some kind of animation? or smooothening effect when switching categories.
|
||||
4. The search bar is broken when compressed, and clicking elsewhere should compress it again.
|
||||
5. The search filter should just be the categories selected. Select categories with cmd.
|
||||
6. There're a lot of categories, if there's nothing in them, don't show the categories by default.
|
||||
7. There is a resize bar at the top lip of the window, don't bother with it. it doesn't allow resizing anywa.yz
|
||||
8. Get rid of new text clip button. Don't need it.
|
||||
9. Get rid of compact mode.
|
||||
10. Get rid of close window. Don't need it. We use the macro from the keyboard.
|
||||
11. Get rid of the status bar.
|
||||
@@ -10,6 +10,8 @@ BIN_NAME="$APP_NAME"
|
||||
BIN_PATH="$REPO_ROOT/.build/release/$BIN_NAME"
|
||||
INFO_PLIST="$REPO_ROOT/sources/clipbored/resources/Info.plist"
|
||||
ICON_FILE="$REPO_ROOT/sources/clipbored/resources/AppIcon.icns"
|
||||
SIZE_LIMIT_BYTES=$((2 * 1024 * 1024))
|
||||
SIZE_LIMIT_LABEL="2 MiB"
|
||||
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
@@ -20,7 +22,8 @@ swift build -c release --product "$APP_NAME" \
|
||||
-Xswiftc -Xfrontend \
|
||||
-Xswiftc -disable-reflection-metadata \
|
||||
-Xlinker -dead_strip \
|
||||
-Xlinker -no_function_starts
|
||||
-Xlinker -no_function_starts \
|
||||
-Xlinker -no_compact_unwind
|
||||
rm -rf "$APP_BUNDLE"
|
||||
mkdir -p "$APP_BUNDLE/Contents/MacOS" "$APP_BUNDLE/Contents/Resources"
|
||||
cp "$INFO_PLIST" "$APP_BUNDLE/Contents/Info.plist"
|
||||
@@ -32,18 +35,17 @@ codesign --deep --force --options runtime --sign - "$APP_BUNDLE" >/dev/null 2>&1
|
||||
touch "$APP_BUNDLE"
|
||||
|
||||
APP_SIZE=$(stat -f%z "$APP_BUNDLE/Contents/MacOS/$APP_NAME")
|
||||
APP_SIZE_LIMIT=$((1024 * 1024))
|
||||
HUMAN_SIZE=$(du -h "$APP_BUNDLE/Contents/MacOS/$APP_NAME" | cut -f1)
|
||||
APP_BUNDLE_SIZE=$(du -sh "$APP_BUNDLE" | cut -f1)
|
||||
APP_BUNDLE_BYTES=$(du -sk "$APP_BUNDLE" | awk '{print $1*1024}')
|
||||
echo "Built $APP_BUNDLE"
|
||||
echo "Binary size: $HUMAN_SIZE ($APP_SIZE bytes)"
|
||||
echo "Bundle size: $APP_BUNDLE_SIZE"
|
||||
if [ "$APP_SIZE" -gt "$APP_SIZE_LIMIT" ]; then
|
||||
echo "FAIL: executable exceeds 1MiB target ($APP_SIZE bytes)"
|
||||
if [ "$APP_SIZE" -gt "$SIZE_LIMIT_BYTES" ]; then
|
||||
echo "FAIL: executable exceeds $SIZE_LIMIT_LABEL target ($APP_SIZE bytes)"
|
||||
exit 1
|
||||
fi
|
||||
if [ "$APP_BUNDLE_BYTES" -gt 1800000 ]; then
|
||||
echo "FAIL: bundle exceeds 1.8MB target ($APP_BUNDLE_BYTES bytes)"
|
||||
if [ "$APP_BUNDLE_BYTES" -gt "$SIZE_LIMIT_BYTES" ]; then
|
||||
echo "FAIL: bundle exceeds $SIZE_LIMIT_LABEL target ($APP_BUNDLE_BYTES bytes)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -17,31 +17,45 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
let detail: String?
|
||||
}
|
||||
|
||||
struct CapturePauseDuration: Equatable {
|
||||
let title: String
|
||||
let seconds: TimeInterval
|
||||
let symbolName: String
|
||||
}
|
||||
|
||||
private static let statusMenuTextLimit = 68
|
||||
static let temporaryPauseDurations = [
|
||||
CapturePauseDuration(title: "Pause for 5 Minutes", seconds: 5 * 60, symbolName: "timer"),
|
||||
CapturePauseDuration(title: "Pause for 30 Minutes", seconds: 30 * 60, symbolName: "timer"),
|
||||
CapturePauseDuration(title: "Pause for 1 Hour", seconds: 60 * 60, symbolName: "clock")
|
||||
]
|
||||
|
||||
private var cacheService: ClipboardCacheService!
|
||||
private var cloudSyncService: ClipboardCloudSyncService!
|
||||
private var settings: SettingsModel!
|
||||
private var store: ClipboardStore!
|
||||
private var monitor: ClipboardMonitorService!
|
||||
private var panelController: ClipboardPanelController!
|
||||
private var settingsController: SettingsWindowController!
|
||||
private var onboardingController: OnboardingWindowController?
|
||||
private var shortcutManager: ShortcutManager!
|
||||
private var lifecycleService: AppLifecycleService!
|
||||
private var statusItem: NSStatusItem?
|
||||
private var statusMenu: NSMenu?
|
||||
private var pauseResumeTimer: Timer?
|
||||
private var cloudSyncPushWorkItem: DispatchWorkItem?
|
||||
private var suppressCloudSyncPush = false
|
||||
|
||||
func applicationDidFinishLaunching(_ notification: Notification) {
|
||||
settings = SettingsModel()
|
||||
cacheService = ClipboardCacheService()
|
||||
cloudSyncService = ClipboardCloudSyncService()
|
||||
store = ClipboardStore(settings: settings, cacheService: cacheService)
|
||||
monitor = ClipboardMonitorService(store: store, cacheService: cacheService, settings: settings)
|
||||
panelController = ClipboardPanelController(
|
||||
store: store,
|
||||
settings: settings,
|
||||
cacheService: cacheService,
|
||||
preferredScreen: { [weak self] in
|
||||
self?.statusItem?.button?.window?.screen
|
||||
},
|
||||
pollClipboardNow: { [weak monitor] in
|
||||
monitor?.pollNowAndWait()
|
||||
},
|
||||
@@ -49,7 +63,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
self?.openSettings()
|
||||
}
|
||||
)
|
||||
settingsController = SettingsWindowController(settings: settings, store: store, cacheService: cacheService)
|
||||
monitor.onCapturedItem = { [weak self] item in
|
||||
self?.panelController.addCapturedItemToStack(item)
|
||||
}
|
||||
settingsController = SettingsWindowController(
|
||||
settings: settings,
|
||||
store: store,
|
||||
cacheService: cacheService,
|
||||
cloudSyncService: cloudSyncService
|
||||
)
|
||||
lifecycleService = AppLifecycleService()
|
||||
shortcutManager = ShortcutManager(
|
||||
onOpenClipboardPanel: { [weak self] in
|
||||
@@ -63,6 +85,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
self?.settingsController.show()
|
||||
}
|
||||
},
|
||||
onToggleStackCapture: { [weak self] in
|
||||
DispatchQueue.main.async {
|
||||
self?.panelController.toggleStackCaptureMode()
|
||||
}
|
||||
},
|
||||
onStatusChange: { [weak self] status in
|
||||
DispatchQueue.main.async {
|
||||
self?.settings.setShortcutStatus(message: status.message)
|
||||
@@ -72,8 +99,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
settingsShortcut: settings.settingsShortcut
|
||||
)
|
||||
bindSettings()
|
||||
bindCloudSync()
|
||||
applyPresentation(changedSurface: nil)
|
||||
monitor.setPaused(settings.pauseCapture)
|
||||
applyCapturePauseSetting()
|
||||
monitor.start()
|
||||
shortcutManager.start()
|
||||
|
||||
@@ -81,14 +109,17 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
|
||||
refreshStatusItem()
|
||||
configureMainMenu()
|
||||
requestInitialAccessibilityPermissionIfNeeded()
|
||||
presentInitialSetupIfNeeded()
|
||||
}
|
||||
|
||||
func applicationDidBecomeActive(_ notification: Notification) {
|
||||
refreshAccessibilityPermissionMessage()
|
||||
onboardingController?.refreshPermissionStatus()
|
||||
}
|
||||
|
||||
func applicationWillTerminate(_ notification: Notification) {
|
||||
pauseResumeTimer?.invalidate()
|
||||
cloudSyncPushWorkItem?.cancel()
|
||||
monitor.stop()
|
||||
shortcutManager.stop()
|
||||
cacheService.clearTemporaryPreviews(wait: true)
|
||||
@@ -108,6 +139,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
panelController.toggle()
|
||||
}
|
||||
|
||||
@objc private func createCollection() {
|
||||
panelController.createCollection()
|
||||
}
|
||||
|
||||
@objc private func toggleStackCaptureMode() {
|
||||
panelController.toggleStackCaptureMode()
|
||||
}
|
||||
|
||||
@objc private func statusItemClicked(_ sender: NSStatusBarButton) {
|
||||
let event = NSApp.currentEvent
|
||||
if shouldOpenStatusMenu(for: event) {
|
||||
@@ -115,7 +154,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
return
|
||||
}
|
||||
|
||||
showClipboardPanel()
|
||||
showClipboardPanelFromStatusButton(sender)
|
||||
}
|
||||
|
||||
private func showClipboardPanelFromStatusButton(_ button: NSStatusBarButton) {
|
||||
panelController.toggle(preferredScreen: button.window?.screen)
|
||||
}
|
||||
|
||||
private func shouldOpenStatusMenu(for event: NSEvent?) -> Bool {
|
||||
@@ -143,6 +186,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
presentation: Self.statusMenuPresentation(
|
||||
historyCount: store.items.count,
|
||||
isCapturePaused: settings.pauseCapture,
|
||||
pauseCaptureUntil: settings.pauseCaptureUntil,
|
||||
captureStatus: settings.captureStatusMessage,
|
||||
pasteStatus: settings.pasteStatusMessage,
|
||||
shortcutStatus: settings.shortcutStatusMessage,
|
||||
@@ -163,6 +207,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
static func statusMenuPresentation(
|
||||
historyCount: Int,
|
||||
isCapturePaused: Bool,
|
||||
pauseCaptureUntil: Date? = nil,
|
||||
now: Date = Date(),
|
||||
captureStatus: String,
|
||||
pasteStatus: String,
|
||||
shortcutStatus: String,
|
||||
@@ -172,7 +218,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
let captureState = isCapturePaused ? "Capture Paused" : "Capture Running"
|
||||
let summary = "\(captureState) - \(clipCountText(historyCount))"
|
||||
let status = firstPresentStatus([
|
||||
isCapturePaused ? "Capture is paused." : nil,
|
||||
capturePauseStatusText(isCapturePaused: isCapturePaused, pauseCaptureUntil: pauseCaptureUntil, now: now),
|
||||
captureStatus,
|
||||
pasteStatus,
|
||||
shortcutStatus,
|
||||
@@ -210,6 +256,24 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
symbolName: "rectangle.bottomthird.inset.filled",
|
||||
to: menu
|
||||
)
|
||||
addActionMenuItem(
|
||||
"New Collection",
|
||||
action: #selector(createCollection),
|
||||
target: target,
|
||||
keyEquivalent: "n",
|
||||
keyEquivalentModifierMask: [.command, .shift],
|
||||
symbolName: "folder.badge.plus",
|
||||
to: menu
|
||||
)
|
||||
addActionMenuItem(
|
||||
"Stack Capture",
|
||||
action: #selector(toggleStackCaptureMode),
|
||||
target: target,
|
||||
keyEquivalent: "c",
|
||||
keyEquivalentModifierMask: [.command, .shift],
|
||||
symbolName: "square.stack.3d.up.fill",
|
||||
to: menu
|
||||
)
|
||||
addActionMenuItem(
|
||||
"Settings\u{2026}",
|
||||
action: #selector(openSettings),
|
||||
@@ -225,10 +289,24 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
isCapturePaused ? "Resume Capture" : "Pause Capture",
|
||||
action: #selector(togglePauseCapture),
|
||||
target: target,
|
||||
keyEquivalent: "t",
|
||||
keyEquivalentModifierMask: .command,
|
||||
symbolName: isCapturePaused ? "play.fill" : "pause.fill",
|
||||
to: menu
|
||||
)
|
||||
pause.state = isCapturePaused ? .on : .off
|
||||
if !isCapturePaused {
|
||||
for duration in temporaryPauseDurations {
|
||||
let item = addActionMenuItem(
|
||||
duration.title,
|
||||
action: #selector(pauseCaptureForDuration(_:)),
|
||||
target: target,
|
||||
symbolName: duration.symbolName,
|
||||
to: menu
|
||||
)
|
||||
item.representedObject = duration.seconds
|
||||
}
|
||||
}
|
||||
|
||||
menu.addItem(NSMenuItem.separator())
|
||||
addActionMenuItem(
|
||||
@@ -249,7 +327,20 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
}
|
||||
|
||||
@objc private func togglePauseCapture() {
|
||||
settings.pauseCapture.toggle()
|
||||
if settings.pauseCapture {
|
||||
settings.pauseCapture = false
|
||||
settings.pauseCaptureUntil = nil
|
||||
} else {
|
||||
settings.pauseCaptureUntil = nil
|
||||
settings.pauseCapture = true
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func pauseCaptureForDuration(_ sender: NSMenuItem) {
|
||||
let seconds = sender.representedObject as? TimeInterval ?? 0
|
||||
guard seconds > 0 else { return }
|
||||
settings.pauseCapture = true
|
||||
settings.pauseCaptureUntil = Date().addingTimeInterval(seconds)
|
||||
}
|
||||
|
||||
@objc private func quitApp() {
|
||||
@@ -317,6 +408,35 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
return "\(count) clips"
|
||||
}
|
||||
|
||||
static func shouldResumeExpiredCapturePause(isCapturePaused: Bool, pauseCaptureUntil: Date?, now: Date) -> Bool {
|
||||
guard isCapturePaused, let pauseCaptureUntil else { return false }
|
||||
return pauseCaptureUntil <= now
|
||||
}
|
||||
|
||||
static func capturePauseStatusText(isCapturePaused: Bool, pauseCaptureUntil: Date?, now: Date) -> String? {
|
||||
guard isCapturePaused else { return nil }
|
||||
guard let pauseCaptureUntil, pauseCaptureUntil > now else {
|
||||
return "Capture is paused."
|
||||
}
|
||||
|
||||
let seconds = max(0, pauseCaptureUntil.timeIntervalSince(now))
|
||||
if seconds < 60 {
|
||||
return "Capture is paused for less than a minute."
|
||||
}
|
||||
|
||||
let minutes = Int(ceil(seconds / 60))
|
||||
if minutes < 60 {
|
||||
return "Capture is paused for \(minutes) more \(pluralized("minute", minutes))."
|
||||
}
|
||||
|
||||
let hours = Int(ceil(Double(minutes) / 60))
|
||||
return "Capture is paused for \(hours) more \(pluralized("hour", hours))."
|
||||
}
|
||||
|
||||
private static func pluralized(_ singular: String, _ count: Int) -> String {
|
||||
count == 1 ? singular : "\(singular)s"
|
||||
}
|
||||
|
||||
private static func boundedStatusText(_ value: String) -> String {
|
||||
let collapsed = value
|
||||
.split { $0.isWhitespace || $0.isNewline }
|
||||
@@ -385,6 +505,27 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
appSubMenu.addItem(quit)
|
||||
appMenu.submenu = appSubMenu
|
||||
|
||||
let fileMenu = NSMenuItem()
|
||||
let fileSubMenu = NSMenu(title: "File")
|
||||
let newCollection = NSMenuItem(
|
||||
title: "New Collection",
|
||||
action: #selector(createCollection),
|
||||
keyEquivalent: "n"
|
||||
)
|
||||
newCollection.keyEquivalentModifierMask = [.command, .shift]
|
||||
newCollection.target = self
|
||||
fileSubMenu.addItem(newCollection)
|
||||
fileSubMenu.addItem(NSMenuItem.separator())
|
||||
let pauseCapture = NSMenuItem(
|
||||
title: "Pause/Resume Capture",
|
||||
action: #selector(togglePauseCapture),
|
||||
keyEquivalent: "t"
|
||||
)
|
||||
pauseCapture.keyEquivalentModifierMask = .command
|
||||
pauseCapture.target = self
|
||||
fileSubMenu.addItem(pauseCapture)
|
||||
fileMenu.submenu = fileSubMenu
|
||||
|
||||
let editMenu = NSMenuItem()
|
||||
let editSubMenu = NSMenu(title: "Edit")
|
||||
let openShortcut = self.settings.openShortcut
|
||||
@@ -396,10 +537,19 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
showClipboard.keyEquivalentModifierMask = menuModifierFlags(openShortcut)
|
||||
showClipboard.target = self
|
||||
editSubMenu.addItem(showClipboard)
|
||||
let stackCapture = NSMenuItem(
|
||||
title: "Stack Capture",
|
||||
action: #selector(toggleStackCaptureMode),
|
||||
keyEquivalent: "c"
|
||||
)
|
||||
stackCapture.keyEquivalentModifierMask = [.command, .shift]
|
||||
stackCapture.target = self
|
||||
editSubMenu.addItem(stackCapture)
|
||||
editMenu.submenu = editSubMenu
|
||||
|
||||
let mainMenu = NSMenu()
|
||||
mainMenu.addItem(appMenu)
|
||||
mainMenu.addItem(fileMenu)
|
||||
mainMenu.addItem(editMenu)
|
||||
NSApp.mainMenu = mainMenu
|
||||
}
|
||||
@@ -413,10 +563,31 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
private func bindCloudSync() {
|
||||
var observedInitialItems = false
|
||||
store.observeItems { [weak self] _ in
|
||||
guard let self else { return }
|
||||
if !observedInitialItems {
|
||||
observedInitialItems = true
|
||||
return
|
||||
}
|
||||
guard self.settings.iCloudSyncEnabled, !self.suppressCloudSyncPush else { return }
|
||||
self.scheduleCloudSyncPush()
|
||||
}
|
||||
|
||||
if settings.iCloudSyncEnabled {
|
||||
applyCloudSyncSetting()
|
||||
} else {
|
||||
settings.setCloudSyncStatus(message: "iCloud Sync is off.")
|
||||
}
|
||||
}
|
||||
|
||||
private func handleSettingsChange(_ change: SettingsModel.Change) {
|
||||
switch change {
|
||||
case .maxHistoryItems:
|
||||
store.updateHistoryLimit(settings.maxHistoryItems)
|
||||
case .historyRetention:
|
||||
store.normalizeHistoryLength()
|
||||
case .imageCacheMaxBytes:
|
||||
cacheService.purgeIfNeeded(maxBytes: settings.imageCacheMaxBytes)
|
||||
case .openShortcut, .settingsShortcut:
|
||||
@@ -430,20 +601,129 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
applyPresentation(changedSurface: .menuBar)
|
||||
case .showDockIcon:
|
||||
applyPresentation(changedSurface: .dock)
|
||||
case .compactMode:
|
||||
break
|
||||
case .panelLayout:
|
||||
break
|
||||
case .panelSizing:
|
||||
break
|
||||
case .cloudSync:
|
||||
applyCloudSyncSetting()
|
||||
case .pauseCapture:
|
||||
monitor.setPaused(settings.pauseCapture)
|
||||
if settings.showMenuBarIcon {
|
||||
refreshStatusMenu()
|
||||
}
|
||||
applyCapturePauseSetting()
|
||||
case .pollProfile:
|
||||
monitor.setPaused(settings.pauseCapture)
|
||||
case .status, .collections, .other:
|
||||
case .hideFromScreenCapture:
|
||||
break
|
||||
case .defaultSortMode, .includeImageTextInSearch, .pruneDuplicates, .ignoredItemKinds, .keepFirstImage, .excludeSensitive, .clearHistoryOnQuit:
|
||||
break
|
||||
case .status, .collections, .ignoredApps, .other:
|
||||
break
|
||||
case .captureStatus:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private func applyCloudSyncSetting() {
|
||||
cloudSyncPushWorkItem?.cancel()
|
||||
cloudSyncPushWorkItem = nil
|
||||
|
||||
guard settings.iCloudSyncEnabled else {
|
||||
settings.setCloudSyncStatus(message: "iCloud Sync is off.")
|
||||
return
|
||||
}
|
||||
|
||||
let status = cloudSyncService.status()
|
||||
settings.setCloudSyncStatus(message: status.message)
|
||||
guard status.isAvailable else { return }
|
||||
pullCloudSyncArchiveIfAvailable()
|
||||
}
|
||||
|
||||
private func pullCloudSyncArchiveIfAvailable() {
|
||||
suppressCloudSyncPush = true
|
||||
defer { suppressCloudSyncPush = false }
|
||||
|
||||
do {
|
||||
let summary = try cloudSyncService.pull(store: store)
|
||||
settings.setCloudSyncStatus(message: "Restored \(summary.itemCount) clips from iCloud.")
|
||||
} catch ClipboardCloudSyncError.noRemoteArchive(_) {
|
||||
settings.setCloudSyncStatus(message: "iCloud Sync is ready. No remote archive yet.")
|
||||
scheduleCloudSyncPush(after: 1.0)
|
||||
} catch {
|
||||
settings.setCloudSyncStatus(message: "iCloud Sync failed: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
private func scheduleCloudSyncPush(after delay: TimeInterval = 2.0) {
|
||||
cloudSyncPushWorkItem?.cancel()
|
||||
let workItem = DispatchWorkItem { [weak self] in
|
||||
self?.pushCloudSyncArchive()
|
||||
}
|
||||
cloudSyncPushWorkItem = workItem
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: workItem)
|
||||
}
|
||||
|
||||
private func pushCloudSyncArchive() {
|
||||
guard settings.iCloudSyncEnabled else { return }
|
||||
do {
|
||||
let summary = try cloudSyncService.push(store: store)
|
||||
settings.setCloudSyncStatus(message: "Synced \(summary.itemCount) clips to iCloud.")
|
||||
} catch {
|
||||
settings.setCloudSyncStatus(message: "iCloud Sync failed: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
private func applyCapturePauseSetting(now: Date = Date()) {
|
||||
if Self.shouldResumeExpiredCapturePause(
|
||||
isCapturePaused: settings.pauseCapture,
|
||||
pauseCaptureUntil: settings.pauseCaptureUntil,
|
||||
now: now
|
||||
) {
|
||||
settings.pauseCapture = false
|
||||
settings.pauseCaptureUntil = nil
|
||||
return
|
||||
}
|
||||
|
||||
monitor.setPaused(settings.pauseCapture)
|
||||
scheduleCapturePauseTimer(now: now)
|
||||
if settings.showMenuBarIcon {
|
||||
refreshStatusMenu()
|
||||
}
|
||||
}
|
||||
|
||||
private func scheduleCapturePauseTimer(now: Date = Date()) {
|
||||
pauseResumeTimer?.invalidate()
|
||||
pauseResumeTimer = nil
|
||||
|
||||
guard settings.pauseCapture, let pauseCaptureUntil = settings.pauseCaptureUntil else { return }
|
||||
let interval = pauseCaptureUntil.timeIntervalSince(now)
|
||||
guard interval > 0 else {
|
||||
settings.pauseCapture = false
|
||||
settings.pauseCaptureUntil = nil
|
||||
return
|
||||
}
|
||||
|
||||
pauseResumeTimer = Timer.scheduledTimer(withTimeInterval: interval, repeats: false) { [weak self] _ in
|
||||
DispatchQueue.main.async {
|
||||
self?.resumeExpiredCapturePause()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func resumeExpiredCapturePause() {
|
||||
guard Self.shouldResumeExpiredCapturePause(
|
||||
isCapturePaused: settings.pauseCapture,
|
||||
pauseCaptureUntil: settings.pauseCaptureUntil,
|
||||
now: Date()
|
||||
) else {
|
||||
applyCapturePauseSetting()
|
||||
return
|
||||
}
|
||||
|
||||
settings.pauseCapture = false
|
||||
settings.pauseCaptureUntil = nil
|
||||
}
|
||||
|
||||
static func presentationPlan(
|
||||
showMenuBarIcon: Bool,
|
||||
showDockIcon: Bool,
|
||||
@@ -536,7 +816,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
|
||||
let alert = NSAlert()
|
||||
alert.messageText = "Allow automatic paste?"
|
||||
alert.informativeText = "ClipBored can capture clipboard history without extra permission. Grant Accessibility only if you want selected clips to paste directly into the previous app; otherwise paste actions will copy the clip for you."
|
||||
alert.informativeText = "ClipBored captures history without extra permission. Grant Accessibility only for direct paste; otherwise paste actions copy the clip."
|
||||
alert.addButton(withTitle: "Open Accessibility Settings")
|
||||
alert.addButton(withTitle: "Later")
|
||||
alert.alertStyle = .warning
|
||||
@@ -550,6 +830,39 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
refreshAccessibilityPermissionMessage()
|
||||
}
|
||||
|
||||
private func presentInitialSetupIfNeeded() {
|
||||
guard !settings.onboardingCompleted else {
|
||||
requestInitialAccessibilityPermissionIfNeeded()
|
||||
return
|
||||
}
|
||||
|
||||
let controller = OnboardingWindowController(
|
||||
settings: settings,
|
||||
onOpenAccessibility: { [weak self] in
|
||||
self?.openAccessibilitySettingsFromOnboarding()
|
||||
},
|
||||
onFinish: { [weak self] in
|
||||
self?.completeInitialSetup()
|
||||
}
|
||||
)
|
||||
onboardingController = controller
|
||||
controller.show()
|
||||
}
|
||||
|
||||
private func openAccessibilitySettingsFromOnboarding() {
|
||||
settings.markAccessibilityNoticeShown()
|
||||
_ = AccessibilityPermissionService.requestPromptIfNeeded()
|
||||
if !AccessibilityPermissionService.isTrusted {
|
||||
AccessibilityPermissionService.openSystemSettings()
|
||||
}
|
||||
refreshAccessibilityPermissionMessage()
|
||||
}
|
||||
|
||||
private func completeInitialSetup() {
|
||||
onboardingController = nil
|
||||
requestInitialAccessibilityPermissionIfNeeded()
|
||||
}
|
||||
|
||||
private func menuModifierFlags(_ binding: ShortcutBinding) -> NSEvent.ModifierFlags {
|
||||
NSEvent.ModifierFlags(rawValue: binding.modifierFlags)
|
||||
}
|
||||
|
||||
@@ -7,7 +7,9 @@ enum AppConfiguration {
|
||||
static let defaultHistoryLength = 300
|
||||
static let minHistoryLength = 50
|
||||
static let maxHistoryLength = 2000
|
||||
static let minCacheMaxBytes: Int64 = 2 * 1024 * 1024
|
||||
static let defaultCacheMaxBytes: Int64 = 120 * 1024 * 1024
|
||||
static let maxCacheMaxBytes: Int64 = 512 * 1024 * 1024
|
||||
static let maxPinnedItems = 250
|
||||
static let maxFullImagePixelSize: CGFloat = 1600
|
||||
static let maxRecognizedImageTextLength = 4096
|
||||
|
||||
@@ -25,6 +25,42 @@ extension NSImage {
|
||||
let rep = NSBitmapImageRep(cgImage: cgImage)
|
||||
return rep.representation(using: .png, properties: [:])
|
||||
}
|
||||
|
||||
func rotatedClockwise() -> NSImage? {
|
||||
guard let cgImage = cgImage(forProposedRect: nil, context: nil, hints: nil),
|
||||
cgImage.width > 0,
|
||||
cgImage.height > 0 else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let colorSpace = cgImage.colorSpace ?? CGColorSpaceCreateDeviceRGB()
|
||||
let bitmapInfo = CGImageAlphaInfo.premultipliedLast.rawValue
|
||||
guard let context = CGContext(
|
||||
data: nil,
|
||||
width: cgImage.height,
|
||||
height: cgImage.width,
|
||||
bitsPerComponent: 8,
|
||||
bytesPerRow: 0,
|
||||
space: colorSpace,
|
||||
bitmapInfo: bitmapInfo
|
||||
) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
context.interpolationQuality = .high
|
||||
context.translateBy(x: CGFloat(cgImage.height), y: 0)
|
||||
context.rotate(by: .pi / 2)
|
||||
context.draw(
|
||||
cgImage,
|
||||
in: CGRect(x: 0, y: 0, width: cgImage.width, height: cgImage.height)
|
||||
)
|
||||
|
||||
guard let output = context.makeImage() else { return nil }
|
||||
return NSImage(
|
||||
cgImage: output,
|
||||
size: NSSize(width: cgImage.height, height: cgImage.width)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension NSView {
|
||||
|
||||
@@ -89,6 +89,31 @@ enum ClipboardSortMode: Int {
|
||||
case .videos: return "Videos"
|
||||
}
|
||||
}
|
||||
|
||||
func includes(_ item: ClipboardItem) -> Bool {
|
||||
switch self {
|
||||
case .mostRecent, .mostUsed:
|
||||
return true
|
||||
case .images:
|
||||
return item.kind == .image
|
||||
case .links:
|
||||
return item.kind == .url
|
||||
case .text:
|
||||
return item.kind == .text || item.kind == .richText || item.kind == .code
|
||||
case .pinned:
|
||||
return item.isPinned
|
||||
case .files:
|
||||
return item.kind == .file || item.kind == .pdf
|
||||
case .audio:
|
||||
return item.kind == .audio
|
||||
case .colors:
|
||||
return item.kind == .color
|
||||
case .code:
|
||||
return item.kind == .code
|
||||
case .videos:
|
||||
return item.kind == .video
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum ClipboardCollectionDefaults {
|
||||
@@ -127,6 +152,7 @@ struct ClipboardItem {
|
||||
var ocrText: String?
|
||||
var collectionName: String?
|
||||
var customTitle: String?
|
||||
var sourceDeviceName: String?
|
||||
|
||||
var searchableText: String {
|
||||
var text = kindLabel + " " + displayText.lowercased() + " " + payload.lowercased()
|
||||
@@ -136,18 +162,22 @@ struct ClipboardItem {
|
||||
if let sourceApp {
|
||||
text += " " + sourceApp.lowercased()
|
||||
}
|
||||
if let ocrText {
|
||||
text += " " + ocrText.lowercased()
|
||||
}
|
||||
if let sourceAppBundleId {
|
||||
text += " " + sourceAppBundleId.lowercased()
|
||||
}
|
||||
if let collectionName {
|
||||
text += " " + collectionName.lowercased()
|
||||
}
|
||||
if let sourceDeviceName {
|
||||
text += " " + sourceDeviceName.lowercased()
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
var effectiveSourceDeviceName: String {
|
||||
ClipboardItem.normalizedDeviceName(sourceDeviceName) ?? Self.localDeviceName
|
||||
}
|
||||
|
||||
private var kindLabel: String {
|
||||
switch kind {
|
||||
case .text: return "text"
|
||||
@@ -180,7 +210,8 @@ struct ClipboardItem {
|
||||
sourceAppBundleId: String? = nil,
|
||||
ocrText: String? = nil,
|
||||
collectionName: String? = nil,
|
||||
customTitle: String? = nil
|
||||
customTitle: String? = nil,
|
||||
sourceDeviceName: String? = ClipboardItem.localDeviceName
|
||||
) {
|
||||
self.id = id
|
||||
self.kind = kind
|
||||
@@ -198,6 +229,7 @@ struct ClipboardItem {
|
||||
self.ocrText = ocrText
|
||||
self.collectionName = collectionName
|
||||
self.customTitle = ClipboardItem.normalizedCustomTitle(customTitle)
|
||||
self.sourceDeviceName = ClipboardItem.normalizedDeviceName(sourceDeviceName)
|
||||
}
|
||||
|
||||
static func normalizedCustomTitle(_ value: String?) -> String? {
|
||||
@@ -209,4 +241,21 @@ struct ClipboardItem {
|
||||
guard !title.isEmpty else { return nil }
|
||||
return String(title.prefix(80))
|
||||
}
|
||||
|
||||
static var localDeviceName: String {
|
||||
normalizedDeviceName(Host.current().localizedName)
|
||||
?? normalizedDeviceName(Host.current().name)
|
||||
?? normalizedDeviceName(ProcessInfo.processInfo.hostName)
|
||||
?? "This Mac"
|
||||
}
|
||||
|
||||
static func normalizedDeviceName(_ value: String?) -> String? {
|
||||
guard let value else { return nil }
|
||||
let name = value
|
||||
.split { $0.isWhitespace }
|
||||
.joined(separator: " ")
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !name.isEmpty else { return nil }
|
||||
return String(name.prefix(60))
|
||||
}
|
||||
}
|
||||
|
||||
6
sources/clipbored/models/LinkPreviewRequest.swift
Normal file
6
sources/clipbored/models/LinkPreviewRequest.swift
Normal file
@@ -0,0 +1,6 @@
|
||||
import Foundation
|
||||
|
||||
struct LinkPreviewRequest: Equatable {
|
||||
let url: URL
|
||||
let title: String
|
||||
}
|
||||
@@ -1,15 +1,75 @@
|
||||
import Foundation
|
||||
|
||||
enum HistoryRetention: Int {
|
||||
case forever = 0
|
||||
case oneDay = 1
|
||||
case oneWeek = 7
|
||||
case oneMonth = 30
|
||||
case oneYear = 365
|
||||
|
||||
static let allCases: [HistoryRetention] = [.oneDay, .oneWeek, .oneMonth, .oneYear, .forever]
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .oneDay: return "1 Day"
|
||||
case .oneWeek: return "1 Week"
|
||||
case .oneMonth: return "1 Month"
|
||||
case .oneYear: return "1 Year"
|
||||
case .forever: return "Forever"
|
||||
}
|
||||
}
|
||||
|
||||
func cutoffDate(relativeTo now: Date = Date()) -> Date? {
|
||||
guard self != .forever else { return nil }
|
||||
return now.addingTimeInterval(-Double(rawValue) * 24 * 60 * 60)
|
||||
}
|
||||
}
|
||||
|
||||
enum ClipboardPanelLayout: Int, CaseIterable {
|
||||
case horizontal = 0
|
||||
case vertical = 1
|
||||
|
||||
var title: String {
|
||||
"Side Shelf"
|
||||
}
|
||||
}
|
||||
|
||||
enum ClipboardPanelSide: Int, CaseIterable {
|
||||
case left = 0
|
||||
case right = 1
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .left: return "Left"
|
||||
case .right: return "Right"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final class SettingsModel {
|
||||
enum Change: Equatable {
|
||||
case maxHistoryItems
|
||||
case historyRetention
|
||||
case defaultSortMode
|
||||
case imageCacheMaxBytes
|
||||
case includeImageTextInSearch
|
||||
case pruneDuplicates
|
||||
case openShortcut
|
||||
case settingsShortcut
|
||||
case launchAtLogin
|
||||
case showMenuBarIcon
|
||||
case showDockIcon
|
||||
case compactMode
|
||||
case panelLayout
|
||||
case panelSizing
|
||||
case cloudSync
|
||||
case pauseCapture
|
||||
case ignoredApps
|
||||
case ignoredItemKinds
|
||||
case keepFirstImage
|
||||
case excludeSensitive
|
||||
case hideFromScreenCapture
|
||||
case clearHistoryOnQuit
|
||||
case pollProfile
|
||||
case captureStatus
|
||||
case collections
|
||||
@@ -19,6 +79,7 @@ final class SettingsModel {
|
||||
|
||||
enum Keys {
|
||||
static let maxHistoryItems = "maxHistoryItems"
|
||||
static let historyRetention = "historyRetentionDays"
|
||||
static let defaultSortMode = "defaultSortMode"
|
||||
static let imageCacheMaxBytes = "imageCacheMaxBytes"
|
||||
static let includeImageTextInSearch = "includeImageTextInSearch"
|
||||
@@ -26,6 +87,11 @@ final class SettingsModel {
|
||||
static let launchAtLogin = "launchAtLogin"
|
||||
static let showMenuBarIcon = "showMenuBarIcon"
|
||||
static let showDockIcon = "showDockIcon"
|
||||
static let compactMode = "compactMode"
|
||||
static let panelLayout = "panelLayout"
|
||||
static let panelSide = "panelSide"
|
||||
static let panelShelfHeight = "panelShelfHeight"
|
||||
static let iCloudSyncEnabled = "iCloudSyncEnabled"
|
||||
static let openShortcut = "openShortcut"
|
||||
static let settingsShortcut = "settingsShortcut"
|
||||
static let ignoredApps = "ignoredApps"
|
||||
@@ -34,26 +100,38 @@ final class SettingsModel {
|
||||
static let keepFirstImage = "keepFirstImage"
|
||||
static let excludeSensitive = "excludeSensitive"
|
||||
static let pauseCapture = "pauseCapture"
|
||||
static let pauseCaptureUntil = "pauseCaptureUntil"
|
||||
static let hideFromScreenCapture = "hideFromScreenCapture"
|
||||
static let clearHistoryOnQuit = "clearHistoryOnQuit"
|
||||
static let onboardingCompleted = "onboardingCompleted"
|
||||
static let accessibilityNoticeShown = "accessibilityNoticeShown"
|
||||
static let customCollectionNames = "customCollectionNames"
|
||||
static let collectionColorHexes = "collectionColorHexes"
|
||||
}
|
||||
|
||||
var maxHistoryItems: Int {
|
||||
didSet { if oldValue != maxHistoryItems { storeAndNotify(.maxHistoryItems) } }
|
||||
didSet {
|
||||
maxHistoryItems = Self.clampedMaxHistoryItems(maxHistoryItems)
|
||||
if oldValue != maxHistoryItems { storeAndNotify(.maxHistoryItems) }
|
||||
}
|
||||
}
|
||||
var historyRetention: HistoryRetention {
|
||||
didSet { if oldValue != historyRetention { storeAndNotify(.historyRetention) } }
|
||||
}
|
||||
var defaultSortMode: ClipboardSortMode {
|
||||
didSet { if oldValue != defaultSortMode { storeAndNotify(.other) } }
|
||||
didSet { if oldValue != defaultSortMode { storeAndNotify(.defaultSortMode) } }
|
||||
}
|
||||
var imageCacheMaxBytes: Int64 {
|
||||
didSet { if oldValue != imageCacheMaxBytes { storeAndNotify(.imageCacheMaxBytes) } }
|
||||
didSet {
|
||||
imageCacheMaxBytes = Self.clampedImageCacheMaxBytes(imageCacheMaxBytes)
|
||||
if oldValue != imageCacheMaxBytes { storeAndNotify(.imageCacheMaxBytes) }
|
||||
}
|
||||
}
|
||||
var includeImageTextInSearch: Bool {
|
||||
didSet { if oldValue != includeImageTextInSearch { storeAndNotify(.other) } }
|
||||
didSet { if oldValue != includeImageTextInSearch { storeAndNotify(.includeImageTextInSearch) } }
|
||||
}
|
||||
var pruneDuplicates: Bool {
|
||||
didSet { if oldValue != pruneDuplicates { storeAndNotify(.other) } }
|
||||
didSet { if oldValue != pruneDuplicates { storeAndNotify(.pruneDuplicates) } }
|
||||
}
|
||||
var launchAtLogin: Bool {
|
||||
didSet { if oldValue != launchAtLogin { storeAndNotify(.launchAtLogin) } }
|
||||
@@ -64,6 +142,30 @@ final class SettingsModel {
|
||||
var showDockIcon: Bool {
|
||||
didSet { if oldValue != showDockIcon { storeAndNotify(.showDockIcon) } }
|
||||
}
|
||||
var compactMode: Bool {
|
||||
didSet { if oldValue != compactMode { storeAndNotify(.compactMode) } }
|
||||
}
|
||||
var panelLayout: ClipboardPanelLayout {
|
||||
didSet {
|
||||
if panelLayout != .vertical {
|
||||
panelLayout = .vertical
|
||||
}
|
||||
if oldValue != panelLayout { storeAndNotify(.panelLayout) }
|
||||
}
|
||||
}
|
||||
var panelSide: ClipboardPanelSide {
|
||||
didSet { if oldValue != panelSide { storeAndNotify(.panelLayout) } }
|
||||
}
|
||||
var panelShelfHeight: Double {
|
||||
didSet { if oldValue != panelShelfHeight { storeAndNotify(.panelSizing) } }
|
||||
}
|
||||
var iCloudSyncEnabled: Bool {
|
||||
didSet {
|
||||
guard oldValue != iCloudSyncEnabled else { return }
|
||||
cloudSyncStatusMessage = ""
|
||||
storeAndNotify(.cloudSync)
|
||||
}
|
||||
}
|
||||
var openShortcut: ShortcutBinding {
|
||||
didSet { if oldValue != openShortcut { storeAndNotify(.openShortcut) } }
|
||||
}
|
||||
@@ -71,25 +173,37 @@ final class SettingsModel {
|
||||
didSet { if oldValue != settingsShortcut { storeAndNotify(.settingsShortcut) } }
|
||||
}
|
||||
var ignoredApps: [String] {
|
||||
didSet { if oldValue != ignoredApps { storeAndNotify(.other) } }
|
||||
didSet { if oldValue != ignoredApps { storeAndNotify(.ignoredApps) } }
|
||||
}
|
||||
var ignoredItemKindsRaw: [Int] {
|
||||
didSet { if oldValue != ignoredItemKindsRaw { storeAndNotify(.other) } }
|
||||
didSet {
|
||||
let normalized = Self.normalizedIgnoredItemKinds(ignoredItemKindsRaw)
|
||||
if normalized != ignoredItemKindsRaw {
|
||||
ignoredItemKindsRaw = normalized
|
||||
}
|
||||
if oldValue != ignoredItemKindsRaw { storeAndNotify(.ignoredItemKinds) }
|
||||
}
|
||||
}
|
||||
var pollProfileRaw: AppConfiguration.PollProfile {
|
||||
didSet { if oldValue != pollProfileRaw { storeAndNotify(.pollProfile) } }
|
||||
}
|
||||
var keepFirstImage: Bool {
|
||||
didSet { if oldValue != keepFirstImage { storeAndNotify(.other) } }
|
||||
didSet { if oldValue != keepFirstImage { storeAndNotify(.keepFirstImage) } }
|
||||
}
|
||||
var excludeSensitive: Bool {
|
||||
didSet { if oldValue != excludeSensitive { storeAndNotify(.other) } }
|
||||
didSet { if oldValue != excludeSensitive { storeAndNotify(.excludeSensitive) } }
|
||||
}
|
||||
var pauseCapture: Bool {
|
||||
didSet { if oldValue != pauseCapture { storeAndNotify(.pauseCapture) } }
|
||||
}
|
||||
var pauseCaptureUntil: Date? {
|
||||
didSet { if oldValue != pauseCaptureUntil { storeAndNotify(.pauseCapture) } }
|
||||
}
|
||||
var hideFromScreenCapture: Bool {
|
||||
didSet { if oldValue != hideFromScreenCapture { storeAndNotify(.hideFromScreenCapture) } }
|
||||
}
|
||||
var clearHistoryOnQuit: Bool {
|
||||
didSet { if oldValue != clearHistoryOnQuit { storeAndNotify(.other) } }
|
||||
didSet { if oldValue != clearHistoryOnQuit { storeAndNotify(.clearHistoryOnQuit) } }
|
||||
}
|
||||
private(set) var customCollectionNames: [String]
|
||||
private(set) var collectionColorHexes: [String: String]
|
||||
@@ -98,6 +212,8 @@ final class SettingsModel {
|
||||
private(set) var captureStatusMessage: String = ""
|
||||
private(set) var shortcutStatusMessage: String = ""
|
||||
private(set) var pasteStatusMessage: String = ""
|
||||
private(set) var cloudSyncStatusMessage: String = ""
|
||||
private(set) var onboardingCompleted: Bool
|
||||
private(set) var accessibilityNoticeShown: Bool
|
||||
|
||||
private let defaults: UserDefaults
|
||||
@@ -107,10 +223,17 @@ final class SettingsModel {
|
||||
self.defaults = defaults
|
||||
|
||||
let savedHistory = defaults.integer(forKey: Keys.maxHistoryItems)
|
||||
let savedRetention = defaults.object(forKey: Keys.historyRetention) as? Int
|
||||
let savedSort = defaults.integer(forKey: Keys.defaultSortMode)
|
||||
let savedCacheObject = defaults.object(forKey: Keys.imageCacheMaxBytes)
|
||||
let savedCache = defaults.integer(forKey: Keys.imageCacheMaxBytes)
|
||||
let savedPanelSide = defaults.object(forKey: Keys.panelSide) as? Int
|
||||
let existingProfile = defaults.object(forKey: Keys.maxHistoryItems) != nil
|
||||
|| defaults.object(forKey: Keys.historyRetention) != nil
|
||||
|| defaults.object(forKey: Keys.openShortcut) != nil
|
||||
|
||||
maxHistoryItems = savedHistory > 0 ? savedHistory : AppConfiguration.defaultHistoryLength
|
||||
historyRetention = savedRetention.flatMap(HistoryRetention.init(rawValue:)) ?? .oneMonth
|
||||
defaultSortMode = ClipboardSortMode(rawValue: savedSort) ?? .mostRecent
|
||||
imageCacheMaxBytes = savedCache > 0 ? Int64(savedCache) : AppConfiguration.defaultCacheMaxBytes
|
||||
includeImageTextInSearch = defaults.object(forKey: Keys.includeImageTextInSearch) as? Bool ?? false
|
||||
@@ -118,30 +241,57 @@ final class SettingsModel {
|
||||
launchAtLogin = defaults.object(forKey: Keys.launchAtLogin) as? Bool ?? false
|
||||
showMenuBarIcon = defaults.object(forKey: Keys.showMenuBarIcon) as? Bool ?? true
|
||||
showDockIcon = defaults.object(forKey: Keys.showDockIcon) as? Bool ?? false
|
||||
compactMode = defaults.object(forKey: Keys.compactMode) as? Bool ?? false
|
||||
panelLayout = .vertical
|
||||
panelSide = savedPanelSide.flatMap(ClipboardPanelSide.init(rawValue:)) ?? .right
|
||||
panelShelfHeight = defaults.object(forKey: Keys.panelShelfHeight) as? Double ?? 0
|
||||
iCloudSyncEnabled = defaults.object(forKey: Keys.iCloudSyncEnabled) as? Bool ?? false
|
||||
openShortcut = Self.readShortcut(from: defaults.string(forKey: Keys.openShortcut)) ?? AppConfiguration.defaultOpenShortcut
|
||||
settingsShortcut = Self.readShortcut(from: defaults.string(forKey: Keys.settingsShortcut)) ?? AppConfiguration.defaultSettingsShortcut
|
||||
ignoredApps = defaults.stringArray(forKey: Keys.ignoredApps) ?? AppConfiguration.defaultIgnoredApps
|
||||
ignoredItemKindsRaw = defaults.object(forKey: Keys.ignoredItemKinds) as? [Int] ?? []
|
||||
let storedIgnoredItemKinds = defaults.object(forKey: Keys.ignoredItemKinds) as? [Int] ?? []
|
||||
ignoredItemKindsRaw = Self.normalizedIgnoredItemKinds(storedIgnoredItemKinds)
|
||||
let profileValue = defaults.integer(forKey: Keys.pollProfile)
|
||||
pollProfileRaw = AppConfiguration.PollProfile(rawValue: profileValue) ?? AppConfiguration.defaultPollProfile
|
||||
keepFirstImage = defaults.object(forKey: Keys.keepFirstImage) as? Bool ?? true
|
||||
excludeSensitive = defaults.object(forKey: Keys.excludeSensitive) as? Bool ?? false
|
||||
pauseCapture = defaults.object(forKey: Keys.pauseCapture) as? Bool ?? false
|
||||
if let pauseUntilValue = defaults.object(forKey: Keys.pauseCaptureUntil) as? TimeInterval,
|
||||
pauseUntilValue > 0 {
|
||||
pauseCaptureUntil = Date(timeIntervalSince1970: pauseUntilValue)
|
||||
} else {
|
||||
pauseCaptureUntil = nil
|
||||
}
|
||||
hideFromScreenCapture = defaults.object(forKey: Keys.hideFromScreenCapture) as? Bool ?? false
|
||||
clearHistoryOnQuit = defaults.object(forKey: Keys.clearHistoryOnQuit) as? Bool ?? false
|
||||
customCollectionNames = Self.normalizedCollectionNames(defaults.stringArray(forKey: Keys.customCollectionNames) ?? [])
|
||||
collectionColorHexes = Self.normalizedCollectionColorHexes(defaults.dictionary(forKey: Keys.collectionColorHexes))
|
||||
onboardingCompleted = defaults.object(forKey: Keys.onboardingCompleted) as? Bool ?? existingProfile
|
||||
accessibilityNoticeShown = defaults.object(forKey: Keys.accessibilityNoticeShown) as? Bool ?? false
|
||||
|
||||
maxHistoryItems = max(AppConfiguration.minHistoryLength, min(AppConfiguration.maxHistoryLength, maxHistoryItems))
|
||||
imageCacheMaxBytes = max(4 * 1024 * 1024, imageCacheMaxBytes)
|
||||
maxHistoryItems = Self.clampedMaxHistoryItems(maxHistoryItems)
|
||||
imageCacheMaxBytes = Self.clampedImageCacheMaxBytes(imageCacheMaxBytes)
|
||||
if panelShelfHeight < 0 {
|
||||
panelShelfHeight = 0
|
||||
}
|
||||
|
||||
if defaults.object(forKey: Keys.maxHistoryItems) == nil {
|
||||
if defaults.object(forKey: Keys.maxHistoryItems) == nil
|
||||
|| savedHistory != maxHistoryItems
|
||||
|| defaults.object(forKey: Keys.historyRetention) == nil
|
||||
|| savedCacheObject == nil
|
||||
|| savedCache <= 0
|
||||
|| Int64(savedCache) != imageCacheMaxBytes
|
||||
|| storedIgnoredItemKinds != ignoredItemKindsRaw
|
||||
|| defaults.object(forKey: Keys.panelLayout) as? Int != ClipboardPanelLayout.vertical.rawValue
|
||||
|| savedPanelSide == nil
|
||||
|| ClipboardPanelSide(rawValue: savedPanelSide ?? -1) == nil {
|
||||
store()
|
||||
}
|
||||
}
|
||||
|
||||
private func store() {
|
||||
defaults.set(maxHistoryItems, forKey: Keys.maxHistoryItems)
|
||||
defaults.set(historyRetention.rawValue, forKey: Keys.historyRetention)
|
||||
defaults.set(defaultSortMode.rawValue, forKey: Keys.defaultSortMode)
|
||||
defaults.set(imageCacheMaxBytes, forKey: Keys.imageCacheMaxBytes)
|
||||
defaults.set(includeImageTextInSearch, forKey: Keys.includeImageTextInSearch)
|
||||
@@ -149,6 +299,11 @@ final class SettingsModel {
|
||||
defaults.set(launchAtLogin, forKey: Keys.launchAtLogin)
|
||||
defaults.set(showMenuBarIcon, forKey: Keys.showMenuBarIcon)
|
||||
defaults.set(showDockIcon, forKey: Keys.showDockIcon)
|
||||
defaults.set(compactMode, forKey: Keys.compactMode)
|
||||
defaults.set(panelLayout.rawValue, forKey: Keys.panelLayout)
|
||||
defaults.set(panelSide.rawValue, forKey: Keys.panelSide)
|
||||
defaults.set(panelShelfHeight, forKey: Keys.panelShelfHeight)
|
||||
defaults.set(iCloudSyncEnabled, forKey: Keys.iCloudSyncEnabled)
|
||||
defaults.set(openShortcut.encoded(), forKey: Keys.openShortcut)
|
||||
defaults.set(settingsShortcut.encoded(), forKey: Keys.settingsShortcut)
|
||||
defaults.set(ignoredApps, forKey: Keys.ignoredApps)
|
||||
@@ -157,7 +312,14 @@ final class SettingsModel {
|
||||
defaults.set(keepFirstImage, forKey: Keys.keepFirstImage)
|
||||
defaults.set(excludeSensitive, forKey: Keys.excludeSensitive)
|
||||
defaults.set(pauseCapture, forKey: Keys.pauseCapture)
|
||||
if let pauseCaptureUntil {
|
||||
defaults.set(pauseCaptureUntil.timeIntervalSince1970, forKey: Keys.pauseCaptureUntil)
|
||||
} else {
|
||||
defaults.removeObject(forKey: Keys.pauseCaptureUntil)
|
||||
}
|
||||
defaults.set(hideFromScreenCapture, forKey: Keys.hideFromScreenCapture)
|
||||
defaults.set(clearHistoryOnQuit, forKey: Keys.clearHistoryOnQuit)
|
||||
defaults.set(onboardingCompleted, forKey: Keys.onboardingCompleted)
|
||||
defaults.set(customCollectionNames, forKey: Keys.customCollectionNames)
|
||||
defaults.set(collectionColorHexes, forKey: Keys.collectionColorHexes)
|
||||
}
|
||||
@@ -201,6 +363,13 @@ final class SettingsModel {
|
||||
defaults.set(true, forKey: Keys.accessibilityNoticeShown)
|
||||
}
|
||||
|
||||
func markOnboardingCompleted() {
|
||||
guard !onboardingCompleted else { return }
|
||||
onboardingCompleted = true
|
||||
defaults.set(true, forKey: Keys.onboardingCompleted)
|
||||
notify(.other)
|
||||
}
|
||||
|
||||
func setShortcutStatus(message: String) {
|
||||
guard shortcutStatusMessage != message else { return }
|
||||
shortcutStatusMessage = message
|
||||
@@ -213,6 +382,12 @@ final class SettingsModel {
|
||||
notify(.status)
|
||||
}
|
||||
|
||||
func setCloudSyncStatus(message: String) {
|
||||
guard cloudSyncStatusMessage != message else { return }
|
||||
cloudSyncStatusMessage = message
|
||||
notify(.status)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func ensureCollection(named name: String, colorHex: String? = nil) -> String? {
|
||||
guard let normalizedName = ClipboardCollectionDefaults.normalizedName(name) else { return nil }
|
||||
@@ -334,8 +509,16 @@ final class SettingsModel {
|
||||
}
|
||||
|
||||
func sanitizeLimits() {
|
||||
maxHistoryItems = max(AppConfiguration.minHistoryLength, min(AppConfiguration.maxHistoryLength, maxHistoryItems))
|
||||
imageCacheMaxBytes = max(4 * 1024 * 1024, imageCacheMaxBytes)
|
||||
maxHistoryItems = Self.clampedMaxHistoryItems(maxHistoryItems)
|
||||
imageCacheMaxBytes = Self.clampedImageCacheMaxBytes(imageCacheMaxBytes)
|
||||
}
|
||||
|
||||
private static func clampedMaxHistoryItems(_ count: Int) -> Int {
|
||||
max(AppConfiguration.minHistoryLength, min(AppConfiguration.maxHistoryLength, count))
|
||||
}
|
||||
|
||||
private static func clampedImageCacheMaxBytes(_ bytes: Int64) -> Int64 {
|
||||
max(AppConfiguration.minCacheMaxBytes, min(AppConfiguration.maxCacheMaxBytes, bytes))
|
||||
}
|
||||
|
||||
private static func normalizedCollectionNames(_ names: [String]) -> [String] {
|
||||
@@ -348,6 +531,25 @@ final class SettingsModel {
|
||||
return normalized
|
||||
}
|
||||
|
||||
private static let userVisibleItemKindRawValues: Set<Int> = [
|
||||
ClipboardItemKind.text.rawValue,
|
||||
ClipboardItemKind.code.rawValue,
|
||||
ClipboardItemKind.url.rawValue,
|
||||
ClipboardItemKind.image.rawValue,
|
||||
ClipboardItemKind.color.rawValue,
|
||||
ClipboardItemKind.audio.rawValue,
|
||||
ClipboardItemKind.video.rawValue,
|
||||
ClipboardItemKind.richText.rawValue,
|
||||
ClipboardItemKind.pdf.rawValue,
|
||||
ClipboardItemKind.file.rawValue
|
||||
]
|
||||
|
||||
private static func normalizedIgnoredItemKinds(_ values: [Int]) -> [Int] {
|
||||
let ignoredVisibleKinds = Set(values).intersection(userVisibleItemKindRawValues)
|
||||
guard userVisibleItemKindRawValues.isSubset(of: ignoredVisibleKinds) else { return values }
|
||||
return values.filter { $0 != ClipboardItemKind.text.rawValue }
|
||||
}
|
||||
|
||||
private static func normalizedCollectionColorHexes(_ rawValue: [String: Any]?) -> [String: String] {
|
||||
guard let rawValue else { return [:] }
|
||||
var normalized: [String: String] = [:]
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
import AppKit
|
||||
import Foundation
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
enum VideoPayload {
|
||||
private enum TypeIdentifier {
|
||||
static let mpeg4Movie = "public.mpeg-4"
|
||||
static let quickTimeMovie = "com.apple.quicktime-movie"
|
||||
static let movie = "public.movie"
|
||||
static let video = "public.video"
|
||||
}
|
||||
|
||||
static let pasteboardTypes: [NSPasteboard.PasteboardType] = [
|
||||
NSPasteboard.PasteboardType(rawValue: UTType.mpeg4Movie.identifier),
|
||||
NSPasteboard.PasteboardType(rawValue: UTType.quickTimeMovie.identifier),
|
||||
NSPasteboard.PasteboardType(rawValue: UTType.movie.identifier),
|
||||
NSPasteboard.PasteboardType(rawValue: UTType.video.identifier),
|
||||
NSPasteboard.PasteboardType(rawValue: TypeIdentifier.mpeg4Movie),
|
||||
NSPasteboard.PasteboardType(rawValue: TypeIdentifier.quickTimeMovie),
|
||||
NSPasteboard.PasteboardType(rawValue: TypeIdentifier.movie),
|
||||
NSPasteboard.PasteboardType(rawValue: TypeIdentifier.video),
|
||||
NSPasteboard.PasteboardType(rawValue: "com.apple.m4v-video")
|
||||
]
|
||||
|
||||
@@ -22,9 +28,9 @@ enum VideoPayload {
|
||||
|
||||
static func fileExtension(for type: NSPasteboard.PasteboardType) -> String {
|
||||
switch type.rawValue {
|
||||
case UTType.mpeg4Movie.identifier:
|
||||
case TypeIdentifier.mpeg4Movie:
|
||||
return "mp4"
|
||||
case UTType.quickTimeMovie.identifier, UTType.movie.identifier, UTType.video.identifier:
|
||||
case TypeIdentifier.quickTimeMovie, TypeIdentifier.movie, TypeIdentifier.video:
|
||||
return "mov"
|
||||
case "com.apple.m4v-video":
|
||||
return "m4v"
|
||||
@@ -36,13 +42,13 @@ enum VideoPayload {
|
||||
static func pasteboardType(forPath path: String) -> NSPasteboard.PasteboardType {
|
||||
switch URL(fileURLWithPath: path).pathExtension.lowercased() {
|
||||
case "mp4":
|
||||
return NSPasteboard.PasteboardType(rawValue: UTType.mpeg4Movie.identifier)
|
||||
return NSPasteboard.PasteboardType(rawValue: TypeIdentifier.mpeg4Movie)
|
||||
case "m4v":
|
||||
return NSPasteboard.PasteboardType(rawValue: "com.apple.m4v-video")
|
||||
case "mov", "qt":
|
||||
return NSPasteboard.PasteboardType(rawValue: UTType.quickTimeMovie.identifier)
|
||||
return NSPasteboard.PasteboardType(rawValue: TypeIdentifier.quickTimeMovie)
|
||||
default:
|
||||
return NSPasteboard.PasteboardType(rawValue: UTType.movie.identifier)
|
||||
return NSPasteboard.PasteboardType(rawValue: TypeIdentifier.movie)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
328
sources/clipbored/services/ClipboardArchiveService.swift
Normal file
328
sources/clipbored/services/ClipboardArchiveService.swift
Normal file
@@ -0,0 +1,328 @@
|
||||
import Foundation
|
||||
|
||||
struct ClipboardArchiveSummary: Equatable {
|
||||
let itemCount: Int
|
||||
let sidecarCount: Int
|
||||
let skippedItemCount: Int
|
||||
let skippedSidecarCount: Int
|
||||
}
|
||||
|
||||
struct ClipboardArchiveImport {
|
||||
let items: [ClipboardItem]
|
||||
let collections: [ClipboardArchiveCollection]
|
||||
let summary: ClipboardArchiveSummary
|
||||
}
|
||||
|
||||
struct ClipboardArchiveCollection: Codable, Equatable {
|
||||
let name: String
|
||||
let colorHex: String?
|
||||
}
|
||||
|
||||
enum ClipboardArchiveError: LocalizedError {
|
||||
case unsupportedVersion(Int)
|
||||
case invalidArchive
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .unsupportedVersion(let version):
|
||||
return "This ClipBored archive uses unsupported format version \(version)."
|
||||
case .invalidArchive:
|
||||
return "The selected file is not a valid ClipBored archive."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final class ClipboardArchiveService {
|
||||
static let fileExtension = "clipboredarchive"
|
||||
private static let currentFormatVersion = 1
|
||||
|
||||
private let fileManager: FileManager
|
||||
|
||||
init(fileManager: FileManager = .default) {
|
||||
self.fileManager = fileManager
|
||||
}
|
||||
|
||||
func exportArchive(
|
||||
items: [ClipboardItem],
|
||||
to url: URL,
|
||||
cacheService: ClipboardCacheService,
|
||||
collections: [ClipboardArchiveCollection] = []
|
||||
) throws -> ClipboardArchiveSummary {
|
||||
var sidecarCount = 0
|
||||
let archivedItems = items.map { item -> ArchiveItem in
|
||||
let sidecars = archivedSidecars(for: item, cacheService: cacheService)
|
||||
sidecarCount += sidecars.count
|
||||
return ArchiveItem(item: item, sidecars: sidecars)
|
||||
}
|
||||
|
||||
let archive = ArchivePayload(
|
||||
formatVersion: Self.currentFormatVersion,
|
||||
createdBy: AppConfiguration.appName,
|
||||
exportedAt: Date(),
|
||||
collections: collections,
|
||||
items: archivedItems
|
||||
)
|
||||
|
||||
let encoder = JSONEncoder()
|
||||
encoder.dateEncodingStrategy = .secondsSince1970
|
||||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||
let data = try encoder.encode(archive)
|
||||
try fileManager.createDirectory(
|
||||
at: url.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
try data.write(to: url, options: .atomic)
|
||||
try? fileManager.setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path)
|
||||
|
||||
return ClipboardArchiveSummary(
|
||||
itemCount: items.count,
|
||||
sidecarCount: sidecarCount,
|
||||
skippedItemCount: 0,
|
||||
skippedSidecarCount: 0
|
||||
)
|
||||
}
|
||||
|
||||
func importArchive(
|
||||
from url: URL,
|
||||
cacheService: ClipboardCacheService
|
||||
) throws -> ClipboardArchiveImport {
|
||||
let data = try Data(contentsOf: url)
|
||||
let decoder = JSONDecoder()
|
||||
decoder.dateDecodingStrategy = .secondsSince1970
|
||||
let archive: ArchivePayload
|
||||
do {
|
||||
archive = try decoder.decode(ArchivePayload.self, from: data)
|
||||
} catch {
|
||||
throw ClipboardArchiveError.invalidArchive
|
||||
}
|
||||
guard archive.formatVersion <= Self.currentFormatVersion else {
|
||||
throw ClipboardArchiveError.unsupportedVersion(archive.formatVersion)
|
||||
}
|
||||
|
||||
var importedItems: [ClipboardItem] = []
|
||||
var sidecarCount = 0
|
||||
var skippedItemCount = 0
|
||||
var skippedSidecarCount = 0
|
||||
importedItems.reserveCapacity(archive.items.count)
|
||||
|
||||
for archivedItem in archive.items {
|
||||
guard var item = archivedItem.clipboardItem() else {
|
||||
skippedItemCount += 1
|
||||
continue
|
||||
}
|
||||
|
||||
for sidecar in archivedItem.sidecars {
|
||||
switch sidecar.role {
|
||||
case .image:
|
||||
if let path = cacheService.cacheImageSidecarData(sidecar.data, id: item.id) {
|
||||
item.imagePath = path
|
||||
if item.kind == .image {
|
||||
item.payload = path
|
||||
}
|
||||
sidecarCount += 1
|
||||
} else {
|
||||
skippedSidecarCount += 1
|
||||
}
|
||||
|
||||
case .thumbnail:
|
||||
if let path = cacheService.cacheImageSidecarData(sidecar.data, id: item.id, fileNamePrefix: "thumb") {
|
||||
item.thumbnailPath = path
|
||||
sidecarCount += 1
|
||||
} else {
|
||||
skippedSidecarCount += 1
|
||||
}
|
||||
|
||||
case .attachment:
|
||||
if let path = cacheService.cacheAttachmentData(
|
||||
sidecar.data,
|
||||
id: item.id,
|
||||
fileExtension: sidecar.fileExtension
|
||||
) {
|
||||
if item.kind.usesManagedPayloadAttachment {
|
||||
item.payload = path
|
||||
}
|
||||
sidecarCount += 1
|
||||
} else {
|
||||
skippedSidecarCount += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
importedItems.append(item)
|
||||
}
|
||||
|
||||
let summary = ClipboardArchiveSummary(
|
||||
itemCount: importedItems.count,
|
||||
sidecarCount: sidecarCount,
|
||||
skippedItemCount: skippedItemCount,
|
||||
skippedSidecarCount: skippedSidecarCount
|
||||
)
|
||||
return ClipboardArchiveImport(
|
||||
items: importedItems,
|
||||
collections: archive.collections ?? [],
|
||||
summary: summary
|
||||
)
|
||||
}
|
||||
|
||||
private func archivedSidecars(
|
||||
for item: ClipboardItem,
|
||||
cacheService: ClipboardCacheService
|
||||
) -> [ArchiveSidecar] {
|
||||
var sidecars: [ArchiveSidecar] = []
|
||||
var archivedPaths = Set<String>()
|
||||
|
||||
func append(_ role: ArchiveSidecarRole, path: String?, fallbackExtension: String) {
|
||||
guard let path, !path.clipboardTrimmed.isEmpty, !archivedPaths.contains(path) else { return }
|
||||
guard let data = cacheService.data(for: path) else { return }
|
||||
archivedPaths.insert(path)
|
||||
sidecars.append(
|
||||
ArchiveSidecar(
|
||||
role: role,
|
||||
fileExtension: fileExtension(for: path, fallback: fallbackExtension),
|
||||
data: data
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
switch item.kind {
|
||||
case .image:
|
||||
append(.image, path: item.imagePath ?? item.payload, fallbackExtension: "png")
|
||||
append(.thumbnail, path: item.thumbnailPath, fallbackExtension: "png")
|
||||
|
||||
case .url:
|
||||
append(.thumbnail, path: item.thumbnailPath, fallbackExtension: "png")
|
||||
|
||||
case .pdf:
|
||||
append(.attachment, path: item.payload, fallbackExtension: "pdf")
|
||||
|
||||
case .audio:
|
||||
append(.attachment, path: item.payload, fallbackExtension: "sound")
|
||||
|
||||
case .richText:
|
||||
append(.attachment, path: item.payload, fallbackExtension: "rtf")
|
||||
|
||||
case .video:
|
||||
append(.attachment, path: item.payload, fallbackExtension: VideoPayload.fileExtension(from: item.payload))
|
||||
|
||||
case .text, .file, .unknown, .color, .code:
|
||||
break
|
||||
}
|
||||
|
||||
return sidecars
|
||||
}
|
||||
|
||||
private func fileExtension(for path: String, fallback: String) -> String {
|
||||
let ext = URL(fileURLWithPath: path).pathExtension.clipboardTrimmed
|
||||
return ext.isEmpty ? fallback : ext
|
||||
}
|
||||
}
|
||||
|
||||
private struct ArchivePayload: Codable {
|
||||
let formatVersion: Int
|
||||
let createdBy: String
|
||||
let exportedAt: Date
|
||||
let collections: [ClipboardArchiveCollection]?
|
||||
let items: [ArchiveItem]
|
||||
|
||||
init(
|
||||
formatVersion: Int,
|
||||
createdBy: String,
|
||||
exportedAt: Date,
|
||||
collections: [ClipboardArchiveCollection],
|
||||
items: [ArchiveItem]
|
||||
) {
|
||||
self.formatVersion = formatVersion
|
||||
self.createdBy = createdBy
|
||||
self.exportedAt = exportedAt
|
||||
self.collections = collections.isEmpty ? nil : collections
|
||||
self.items = items
|
||||
}
|
||||
}
|
||||
|
||||
private struct ArchiveItem: Codable {
|
||||
let id: UUID
|
||||
let kind: Int
|
||||
let displayText: String
|
||||
let payload: String
|
||||
let payloadHash: String
|
||||
let createdAt: Date
|
||||
let lastUsedAt: Date
|
||||
let useCount: Int
|
||||
let sourceApp: String?
|
||||
let imagePath: String?
|
||||
let thumbnailPath: String?
|
||||
let isPinned: Bool
|
||||
let sourceAppBundleId: String?
|
||||
let ocrText: String?
|
||||
let collectionName: String?
|
||||
let customTitle: String?
|
||||
let sourceDeviceName: String?
|
||||
let sidecars: [ArchiveSidecar]
|
||||
|
||||
init(item: ClipboardItem, sidecars: [ArchiveSidecar]) {
|
||||
id = item.id
|
||||
kind = item.kind.rawValue
|
||||
displayText = item.displayText
|
||||
payload = item.payload
|
||||
payloadHash = item.payloadHash
|
||||
createdAt = item.createdAt
|
||||
lastUsedAt = item.lastUsedAt
|
||||
useCount = item.useCount
|
||||
sourceApp = item.sourceApp
|
||||
imagePath = item.imagePath
|
||||
thumbnailPath = item.thumbnailPath
|
||||
isPinned = item.isPinned
|
||||
sourceAppBundleId = item.sourceAppBundleId
|
||||
ocrText = item.ocrText
|
||||
collectionName = item.collectionName
|
||||
customTitle = item.customTitle
|
||||
sourceDeviceName = item.sourceDeviceName
|
||||
self.sidecars = sidecars
|
||||
}
|
||||
|
||||
func clipboardItem() -> ClipboardItem? {
|
||||
guard let kind = ClipboardItemKind(rawValue: kind) else { return nil }
|
||||
return ClipboardItem(
|
||||
id: id,
|
||||
kind: kind,
|
||||
displayText: displayText,
|
||||
payload: payload,
|
||||
payloadHash: payloadHash,
|
||||
createdAt: createdAt,
|
||||
lastUsedAt: lastUsedAt,
|
||||
useCount: useCount,
|
||||
sourceApp: sourceApp,
|
||||
imagePath: imagePath,
|
||||
thumbnailPath: thumbnailPath,
|
||||
isPinned: isPinned,
|
||||
sourceAppBundleId: sourceAppBundleId,
|
||||
ocrText: ocrText,
|
||||
collectionName: collectionName,
|
||||
customTitle: customTitle,
|
||||
sourceDeviceName: sourceDeviceName
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private struct ArchiveSidecar: Codable {
|
||||
let role: ArchiveSidecarRole
|
||||
let fileExtension: String
|
||||
let data: Data
|
||||
}
|
||||
|
||||
private enum ArchiveSidecarRole: String, Codable {
|
||||
case image
|
||||
case thumbnail
|
||||
case attachment
|
||||
}
|
||||
|
||||
private extension ClipboardItemKind {
|
||||
var usesManagedPayloadAttachment: Bool {
|
||||
switch self {
|
||||
case .pdf, .audio, .richText, .video:
|
||||
return true
|
||||
case .text, .url, .image, .file, .unknown, .color, .code:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -57,19 +57,46 @@ final class ClipboardCacheService {
|
||||
}
|
||||
|
||||
func cachePDF(_ data: Data, id: UUID) -> String? {
|
||||
cacheAttachment(data, id: id, fileExtension: "pdf")
|
||||
cacheAttachmentData(data, id: id, fileExtension: "pdf")
|
||||
}
|
||||
|
||||
func cacheAudio(_ data: Data, id: UUID) -> String? {
|
||||
cacheAttachment(data, id: id, fileExtension: "sound")
|
||||
cacheAttachmentData(data, id: id, fileExtension: "sound")
|
||||
}
|
||||
|
||||
func cacheVideo(_ data: Data, id: UUID, fileExtension: String) -> String? {
|
||||
cacheAttachment(data, id: id, fileExtension: fileExtension)
|
||||
cacheAttachmentData(data, id: id, fileExtension: fileExtension)
|
||||
}
|
||||
|
||||
func cacheRichText(_ data: Data, id: UUID) -> String? {
|
||||
cacheAttachment(data, id: id, fileExtension: "rtf")
|
||||
cacheAttachmentData(data, id: id, fileExtension: "rtf")
|
||||
}
|
||||
|
||||
func cacheAttachmentData(_ data: Data, id: UUID, fileExtension: String) -> String? {
|
||||
let sanitizedExtension = fileExtension
|
||||
.split { $0 == "." || $0 == "/" || $0 == "\\" }
|
||||
.last
|
||||
.map(String.init) ?? "dat"
|
||||
let normalizedExtension = sanitizedExtension.clipboardTrimmed.isEmpty ? "dat" : sanitizedExtension
|
||||
return cacheAttachment(data, id: id, fileExtension: normalizedExtension)
|
||||
}
|
||||
|
||||
func cacheImageSidecarData(_ data: Data, id: UUID, fileNamePrefix: String? = nil) -> String? {
|
||||
let prefix = fileNamePrefix?.clipboardTrimmed ?? ""
|
||||
let fileName = prefix.isEmpty
|
||||
? "\(id.uuidString).png"
|
||||
: "\(prefix)-\(id.uuidString).png"
|
||||
let url = imageDirectory.appendingPathComponent(fileName)
|
||||
do {
|
||||
try encrypted(data).write(to: url, options: .atomic)
|
||||
hardenFile(url)
|
||||
if let image = thumbImage(data) {
|
||||
thumbnailCache.setObject(image, forKey: url.path as NSString)
|
||||
}
|
||||
return url.path
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private func cacheAttachment(_ data: Data, id: UUID, fileExtension: String) -> String? {
|
||||
|
||||
111
sources/clipbored/services/ClipboardCloudSyncService.swift
Normal file
111
sources/clipbored/services/ClipboardCloudSyncService.swift
Normal file
@@ -0,0 +1,111 @@
|
||||
import Foundation
|
||||
|
||||
struct ClipboardCloudSyncStatus: Equatable {
|
||||
let isAvailable: Bool
|
||||
let archiveURL: URL?
|
||||
let lastModifiedAt: Date?
|
||||
let message: String
|
||||
}
|
||||
|
||||
protocol ClipboardCloudSyncServicing {
|
||||
func syncArchiveURL() throws -> URL
|
||||
func status() -> ClipboardCloudSyncStatus
|
||||
@discardableResult
|
||||
func push(store: ClipboardStore) throws -> ClipboardArchiveSummary
|
||||
@discardableResult
|
||||
func pull(store: ClipboardStore) throws -> ClipboardArchiveSummary
|
||||
}
|
||||
|
||||
enum ClipboardCloudSyncError: LocalizedError, Equatable {
|
||||
case unavailable
|
||||
case noRemoteArchive(URL)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .unavailable:
|
||||
return "iCloud Sync is unavailable. Sign ClipBored with an iCloud container entitlement and make sure iCloud Drive is enabled."
|
||||
case .noRemoteArchive:
|
||||
return "No ClipBored iCloud archive has been created yet."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final class ClipboardCloudSyncService: ClipboardCloudSyncServicing {
|
||||
static let archiveFileName = "ClipBored.\(ClipboardArchiveService.fileExtension)"
|
||||
|
||||
private let fileManager: FileManager
|
||||
private let containerProvider: () -> URL?
|
||||
|
||||
init(
|
||||
fileManager: FileManager = .default,
|
||||
containerProvider: @escaping () -> URL? = {
|
||||
FileManager.default.url(forUbiquityContainerIdentifier: nil)
|
||||
}
|
||||
) {
|
||||
self.fileManager = fileManager
|
||||
self.containerProvider = containerProvider
|
||||
}
|
||||
|
||||
func syncArchiveURL() throws -> URL {
|
||||
guard let containerURL = containerProvider() else {
|
||||
throw ClipboardCloudSyncError.unavailable
|
||||
}
|
||||
|
||||
let directory = containerURL
|
||||
.appendingPathComponent("Documents", isDirectory: true)
|
||||
.appendingPathComponent(AppConfiguration.appName, isDirectory: true)
|
||||
try fileManager.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
try? fileManager.setAttributes([.posixPermissions: 0o700], ofItemAtPath: directory.path)
|
||||
return directory.appendingPathComponent(Self.archiveFileName)
|
||||
}
|
||||
|
||||
func status() -> ClipboardCloudSyncStatus {
|
||||
do {
|
||||
let url = try syncArchiveURL()
|
||||
let attributes = try? fileManager.attributesOfItem(atPath: url.path)
|
||||
let lastModifiedAt = attributes?[.modificationDate] as? Date
|
||||
let message: String
|
||||
if lastModifiedAt != nil {
|
||||
message = "iCloud Sync is ready."
|
||||
} else {
|
||||
message = "iCloud Sync is ready. No remote archive yet."
|
||||
}
|
||||
return ClipboardCloudSyncStatus(
|
||||
isAvailable: true,
|
||||
archiveURL: url,
|
||||
lastModifiedAt: lastModifiedAt,
|
||||
message: message
|
||||
)
|
||||
} catch {
|
||||
return ClipboardCloudSyncStatus(
|
||||
isAvailable: false,
|
||||
archiveURL: nil,
|
||||
lastModifiedAt: nil,
|
||||
message: error.localizedDescription
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func push(store: ClipboardStore) throws -> ClipboardArchiveSummary {
|
||||
let url = try syncArchiveURL()
|
||||
return try store.exportArchive(to: url)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func pull(store: ClipboardStore) throws -> ClipboardArchiveSummary {
|
||||
let url = try syncArchiveURL()
|
||||
guard fileManager.fileExists(atPath: url.path) else {
|
||||
throw ClipboardCloudSyncError.noRemoteArchive(url)
|
||||
}
|
||||
|
||||
startDownloadingIfNeeded(url)
|
||||
return try store.importArchive(from: url)
|
||||
}
|
||||
|
||||
private func startDownloadingIfNeeded(_ url: URL) {
|
||||
let values = try? url.resourceValues(forKeys: [.isUbiquitousItemKey])
|
||||
guard values?.isUbiquitousItem == true else { return }
|
||||
try? fileManager.startDownloadingUbiquitousItem(at: url)
|
||||
}
|
||||
}
|
||||
@@ -20,8 +20,13 @@ final class ClipboardEncryptionService {
|
||||
private let resetProvider: () -> Void
|
||||
|
||||
init() {
|
||||
keyProvider = { ClipboardEncryptionKeychain.shared.symmetricKey() }
|
||||
resetProvider = { ClipboardEncryptionKeychain.shared.resetStoredKey() }
|
||||
if Self.shouldBypassSystemKeychain() {
|
||||
keyProvider = { nil }
|
||||
resetProvider = {}
|
||||
} else {
|
||||
keyProvider = { ClipboardEncryptionKeychain.shared.symmetricKey() }
|
||||
resetProvider = { ClipboardEncryptionKeychain.shared.resetStoredKey() }
|
||||
}
|
||||
}
|
||||
|
||||
init(keyProvider: @escaping () -> SymmetricKey?, resetProvider: @escaping () -> Void = {}) {
|
||||
@@ -102,6 +107,20 @@ final class ClipboardEncryptionService {
|
||||
func resetStoredKey() {
|
||||
resetProvider()
|
||||
}
|
||||
|
||||
static func shouldBypassSystemKeychain(
|
||||
environment: [String: String] = ProcessInfo.processInfo.environment,
|
||||
arguments: [String] = ProcessInfo.processInfo.arguments
|
||||
) -> Bool {
|
||||
if environment["CLIPBORED_DISABLE_KEYCHAIN"] == "1" ||
|
||||
environment["XCTestConfigurationFilePath"] != nil {
|
||||
return true
|
||||
}
|
||||
|
||||
return arguments.contains { argument in
|
||||
argument.contains(".xctest") || argument.hasSuffix("/xctest")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private enum ClipboardEncryptionKeychain {
|
||||
|
||||
@@ -15,6 +15,7 @@ final class ClipboardMonitorService {
|
||||
private var scheduledInterval: TimeInterval = 0
|
||||
private var didReportReadFailure = false
|
||||
private(set) var isPaused = false
|
||||
var onCapturedItem: (ClipboardItem) -> Void = { _ in }
|
||||
|
||||
init(
|
||||
store: ClipboardStore,
|
||||
@@ -143,7 +144,9 @@ final class ClipboardMonitorService {
|
||||
if let item = readCurrentItem(from: pasteboard) {
|
||||
reportCaptured(item)
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.store.upsert(item)
|
||||
guard let self else { return }
|
||||
let storedItem = self.store.upsert(item)
|
||||
self.onCapturedItem(storedItem)
|
||||
}
|
||||
} else if !didReportReadFailure {
|
||||
reportCaptureStatus("Clipboard changed, but ClipBored could not read a supported item.")
|
||||
@@ -431,10 +434,7 @@ final class ClipboardMonitorService {
|
||||
return nil
|
||||
}
|
||||
|
||||
let normalized = text
|
||||
.split(whereSeparator: \.isWhitespace)
|
||||
.joined(separator: " ")
|
||||
return String(normalized.prefix(AppConfiguration.maxRecognizedImageTextLength))
|
||||
return ImageTextExtractor.normalizedRecognizedText(text)
|
||||
}
|
||||
|
||||
private func hasImage(on pasteboard: NSPasteboard) -> Bool {
|
||||
|
||||
@@ -4,6 +4,22 @@ import Foundation
|
||||
import CommonCrypto
|
||||
import SQLite3
|
||||
|
||||
struct ClipboardStoreRemoval {
|
||||
let item: ClipboardItem
|
||||
let index: Int
|
||||
}
|
||||
|
||||
enum ClipboardStoreArchiveError: LocalizedError {
|
||||
case persistenceFailed
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .persistenceFailed:
|
||||
return "ClipBored could not save the imported archive."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final class ClipboardStore {
|
||||
private(set) var items: [ClipboardItem] = [] {
|
||||
didSet { notifyItemsChanged() }
|
||||
@@ -62,18 +78,17 @@ final class ClipboardStore {
|
||||
return base
|
||||
}
|
||||
|
||||
func upsert(_ incoming: ClipboardItem) {
|
||||
@discardableResult
|
||||
func upsert(_ incoming: ClipboardItem) -> ClipboardItem {
|
||||
guard let index = items.firstIndex(where: { settings.pruneDuplicates ? $0.payloadHash == incoming.payloadHash : false }) else {
|
||||
insertNewItem(incoming)
|
||||
return
|
||||
return insertNewItem(incoming)
|
||||
}
|
||||
|
||||
if settings.keepFirstImage, incoming.kind == .image {
|
||||
updateExistingKeepImage(incoming, at: index)
|
||||
return
|
||||
return updateExistingKeepImage(incoming, at: index)
|
||||
}
|
||||
|
||||
updateExistingItem(incoming, at: index)
|
||||
return updateExistingItem(incoming, at: index)
|
||||
}
|
||||
|
||||
func markUsed(_ id: UUID) {
|
||||
@@ -98,7 +113,11 @@ final class ClipboardStore {
|
||||
func setCollection(_ id: UUID, name: String?) {
|
||||
guard let index = items.firstIndex(where: { $0.id == id }) else { return }
|
||||
items[index].collectionName = ClipboardCollectionDefaults.normalizedName(name)
|
||||
persistAsync(.upsert(items[index]))
|
||||
let updated = items[index]
|
||||
normalizeHistoryLength()
|
||||
if items.contains(where: { $0.id == updated.id }) {
|
||||
persistAsync(.upsert(updated))
|
||||
}
|
||||
}
|
||||
|
||||
func setCustomTitle(_ id: UUID, title: String?) {
|
||||
@@ -123,21 +142,74 @@ final class ClipboardStore {
|
||||
return true
|
||||
}
|
||||
|
||||
func remove(_ id: UUID) {
|
||||
guard let index = items.firstIndex(where: { $0.id == id }) else { return }
|
||||
@discardableResult
|
||||
func updateImage(_ id: UUID, imagePath: String, thumbnailPath: String, payloadHash: String) -> Bool {
|
||||
guard let index = items.firstIndex(where: { $0.id == id }),
|
||||
items[index].kind == .image,
|
||||
!imagePath.clipboardTrimmed.isEmpty,
|
||||
!thumbnailPath.clipboardTrimmed.isEmpty,
|
||||
!payloadHash.clipboardTrimmed.isEmpty else {
|
||||
return false
|
||||
}
|
||||
|
||||
items[index].payload = imagePath
|
||||
items[index].imagePath = imagePath
|
||||
items[index].thumbnailPath = thumbnailPath
|
||||
items[index].payloadHash = payloadHash
|
||||
persistAsync(.upsert(items[index]), purgeCache: true)
|
||||
return true
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func updateImageText(_ id: UUID, ocrText: String) -> Bool {
|
||||
guard let normalizedText = ImageTextExtractor.normalizedRecognizedText(ocrText) else {
|
||||
return false
|
||||
}
|
||||
guard let index = items.firstIndex(where: { $0.id == id }),
|
||||
items[index].kind == .image else {
|
||||
return false
|
||||
}
|
||||
|
||||
items[index].ocrText = normalizedText
|
||||
persistAsync(.upsert(items[index]))
|
||||
return true
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func remove(_ id: UUID, purgeManagedCache: Bool = true) -> ClipboardStoreRemoval? {
|
||||
guard let index = items.firstIndex(where: { $0.id == id }) else { return nil }
|
||||
let removed = items.remove(at: index)
|
||||
if removed.kind.hasManagedCacheReference {
|
||||
cacheService.removeCachedReferences(removed)
|
||||
if purgeManagedCache {
|
||||
purgeManagedCacheReferences(for: [removed])
|
||||
}
|
||||
persistAsync(.delete(id))
|
||||
return ClipboardStoreRemoval(item: removed, index: index)
|
||||
}
|
||||
|
||||
func restore(_ removals: [ClipboardStoreRemoval]) {
|
||||
guard !removals.isEmpty else { return }
|
||||
|
||||
var restoredItems: [ClipboardItem] = []
|
||||
for removal in removals.sorted(by: { $0.index < $1.index }) {
|
||||
guard !items.contains(where: { $0.id == removal.item.id }) else { continue }
|
||||
let insertionIndex = max(0, min(removal.index, items.count))
|
||||
items.insert(removal.item, at: insertionIndex)
|
||||
restoredItems.append(removal.item)
|
||||
}
|
||||
|
||||
guard !restoredItems.isEmpty else { return }
|
||||
normalizeHistoryLength()
|
||||
let retainedIDs = Set(items.map(\.id))
|
||||
let retainedRestoredItems = restoredItems.filter { retainedIDs.contains($0.id) }
|
||||
persistAsync(.upsertMany(retainedRestoredItems))
|
||||
}
|
||||
|
||||
func purgeManagedCacheReferences(for removals: [ClipboardStoreRemoval]) {
|
||||
purgeManagedCacheReferences(for: removals.map(\.item))
|
||||
}
|
||||
|
||||
func removeAll() {
|
||||
for item in items {
|
||||
if item.kind.hasManagedCacheReference {
|
||||
cacheService.removeCachedReferences(item)
|
||||
}
|
||||
}
|
||||
purgeManagedCacheReferences(for: items)
|
||||
items.removeAll()
|
||||
persistAsync(.deleteAll)
|
||||
}
|
||||
@@ -155,27 +227,9 @@ final class ClipboardStore {
|
||||
}
|
||||
|
||||
func normalizeHistoryLength() {
|
||||
var pinnedCount = 0
|
||||
var unpinnedCount = 0
|
||||
var kept: [ClipboardItem] = []
|
||||
var overflow: [ClipboardItem] = []
|
||||
|
||||
kept.reserveCapacity(items.count)
|
||||
for item in items {
|
||||
if item.isPinned {
|
||||
if pinnedCount < AppConfiguration.maxPinnedItems {
|
||||
pinnedCount += 1
|
||||
kept.append(item)
|
||||
} else {
|
||||
overflow.append(item)
|
||||
}
|
||||
} else if unpinnedCount < settings.maxHistoryItems {
|
||||
unpinnedCount += 1
|
||||
kept.append(item)
|
||||
} else {
|
||||
overflow.append(item)
|
||||
}
|
||||
}
|
||||
let plan = retentionPlan(for: items)
|
||||
let kept = plan.kept
|
||||
let overflow = plan.overflow
|
||||
|
||||
guard !overflow.isEmpty else { return }
|
||||
|
||||
@@ -199,15 +253,80 @@ final class ClipboardStore {
|
||||
dataQueue.sync {}
|
||||
}
|
||||
|
||||
private func insertNewItem(_ incoming: ClipboardItem) {
|
||||
items.insert(incoming, at: 0)
|
||||
normalizeHistoryLength()
|
||||
persistAsync(.upsert(incoming), purgeCache: incoming.imagePath != nil)
|
||||
@discardableResult
|
||||
func exportArchive(to url: URL) throws -> ClipboardArchiveSummary {
|
||||
dataQueue.sync {}
|
||||
let collections = settings.customCollectionNames
|
||||
.map { name in
|
||||
ClipboardArchiveCollection(
|
||||
name: name,
|
||||
colorHex: settings.collectionColorHex(forCollectionNamed: name)
|
||||
)
|
||||
}
|
||||
return try ClipboardArchiveService().exportArchive(
|
||||
items: items,
|
||||
to: url,
|
||||
cacheService: cacheService,
|
||||
collections: collections
|
||||
)
|
||||
}
|
||||
|
||||
private func updateExistingKeepImage(_ incoming: ClipboardItem, at index: Int) {
|
||||
@discardableResult
|
||||
func exportCollection(named name: String, to url: URL) throws -> ClipboardArchiveSummary {
|
||||
guard let normalizedName = ClipboardCollectionDefaults.normalizedName(name) else {
|
||||
return try ClipboardArchiveService().exportArchive(items: [], to: url, cacheService: cacheService)
|
||||
}
|
||||
dataQueue.sync {}
|
||||
let collectionItems = items.filter {
|
||||
$0.collectionName?.caseInsensitiveCompare(normalizedName) == .orderedSame
|
||||
}
|
||||
let collection = ClipboardArchiveCollection(
|
||||
name: normalizedName,
|
||||
colorHex: settings.collectionColorHex(forCollectionNamed: normalizedName)
|
||||
)
|
||||
return try ClipboardArchiveService().exportArchive(
|
||||
items: collectionItems,
|
||||
to: url,
|
||||
cacheService: cacheService,
|
||||
collections: [collection]
|
||||
)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func importArchive(from url: URL) throws -> ClipboardArchiveSummary {
|
||||
dataQueue.sync {}
|
||||
let archiveImport = try ClipboardArchiveService().importArchive(
|
||||
from: url,
|
||||
cacheService: cacheService
|
||||
)
|
||||
for collection in archiveImport.collections {
|
||||
settings.ensureCollection(named: collection.name, colorHex: collection.colorHex)
|
||||
}
|
||||
for item in archiveImport.items {
|
||||
if let collectionName = item.collectionName {
|
||||
settings.ensureCollection(named: collectionName)
|
||||
}
|
||||
}
|
||||
guard saveImportedItems(archiveImport.items) else {
|
||||
throw ClipboardStoreArchiveError.persistenceFailed
|
||||
}
|
||||
return archiveImport.summary
|
||||
}
|
||||
|
||||
private func insertNewItem(_ incoming: ClipboardItem) -> ClipboardItem {
|
||||
items.insert(incoming, at: 0)
|
||||
normalizeHistoryLength()
|
||||
if let retained = items.first(where: { $0.id == incoming.id }) {
|
||||
persistAsync(.upsert(retained), purgeCache: retained.imagePath != nil)
|
||||
return retained
|
||||
}
|
||||
return incoming
|
||||
}
|
||||
|
||||
private func updateExistingKeepImage(_ incoming: ClipboardItem, at index: Int) -> ClipboardItem {
|
||||
cacheService.removeCachedReferences(incoming)
|
||||
var existing = items.remove(at: index)
|
||||
existing.createdAt = Date()
|
||||
existing.lastUsedAt = Date()
|
||||
existing.useCount += 1
|
||||
if !incoming.displayText.isEmpty {
|
||||
@@ -215,15 +334,22 @@ final class ClipboardStore {
|
||||
}
|
||||
existing.sourceApp = incoming.sourceApp
|
||||
existing.sourceAppBundleId = incoming.sourceAppBundleId
|
||||
existing.sourceDeviceName = incoming.sourceDeviceName
|
||||
existing.collectionName = incoming.collectionName ?? existing.collectionName
|
||||
existing.customTitle = incoming.customTitle ?? existing.customTitle
|
||||
items.insert(existing, at: 0)
|
||||
normalizeHistoryLength()
|
||||
persistAsync(.upsert(existing), purgeCache: existing.kind == .image)
|
||||
if let retained = items.first(where: { $0.id == existing.id }) {
|
||||
persistAsync(.upsert(retained), purgeCache: retained.kind == .image)
|
||||
return retained
|
||||
}
|
||||
return existing
|
||||
}
|
||||
|
||||
private func updateExistingItem(_ incoming: ClipboardItem, at index: Int) {
|
||||
private func updateExistingItem(_ incoming: ClipboardItem, at index: Int) -> ClipboardItem {
|
||||
var existing = items.remove(at: index)
|
||||
let previousCachedItem = existing
|
||||
existing.createdAt = Date()
|
||||
existing.lastUsedAt = Date()
|
||||
existing.useCount += 1
|
||||
if !incoming.displayText.isEmpty {
|
||||
@@ -234,6 +360,8 @@ final class ClipboardStore {
|
||||
existing.kind = incoming.kind
|
||||
existing.sourceApp = incoming.sourceApp
|
||||
existing.sourceAppBundleId = incoming.sourceAppBundleId
|
||||
existing.sourceDeviceName = incoming.sourceDeviceName
|
||||
existing.collectionName = incoming.collectionName ?? existing.collectionName
|
||||
existing.customTitle = incoming.customTitle ?? existing.customTitle
|
||||
|
||||
if incoming.kind == .image || incoming.kind == .url {
|
||||
@@ -252,7 +380,94 @@ final class ClipboardStore {
|
||||
|
||||
items.insert(existing, at: 0)
|
||||
normalizeHistoryLength()
|
||||
persistAsync(.upsert(existing), purgeCache: existing.imagePath != nil)
|
||||
if let retained = items.first(where: { $0.id == existing.id }) {
|
||||
persistAsync(.upsert(retained), purgeCache: retained.imagePath != nil)
|
||||
return retained
|
||||
}
|
||||
return existing
|
||||
}
|
||||
|
||||
private func purgeManagedCacheReferences(for items: [ClipboardItem]) {
|
||||
for item in items where item.kind.hasManagedCacheReference {
|
||||
cacheService.removeCachedReferences(item)
|
||||
}
|
||||
}
|
||||
|
||||
private func saveImportedItems(_ importedItems: [ClipboardItem]) -> Bool {
|
||||
guard !importedItems.isEmpty else { return true }
|
||||
|
||||
var mergedByID: [UUID: ClipboardItem] = [:]
|
||||
mergedByID.reserveCapacity(items.count + importedItems.count)
|
||||
for item in items {
|
||||
mergedByID[item.id] = item
|
||||
}
|
||||
for item in importedItems {
|
||||
mergedByID[item.id] = item
|
||||
}
|
||||
|
||||
let merged = mergedByID.values.sorted(by: historySort)
|
||||
let plan = retentionPlan(for: merged)
|
||||
guard saveAll(plan.kept) else { return false }
|
||||
|
||||
items = plan.kept
|
||||
if !plan.overflow.isEmpty {
|
||||
purgeManagedCacheReferences(for: plan.overflow)
|
||||
cacheService.purgeIfNeeded(maxBytes: settings.imageCacheMaxBytes)
|
||||
}
|
||||
hardenStoragePermissions()
|
||||
return true
|
||||
}
|
||||
|
||||
private func retentionPlan(for sourceItems: [ClipboardItem]) -> (kept: [ClipboardItem], overflow: [ClipboardItem]) {
|
||||
var pinnedCount = 0
|
||||
var unpinnedCount = 0
|
||||
var kept: [ClipboardItem] = []
|
||||
var overflow: [ClipboardItem] = []
|
||||
let retentionCutoff = settings.historyRetention.cutoffDate()
|
||||
|
||||
kept.reserveCapacity(sourceItems.count)
|
||||
for item in sourceItems {
|
||||
if item.isPinned {
|
||||
if pinnedCount < AppConfiguration.maxPinnedItems {
|
||||
pinnedCount += 1
|
||||
kept.append(item)
|
||||
} else if isCollectionRetained(item) {
|
||||
kept.append(item)
|
||||
} else {
|
||||
overflow.append(item)
|
||||
}
|
||||
} else if isCollectionRetained(item) {
|
||||
kept.append(item)
|
||||
} else if isExpiredByRetention(item, cutoff: retentionCutoff) {
|
||||
overflow.append(item)
|
||||
} else if unpinnedCount < settings.maxHistoryItems {
|
||||
unpinnedCount += 1
|
||||
kept.append(item)
|
||||
} else {
|
||||
overflow.append(item)
|
||||
}
|
||||
}
|
||||
|
||||
return (kept, overflow)
|
||||
}
|
||||
|
||||
private func historySort(_ lhs: ClipboardItem, _ rhs: ClipboardItem) -> Bool {
|
||||
if lhs.createdAt != rhs.createdAt {
|
||||
return lhs.createdAt > rhs.createdAt
|
||||
}
|
||||
if lhs.lastUsedAt != rhs.lastUsedAt {
|
||||
return lhs.lastUsedAt > rhs.lastUsedAt
|
||||
}
|
||||
return lhs.id.uuidString < rhs.id.uuidString
|
||||
}
|
||||
|
||||
private func isCollectionRetained(_ item: ClipboardItem) -> Bool {
|
||||
ClipboardCollectionDefaults.normalizedName(item.collectionName) != nil
|
||||
}
|
||||
|
||||
private func isExpiredByRetention(_ item: ClipboardItem, cutoff: Date?) -> Bool {
|
||||
guard let cutoff else { return false }
|
||||
return item.createdAt < cutoff
|
||||
}
|
||||
|
||||
private func persistAsync(_ mutation: PersistenceMutation, purgeCache: Bool = false) {
|
||||
@@ -341,7 +556,8 @@ final class ClipboardStore {
|
||||
is_pinned INTEGER NOT NULL DEFAULT 0,
|
||||
ocr_text TEXT,
|
||||
collection_name TEXT,
|
||||
custom_title TEXT
|
||||
custom_title TEXT,
|
||||
source_device_name TEXT
|
||||
);
|
||||
"""
|
||||
|
||||
@@ -357,6 +573,7 @@ final class ClipboardStore {
|
||||
_ = execute(createTable)
|
||||
_ = execute("ALTER TABLE clipboard_items ADD COLUMN collection_name TEXT;")
|
||||
_ = execute("ALTER TABLE clipboard_items ADD COLUMN custom_title TEXT;")
|
||||
_ = execute("ALTER TABLE clipboard_items ADD COLUMN source_device_name TEXT;")
|
||||
_ = execute(createIndexes)
|
||||
}
|
||||
|
||||
@@ -422,7 +639,8 @@ final class ClipboardStore {
|
||||
sourceAppBundleId: row["sourceAppBundleId"] as? String,
|
||||
ocrText: row["ocrText"] as? String,
|
||||
collectionName: row["collectionName"] as? String,
|
||||
customTitle: row["customTitle"] as? String
|
||||
customTitle: row["customTitle"] as? String,
|
||||
sourceDeviceName: row["sourceDeviceName"] as? String ?? ClipboardItem.localDeviceName
|
||||
)
|
||||
}
|
||||
|
||||
@@ -535,7 +753,7 @@ final class ClipboardStore {
|
||||
id, kind, display_text, payload, payload_hash, created_at,
|
||||
last_used_at, use_count, source_app, source_app_bundle_id,
|
||||
image_path, thumbnail_path, is_pinned, ocr_text, collection_name,
|
||||
custom_title
|
||||
custom_title, source_device_name
|
||||
FROM clipboard_items
|
||||
ORDER BY created_at DESC, last_used_at DESC
|
||||
"""
|
||||
@@ -617,6 +835,7 @@ final class ClipboardStore {
|
||||
let ocrTextValue = stringValue(13)
|
||||
let collectionNameValue = stringValue(14)
|
||||
let customTitleValue = stringValue(15)
|
||||
let sourceDeviceNameValue = stringValue(16)
|
||||
|
||||
needsEncryptionMigration = needsEncryptionMigration
|
||||
|| sourceAppValue.migrationNeeded
|
||||
@@ -626,6 +845,7 @@ final class ClipboardStore {
|
||||
|| ocrTextValue.migrationNeeded
|
||||
|| collectionNameValue.migrationNeeded
|
||||
|| customTitleValue.migrationNeeded
|
||||
|| sourceDeviceNameValue.migrationNeeded
|
||||
hadDecodeFailure = hadDecodeFailure
|
||||
|| sourceAppValue.decodeFailed
|
||||
|| sourceAppBundleIdValue.decodeFailed
|
||||
@@ -634,6 +854,7 @@ final class ClipboardStore {
|
||||
|| ocrTextValue.decodeFailed
|
||||
|| collectionNameValue.decodeFailed
|
||||
|| customTitleValue.decodeFailed
|
||||
|| sourceDeviceNameValue.decodeFailed
|
||||
|
||||
loaded.append(
|
||||
ClipboardItem(
|
||||
@@ -652,7 +873,8 @@ final class ClipboardStore {
|
||||
sourceAppBundleId: sourceAppBundleIdValue.value,
|
||||
ocrText: ocrTextValue.value,
|
||||
collectionName: collectionNameValue.value,
|
||||
customTitle: customTitleValue.value
|
||||
customTitle: customTitleValue.value,
|
||||
sourceDeviceName: sourceDeviceNameValue.value ?? ClipboardItem.localDeviceName
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -671,6 +893,7 @@ final class ClipboardStore {
|
||||
|
||||
private enum PersistenceMutation {
|
||||
case upsert(ClipboardItem)
|
||||
case upsertMany([ClipboardItem])
|
||||
case delete(UUID)
|
||||
case deleteMany([UUID])
|
||||
case deleteAll
|
||||
@@ -684,8 +907,8 @@ final class ClipboardStore {
|
||||
id, kind, display_text, payload, payload_hash,
|
||||
created_at, last_used_at, use_count, source_app,
|
||||
source_app_bundle_id, image_path, thumbnail_path, is_pinned, ocr_text,
|
||||
collection_name, custom_title
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);
|
||||
collection_name, custom_title, source_device_name
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);
|
||||
"""
|
||||
|
||||
switch mutation {
|
||||
@@ -719,6 +942,40 @@ final class ClipboardStore {
|
||||
shouldRollback = true
|
||||
}
|
||||
|
||||
case .upsertMany(let items):
|
||||
guard !items.isEmpty else { return }
|
||||
var statement: OpaquePointer?
|
||||
var shouldRollback = false
|
||||
defer {
|
||||
if let statement {
|
||||
sqlite3_finalize(statement)
|
||||
}
|
||||
if shouldRollback {
|
||||
_ = execute("ROLLBACK;")
|
||||
}
|
||||
}
|
||||
|
||||
guard execute("BEGIN IMMEDIATE TRANSACTION;") else { return }
|
||||
guard sqlite3_prepare_v2(db, insertSQL, -1, &statement, nil) == SQLITE_OK else {
|
||||
shouldRollback = true
|
||||
return
|
||||
}
|
||||
|
||||
for item in items {
|
||||
bindItem(item, to: statement)
|
||||
let stepResult = sqlite3_step(statement)
|
||||
if stepResult != SQLITE_DONE {
|
||||
shouldRollback = true
|
||||
return
|
||||
}
|
||||
sqlite3_reset(statement)
|
||||
sqlite3_clear_bindings(statement)
|
||||
}
|
||||
|
||||
if !execute("COMMIT;") {
|
||||
shouldRollback = true
|
||||
}
|
||||
|
||||
case .delete(let id):
|
||||
guard execute("BEGIN IMMEDIATE TRANSACTION;") else { return }
|
||||
let query = "DELETE FROM clipboard_items WHERE id = ?;"
|
||||
@@ -784,8 +1041,8 @@ final class ClipboardStore {
|
||||
id, kind, display_text, payload, payload_hash,
|
||||
created_at, last_used_at, use_count, source_app,
|
||||
source_app_bundle_id, image_path, thumbnail_path, is_pinned, ocr_text,
|
||||
collection_name, custom_title
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);
|
||||
collection_name, custom_title, source_device_name
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);
|
||||
"""
|
||||
|
||||
guard execute("BEGIN IMMEDIATE TRANSACTION;") else {
|
||||
@@ -870,5 +1127,6 @@ final class ClipboardStore {
|
||||
bindText(statement, 14, encryptionService.protect(item.ocrText))
|
||||
bindText(statement, 15, encryptionService.protect(item.collectionName))
|
||||
bindText(statement, 16, encryptionService.protect(item.customTitle))
|
||||
bindText(statement, 17, encryptionService.protect(item.sourceDeviceName))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,12 +29,20 @@ enum ImageTextExtractor {
|
||||
return normalized(lines)
|
||||
}
|
||||
|
||||
private static func normalized(_ lines: [String]) -> String? {
|
||||
let text = lines
|
||||
.filter { !$0.isEmpty }
|
||||
.joined(separator: " ")
|
||||
static func normalizedRecognizedText(_ text: String?) -> String? {
|
||||
guard let text else { return nil }
|
||||
let normalized = text
|
||||
.split(whereSeparator: \.isWhitespace)
|
||||
.joined(separator: " ")
|
||||
return text.isEmpty ? nil : text
|
||||
guard !normalized.isEmpty else { return nil }
|
||||
return String(normalized.prefix(AppConfiguration.maxRecognizedImageTextLength))
|
||||
}
|
||||
|
||||
private static func normalized(_ lines: [String]) -> String? {
|
||||
normalizedRecognizedText(
|
||||
lines
|
||||
.filter { !$0.isEmpty }
|
||||
.joined(separator: " ")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,6 +57,18 @@ final class PasteActionService {
|
||||
return .failed("Could not write item to clipboard.")
|
||||
}
|
||||
|
||||
return completePaste(targetApp: targetApp)
|
||||
}
|
||||
|
||||
func paste(_ items: [ClipboardItem], targetApp: NSRunningApplication?) -> PasteActionResult {
|
||||
guard writeToPasteboard(items) else {
|
||||
return .failed("Could not write items to clipboard.")
|
||||
}
|
||||
|
||||
return completePaste(targetApp: targetApp)
|
||||
}
|
||||
|
||||
private func completePaste(targetApp: NSRunningApplication?) -> PasteActionResult {
|
||||
guard let targetApp,
|
||||
!targetApp.isTerminated else {
|
||||
return .copied
|
||||
@@ -97,6 +109,11 @@ final class PasteActionService {
|
||||
writeToPasteboard(item) ? .copied : .failed("Could not write item to clipboard.")
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func copy(_ items: [ClipboardItem]) -> PasteActionResult {
|
||||
writeToPasteboard(items) ? .copied : .failed("Could not write items to clipboard.")
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func copyPlainText(_ item: ClipboardItem) -> PasteActionResult {
|
||||
writePlainTextToPasteboard(item) ? .copiedPlainText : .failed("Could not write plain text to clipboard.")
|
||||
@@ -242,6 +259,26 @@ final class PasteActionService {
|
||||
return didWrite
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func writeToPasteboard(_ items: [ClipboardItem]) -> Bool {
|
||||
guard !items.isEmpty else { return false }
|
||||
var writers: [NSPasteboardWriting] = []
|
||||
for item in items {
|
||||
let itemWriters = pasteboardWriters(for: item)
|
||||
guard !itemWriters.isEmpty else { return false }
|
||||
writers.append(contentsOf: itemWriters)
|
||||
}
|
||||
guard !writers.isEmpty else { return false }
|
||||
|
||||
let board = NSPasteboard.general
|
||||
board.clearContents()
|
||||
let didWrite = board.writeObjects(writers)
|
||||
if didWrite {
|
||||
ClipboardSelfWriteTracker.mark(changeCount: board.changeCount)
|
||||
}
|
||||
return didWrite
|
||||
}
|
||||
|
||||
func plainText(for item: ClipboardItem) -> String? {
|
||||
switch item.kind {
|
||||
case .text, .code, .unknown:
|
||||
|
||||
@@ -25,27 +25,32 @@ final class ShortcutManager {
|
||||
private enum HotKeyID: UInt32 {
|
||||
case openPanel = 1
|
||||
case openSettings = 2
|
||||
case stackCapture = 3
|
||||
}
|
||||
|
||||
private let onOpenClipboardPanel: () -> Void
|
||||
private let onOpenSettings: () -> Void
|
||||
private let onToggleStackCapture: () -> Void
|
||||
private let onStatusChange: (RegistrationStatus) -> Void
|
||||
|
||||
private var openBinding: ShortcutBinding
|
||||
private var settingsBinding: ShortcutBinding
|
||||
private var openHotKey: EventHotKeyRef?
|
||||
private var settingsHotKey: EventHotKeyRef?
|
||||
private var stackCaptureHotKey: EventHotKeyRef?
|
||||
private var eventHandler: EventHandlerRef?
|
||||
|
||||
init(
|
||||
onOpenClipboardPanel: @escaping () -> Void,
|
||||
onOpenSettings: @escaping () -> Void,
|
||||
onToggleStackCapture: @escaping () -> Void = {},
|
||||
onStatusChange: @escaping (RegistrationStatus) -> Void = { _ in },
|
||||
openShortcut: ShortcutBinding,
|
||||
settingsShortcut: ShortcutBinding
|
||||
) {
|
||||
self.onOpenClipboardPanel = onOpenClipboardPanel
|
||||
self.onOpenSettings = onOpenSettings
|
||||
self.onToggleStackCapture = onToggleStackCapture
|
||||
self.onStatusChange = onStatusChange
|
||||
self.openBinding = openShortcut
|
||||
self.settingsBinding = settingsShortcut
|
||||
@@ -59,7 +64,7 @@ final class ShortcutManager {
|
||||
func start() -> RegistrationStatus {
|
||||
stop()
|
||||
|
||||
if let status = validationFailure(for: openBinding) ?? validationFailure(for: settingsBinding) {
|
||||
if let status = Self.validationFailure(for: openBinding) ?? Self.validationFailure(for: settingsBinding) {
|
||||
onStatusChange(status)
|
||||
return status
|
||||
}
|
||||
@@ -68,6 +73,11 @@ final class ShortcutManager {
|
||||
onStatusChange(status)
|
||||
return status
|
||||
}
|
||||
if openBinding == Self.stackCaptureShortcut || settingsBinding == Self.stackCaptureShortcut {
|
||||
let status = RegistrationStatus.conflict(Self.stackCaptureShortcut.displayText)
|
||||
onStatusChange(status)
|
||||
return status
|
||||
}
|
||||
|
||||
var eventType = EventTypeSpec(eventClass: OSType(kEventClassKeyboard), eventKind: UInt32(kEventHotKeyPressed))
|
||||
let installStatus = InstallEventHandler(
|
||||
@@ -104,6 +114,13 @@ final class ShortcutManager {
|
||||
return settingsStatus
|
||||
}
|
||||
|
||||
let stackCaptureStatus = register(binding: Self.stackCaptureShortcut, id: .stackCapture, target: &stackCaptureHotKey)
|
||||
guard stackCaptureStatus == .registered else {
|
||||
stop()
|
||||
onStatusChange(stackCaptureStatus)
|
||||
return stackCaptureStatus
|
||||
}
|
||||
|
||||
onStatusChange(.registered)
|
||||
return .registered
|
||||
}
|
||||
@@ -122,12 +139,16 @@ final class ShortcutManager {
|
||||
if let settingsHotKey {
|
||||
UnregisterEventHotKey(settingsHotKey)
|
||||
}
|
||||
if let stackCaptureHotKey {
|
||||
UnregisterEventHotKey(stackCaptureHotKey)
|
||||
}
|
||||
if let eventHandler {
|
||||
RemoveEventHandler(eventHandler)
|
||||
}
|
||||
|
||||
openHotKey = nil
|
||||
settingsHotKey = nil
|
||||
stackCaptureHotKey = nil
|
||||
eventHandler = nil
|
||||
}
|
||||
|
||||
@@ -157,7 +178,7 @@ final class ShortcutManager {
|
||||
return .registrationFailed(osStatusMessage(status))
|
||||
}
|
||||
|
||||
private func validationFailure(for binding: ShortcutBinding) -> RegistrationStatus? {
|
||||
static func validationFailure(for binding: ShortcutBinding) -> RegistrationStatus? {
|
||||
guard Self.virtualKeyCode(for: binding.key) != nil else {
|
||||
return .unsupportedShortcut(binding.displayText)
|
||||
}
|
||||
@@ -187,6 +208,8 @@ final class ShortcutManager {
|
||||
onOpenClipboardPanel()
|
||||
case .openSettings:
|
||||
onOpenSettings()
|
||||
case .stackCapture:
|
||||
onToggleStackCapture()
|
||||
case nil:
|
||||
break
|
||||
}
|
||||
@@ -260,6 +283,11 @@ final class ShortcutManager {
|
||||
|
||||
private static let hotKeySignature: OSType = 0x436C7042
|
||||
|
||||
static let stackCaptureShortcut = ShortcutBinding(
|
||||
key: "c",
|
||||
modifierFlags: NSEvent.ModifierFlags([.command, .shift]).rawValue
|
||||
)
|
||||
|
||||
private func osStatusMessage(_ status: OSStatus) -> String {
|
||||
"OSStatus \(status)"
|
||||
}
|
||||
|
||||
@@ -16,14 +16,22 @@ struct ClipboardPanelReflowPlan {
|
||||
enum ClipboardPanelShortcutAction: Equatable {
|
||||
case copy
|
||||
case copyPlainText
|
||||
case edit
|
||||
case focusSearch
|
||||
case newCollection
|
||||
case nextCollection
|
||||
case open
|
||||
case pastePlainText
|
||||
case pasteStackNext
|
||||
case preview
|
||||
case previousCollection
|
||||
case rename
|
||||
case reveal
|
||||
case showInClipboard
|
||||
case toggleCapturePause
|
||||
case toggleStack
|
||||
case toggleStackCapture
|
||||
case undoDelete
|
||||
}
|
||||
|
||||
enum ClipboardPanelNavigationAction: Equatable {
|
||||
@@ -35,23 +43,41 @@ enum ClipboardPanelNavigationAction: Equatable {
|
||||
case previous
|
||||
}
|
||||
|
||||
enum ClipboardPanelSelectionAction: Equatable {
|
||||
case extendFirst
|
||||
case extendLast
|
||||
case extendNext
|
||||
case extendPageNext
|
||||
case extendPagePrevious
|
||||
case extendPrevious
|
||||
case selectAll
|
||||
}
|
||||
|
||||
final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanelDataSource, QLPreviewPanelDelegate {
|
||||
private enum Animation {
|
||||
static let showDuration: TimeInterval = 0.16
|
||||
static let hideDuration: TimeInterval = 0.12
|
||||
static let reflowDuration: TimeInterval = 0.10
|
||||
static let showDuration: TimeInterval = 0.22
|
||||
static let hideDuration: TimeInterval = 0.16
|
||||
static let reflowDuration: TimeInterval = 0.18
|
||||
static let easing: CAMediaTimingFunctionName = .easeInEaseOut
|
||||
}
|
||||
private enum Metrics {
|
||||
static let shelfHeightRatio: CGFloat = 0.42
|
||||
static let minimumShelfHeight: CGFloat = 408
|
||||
static let maximumShelfHeight: CGFloat = 430
|
||||
static let shelfHeightRatio: CGFloat = 0.18
|
||||
static let minimumShelfHeight: CGFloat = 156
|
||||
static let maximumShelfHeight: CGFloat = 176
|
||||
static let minimumUserShelfHeight: CGFloat = 150
|
||||
static let maximumUserShelfHeight: CGFloat = 680
|
||||
static let maximumUserShelfHeightRatio: CGFloat = 0.72
|
||||
static let preferredVerticalShelfWidth: CGFloat = 336
|
||||
static let minimumVerticalShelfWidth: CGFloat = 320
|
||||
static let maximumVerticalShelfWidthRatio: CGFloat = 0.30
|
||||
static let minimumBottomInset: CGFloat = 18
|
||||
static let maximumBottomInset: CGFloat = 20
|
||||
static let hiddenDockRevealInsetLimit: CGFloat = 8
|
||||
}
|
||||
|
||||
private var panel: NSPanel!
|
||||
private var panelView: ClipboardPanelView!
|
||||
private let settings: SettingsModel
|
||||
private(set) var isVisible = false
|
||||
private var clickMonitor: Any?
|
||||
private var keyMonitor: Any?
|
||||
@@ -62,6 +88,7 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel
|
||||
private let openSettings: () -> Void
|
||||
private var isAnimating = false
|
||||
private var quickLookURL: URL?
|
||||
private var linkPreviewController: LinkPreviewWindowController?
|
||||
private var screenParametersObserver: NSObjectProtocol?
|
||||
private static let quickPasteKeyCodes: [UInt16: Int] = [
|
||||
18: 0,
|
||||
@@ -97,6 +124,7 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel
|
||||
pollClipboardNow: @escaping () -> Void = {},
|
||||
openSettings: @escaping () -> Void = {}
|
||||
) {
|
||||
self.settings = settings
|
||||
self.viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: cacheService)
|
||||
self.pollClipboardNow = pollClipboardNow
|
||||
self.openSettings = openSettings
|
||||
@@ -117,7 +145,7 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel
|
||||
onPreview: { [weak self] in self?.previewSelected() }
|
||||
)
|
||||
|
||||
let contentSize = NSSize(width: 1200, height: 420)
|
||||
let contentSize = NSSize(width: 336, height: 760)
|
||||
panel = KeyablePanel(
|
||||
contentRect: NSRect(x: 0, y: 0, width: contentSize.width, height: contentSize.height),
|
||||
styleMask: [.nonactivatingPanel, .fullSizeContentView],
|
||||
@@ -132,13 +160,25 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel
|
||||
panel.delegate = self
|
||||
panel.isOpaque = false
|
||||
panel.backgroundColor = NSColor.clear
|
||||
panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
|
||||
panel.collectionBehavior = Self.panelCollectionBehavior
|
||||
panel.becomesKeyOnlyIfNeeded = false
|
||||
panel.titlebarAppearsTransparent = true
|
||||
panel.titleVisibility = .hidden
|
||||
panel.standardWindowButton(.miniaturizeButton)?.isHidden = true
|
||||
panel.standardWindowButton(.zoomButton)?.isHidden = true
|
||||
panel.standardWindowButton(.closeButton)?.isHidden = true
|
||||
applyPanelSharingSetting()
|
||||
|
||||
settings.observe { [weak self] change in
|
||||
guard change == .hideFromScreenCapture || change == .panelLayout else { return }
|
||||
DispatchQueue.main.async {
|
||||
if change == .hideFromScreenCapture {
|
||||
self?.applyPanelSharingSetting()
|
||||
} else {
|
||||
self?.reflowPanelForScreenChange()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
screenParametersObserver = NotificationCenter.default.addObserver(
|
||||
forName: NSApplication.didChangeScreenParametersNotification,
|
||||
@@ -157,15 +197,37 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel
|
||||
}
|
||||
}
|
||||
|
||||
func toggle() {
|
||||
func toggle(preferredScreen explicitScreen: NSScreen? = nil) {
|
||||
if isVisible {
|
||||
hide()
|
||||
} else {
|
||||
show()
|
||||
show(preferredScreen: explicitScreen)
|
||||
}
|
||||
}
|
||||
|
||||
func show() {
|
||||
func createTextClip() {
|
||||
performWhenVisible { [weak self] in
|
||||
guard let self else { return }
|
||||
self.panelView.createTextClip()
|
||||
}
|
||||
}
|
||||
|
||||
func createCollection() {
|
||||
performWhenVisible { [weak self] in
|
||||
guard let self else { return }
|
||||
self.panelView.createCollection()
|
||||
}
|
||||
}
|
||||
|
||||
func toggleStackCaptureMode() {
|
||||
viewModel.toggleStackCaptureMode()
|
||||
}
|
||||
|
||||
func addCapturedItemToStack(_ item: ClipboardItem) {
|
||||
viewModel.addCapturedItemToStack(item)
|
||||
}
|
||||
|
||||
func show(preferredScreen explicitScreen: NSScreen? = nil) {
|
||||
if isVisible || isAnimating { return }
|
||||
isAnimating = true
|
||||
isVisible = true
|
||||
@@ -173,7 +235,7 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel
|
||||
rememberTargetApplication()
|
||||
pollClipboardNow()
|
||||
|
||||
guard let screen = preferredScreen() else {
|
||||
guard let screen = preferredScreen(explicitScreen: explicitScreen) else {
|
||||
isVisible = false
|
||||
isAnimating = false
|
||||
return
|
||||
@@ -181,7 +243,8 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel
|
||||
activeScreenSnapshot = (screen.frame, screen.visibleFrame)
|
||||
let frames = Self.panelFrames(
|
||||
forScreenFrame: screen.frame,
|
||||
visibleFrame: screen.visibleFrame
|
||||
visibleFrame: screen.visibleFrame,
|
||||
side: settings.panelSide
|
||||
)
|
||||
panelView.setBottomSafeInset(Self.contentBottomInset(forScreenFrame: screen.frame, visibleFrame: screen.visibleFrame))
|
||||
panel.setFrame(frames.hidden, display: false)
|
||||
@@ -204,7 +267,7 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel
|
||||
self.panelView.finishOpeningTransition()
|
||||
guard self.isVisible else { return }
|
||||
self.installClickMonitor()
|
||||
self.panelView.focusSearchField()
|
||||
self.panelView.focusSelectedCardForKeyboardNavigation()
|
||||
}
|
||||
|
||||
installKeyMonitor()
|
||||
@@ -230,7 +293,8 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel
|
||||
let screenFrames = activeScreenSnapshot ?? activeScreenFrames()
|
||||
let hidden = Self.panelFrames(
|
||||
forScreenFrame: screenFrames.screenFrame,
|
||||
visibleFrame: screenFrames.visibleFrame
|
||||
visibleFrame: screenFrames.visibleFrame,
|
||||
side: settings.panelSide
|
||||
).hidden
|
||||
isAnimating = true
|
||||
|
||||
@@ -252,62 +316,120 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel
|
||||
}
|
||||
}
|
||||
|
||||
private func performWhenVisible(_ action: @escaping () -> Void) {
|
||||
if isVisible, !isAnimating {
|
||||
action()
|
||||
return
|
||||
}
|
||||
|
||||
show()
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + Animation.showDuration + 0.03) { [weak self] in
|
||||
guard let self, self.isVisible else { return }
|
||||
action()
|
||||
}
|
||||
}
|
||||
|
||||
func windowDidResignKey(_ notification: Notification) {
|
||||
hide()
|
||||
}
|
||||
|
||||
func windowDidBecomeKey(_ notification: Notification) {
|
||||
panelView.focusSearchField()
|
||||
panelView.focusSelectedCardForKeyboardNavigation()
|
||||
}
|
||||
|
||||
private func preferredScreen() -> NSScreen? {
|
||||
if let menuBarScreen = preferredScreenProvider() {
|
||||
return menuBarScreen
|
||||
}
|
||||
|
||||
private func preferredScreen(explicitScreen: NSScreen? = nil) -> NSScreen? {
|
||||
let point = NSEvent.mouseLocation
|
||||
return NSScreen.screens.first { NSMouseInRect(point, $0.frame, false) } ?? NSScreen.screens.first
|
||||
let pointerScreen = NSScreen.screens.first { NSMouseInRect(point, $0.frame, false) }
|
||||
return Self.selectedOpenScreen(
|
||||
explicit: explicitScreen,
|
||||
preferred: preferredScreenProvider(),
|
||||
pointer: pointerScreen,
|
||||
fallback: NSScreen.screens.first
|
||||
)
|
||||
}
|
||||
|
||||
static func selectedOpenScreen<Screen>(
|
||||
explicit: Screen?,
|
||||
preferred: Screen?,
|
||||
pointer: Screen?,
|
||||
fallback: Screen?
|
||||
) -> Screen? {
|
||||
explicit ?? preferred ?? pointer ?? fallback
|
||||
}
|
||||
|
||||
static func selectedReflowScreen<Screen>(
|
||||
currentPanel: Screen?,
|
||||
lastKnown: Screen?,
|
||||
preferred: Screen?,
|
||||
pointer: Screen?,
|
||||
fallback: Screen?
|
||||
) -> Screen? {
|
||||
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) -> (shown: NSRect, hidden: NSRect) {
|
||||
static func panelFrames(
|
||||
forScreenFrame screenFrame: CGRect,
|
||||
visibleFrame: CGRect,
|
||||
preferredHeight _: CGFloat? = nil,
|
||||
side: ClipboardPanelSide = .right
|
||||
) -> (shown: NSRect, hidden: NSRect) {
|
||||
let intersectedFrame = visibleFrame.intersection(screenFrame)
|
||||
let effectiveFrame = intersectedFrame.width > 0 && intersectedFrame.height > 0 ? intersectedFrame : screenFrame
|
||||
let frameHeight = effectiveFrame.height > 0 ? effectiveFrame.height : max(1, screenFrame.height)
|
||||
let height = panelHeight(within: frameHeight)
|
||||
let targetWidth = max(1, floor(effectiveFrame.width))
|
||||
let shownMinX = effectiveFrame.minX
|
||||
let shownMinY = max(screenFrame.minY, visibleFrame.minY)
|
||||
return verticalPanelFrames(forScreenFrame: screenFrame, effectiveFrame: effectiveFrame, side: side)
|
||||
}
|
||||
|
||||
private static func verticalPanelFrames(
|
||||
forScreenFrame screenFrame: CGRect,
|
||||
effectiveFrame: CGRect,
|
||||
side: ClipboardPanelSide
|
||||
) -> (shown: NSRect, hidden: NSRect) {
|
||||
let targetWidth = panelWidth(within: max(1, effectiveFrame.width))
|
||||
let targetHeight = max(1, floor(effectiveFrame.maxY - screenFrame.minY))
|
||||
let shownX: CGFloat
|
||||
let hiddenX: CGFloat
|
||||
switch side {
|
||||
case .left:
|
||||
shownX = effectiveFrame.minX
|
||||
hiddenX = shownX - targetWidth - 1
|
||||
case .right:
|
||||
shownX = effectiveFrame.maxX - targetWidth
|
||||
hiddenX = effectiveFrame.maxX + 1
|
||||
}
|
||||
let shown = NSRect(
|
||||
x: shownMinX,
|
||||
y: shownMinY,
|
||||
x: shownX,
|
||||
y: screenFrame.minY,
|
||||
width: targetWidth,
|
||||
height: height
|
||||
height: targetHeight
|
||||
)
|
||||
let hidden = NSRect(
|
||||
x: shown.minX,
|
||||
y: shown.minY - height - 1,
|
||||
x: hiddenX,
|
||||
y: shown.minY,
|
||||
width: shown.width,
|
||||
height: height
|
||||
height: targetHeight
|
||||
)
|
||||
return (shown, hidden)
|
||||
}
|
||||
|
||||
private static func panelHeight(within visibleHeight: CGFloat) -> CGFloat {
|
||||
let available = max(1, visibleHeight)
|
||||
let preferred = floor(available * Metrics.shelfHeightRatio)
|
||||
let clamped = min(max(preferred, Metrics.minimumShelfHeight), Metrics.maximumShelfHeight)
|
||||
return min(available, clamped)
|
||||
private static func panelWidth(within visibleWidth: CGFloat) -> CGFloat {
|
||||
let available = max(1, visibleWidth)
|
||||
let preferred = min(Metrics.preferredVerticalShelfWidth, floor(available * Metrics.maximumVerticalShelfWidthRatio))
|
||||
return min(available, max(Metrics.minimumVerticalShelfWidth, preferred))
|
||||
}
|
||||
|
||||
static func contentBottomInset(forScreenFrame screenFrame: CGRect, visibleFrame: CGRect) -> CGFloat {
|
||||
let dockInset = max(0, visibleFrame.minY - screenFrame.minY)
|
||||
let dockInset = visibleBottomDockInset(forScreenFrame: screenFrame, visibleFrame: visibleFrame)
|
||||
return max(Metrics.minimumBottomInset, min(Metrics.maximumBottomInset, dockInset + 2))
|
||||
}
|
||||
|
||||
private static func visibleBottomDockInset(forScreenFrame screenFrame: CGRect, visibleFrame: CGRect) -> CGFloat {
|
||||
let inset = max(0, visibleFrame.minY - screenFrame.minY)
|
||||
return inset > Metrics.hiddenDockRevealInsetLimit ? inset : 0
|
||||
}
|
||||
|
||||
static var animationProfile: ClipboardPanelAnimationProfile {
|
||||
ClipboardPanelAnimationProfile(
|
||||
showDuration: Animation.showDuration,
|
||||
@@ -317,6 +439,10 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel
|
||||
)
|
||||
}
|
||||
|
||||
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(
|
||||
@@ -325,6 +451,31 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel
|
||||
)
|
||||
}
|
||||
|
||||
static func reflowPlan(
|
||||
forScreenFrame screenFrame: CGRect,
|
||||
visibleFrame: CGRect,
|
||||
preferredHeight _: CGFloat? = nil,
|
||||
side: ClipboardPanelSide = .right
|
||||
) -> ClipboardPanelReflowPlan {
|
||||
let frames = panelFrames(
|
||||
forScreenFrame: screenFrame,
|
||||
visibleFrame: visibleFrame,
|
||||
side: side
|
||||
)
|
||||
return ClipboardPanelReflowPlan(
|
||||
frame: frames.shown,
|
||||
bottomSafeInset: contentBottomInset(forScreenFrame: screenFrame, visibleFrame: visibleFrame)
|
||||
)
|
||||
}
|
||||
|
||||
static func panelSharingType(hideFromScreenCapture: Bool) -> NSWindow.SharingType {
|
||||
hideFromScreenCapture ? .none : .readOnly
|
||||
}
|
||||
|
||||
private func applyPanelSharingSetting() {
|
||||
panel.sharingType = Self.panelSharingType(hideFromScreenCapture: settings.hideFromScreenCapture)
|
||||
}
|
||||
|
||||
private func rememberTargetApplication() {
|
||||
guard let frontmost = NSWorkspace.shared.frontmostApplication else {
|
||||
targetApplication = nil
|
||||
@@ -385,6 +536,11 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel
|
||||
self.performShortcutAction(action)
|
||||
return nil
|
||||
}
|
||||
if self.shouldHandlePanelKeyEvent(event),
|
||||
let action = Self.selectionShortcutAction(forKeyCode: event.keyCode, modifiers: event.modifierFlags) {
|
||||
self.performSelectionAction(action)
|
||||
return nil
|
||||
}
|
||||
guard self.shouldHandlePanelKeyEvent(event) else { return event }
|
||||
if let action = Self.navigationShortcutAction(forKeyCode: event.keyCode, modifiers: event.modifierFlags) {
|
||||
self.performNavigationAction(action)
|
||||
@@ -432,14 +588,47 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel
|
||||
panelView.focusSelectedCardForKeyboardNavigation()
|
||||
}
|
||||
|
||||
private func performSelectionAction(_ action: ClipboardPanelSelectionAction) {
|
||||
switch action {
|
||||
case .extendFirst:
|
||||
viewModel.selectItem(at: 0, mode: .range)
|
||||
case .extendLast:
|
||||
viewModel.selectItem(at: viewModel.visibleItems.count - 1, mode: .range)
|
||||
case .extendNext:
|
||||
extendSelection(by: 1)
|
||||
case .extendPageNext:
|
||||
extendSelection(by: panelView.visibleCardPageStep)
|
||||
case .extendPagePrevious:
|
||||
extendSelection(by: -panelView.visibleCardPageStep)
|
||||
case .extendPrevious:
|
||||
extendSelection(by: -1)
|
||||
case .selectAll:
|
||||
viewModel.selectAllVisibleItems()
|
||||
}
|
||||
panelView.focusSelectedCardForKeyboardNavigation()
|
||||
}
|
||||
|
||||
private func extendSelection(by delta: Int) {
|
||||
let count = viewModel.visibleItems.count
|
||||
guard count > 0 else { return }
|
||||
let target = max(0, min(count - 1, viewModel.selectedIndex + delta))
|
||||
viewModel.selectItem(at: target, mode: .range)
|
||||
}
|
||||
|
||||
private func performShortcutAction(_ action: ClipboardPanelShortcutAction) {
|
||||
switch action {
|
||||
case .copy:
|
||||
viewModel.copySelected()
|
||||
case .copyPlainText:
|
||||
viewModel.copySelectedPlainText()
|
||||
case .edit:
|
||||
panelView.editSelectedClip()
|
||||
case .focusSearch:
|
||||
panelView.focusSearchOrShowFilters()
|
||||
case .newCollection:
|
||||
panelView.createCollection()
|
||||
case .nextCollection:
|
||||
viewModel.selectAdjacentCollection(delta: 1)
|
||||
case .open:
|
||||
viewModel.openSelected()
|
||||
case .pastePlainText:
|
||||
@@ -448,17 +637,46 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel
|
||||
viewModel.pasteNextStackItem()
|
||||
case .preview:
|
||||
previewSelected()
|
||||
case .previousCollection:
|
||||
viewModel.selectAdjacentCollection(delta: -1)
|
||||
case .rename:
|
||||
panelView.renameSelectedClip()
|
||||
case .reveal:
|
||||
viewModel.revealSelected()
|
||||
case .showInClipboard:
|
||||
panelView.showSelectedInClipboard()
|
||||
case .toggleCapturePause:
|
||||
toggleCapturePauseFromShortcut()
|
||||
case .toggleStack:
|
||||
viewModel.toggleSelectedStackMembership()
|
||||
case .toggleStackCapture:
|
||||
viewModel.toggleStackCaptureMode()
|
||||
case .undoDelete:
|
||||
viewModel.undoLastDelete()
|
||||
}
|
||||
}
|
||||
|
||||
private func toggleCapturePauseFromShortcut() {
|
||||
settings.pauseCaptureUntil = nil
|
||||
if settings.pauseCapture {
|
||||
settings.pauseCapture = false
|
||||
settings.setCaptureStatus(message: "Capture resumed.")
|
||||
} else {
|
||||
settings.pauseCapture = true
|
||||
settings.setCaptureStatus(message: "Capture is paused.")
|
||||
}
|
||||
}
|
||||
|
||||
private func previewSelected() {
|
||||
if let request = viewModel.linkPreviewRequestForSelected() {
|
||||
quickLookURL = nil
|
||||
QLPreviewPanel.shared()?.orderOut(nil)
|
||||
showLinkPreview(request)
|
||||
return
|
||||
}
|
||||
|
||||
guard let url = viewModel.previewURLForSelected() else { return }
|
||||
linkPreviewController?.close()
|
||||
quickLookURL = url
|
||||
guard let previewPanel = QLPreviewPanel.shared() else {
|
||||
NSWorkspace.shared.open(url)
|
||||
@@ -470,6 +688,12 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel
|
||||
previewPanel.makeKeyAndOrderFront(nil)
|
||||
}
|
||||
|
||||
private func showLinkPreview(_ request: LinkPreviewRequest) {
|
||||
let controller = linkPreviewController ?? LinkPreviewWindowController()
|
||||
linkPreviewController = controller
|
||||
controller.show(request, relativeTo: panel)
|
||||
}
|
||||
|
||||
private func shouldHandlePanelKeyEvent(_ event: NSEvent) -> Bool {
|
||||
shouldHandlePanelKeyEvent(event, allowSearchFieldEditing: false)
|
||||
}
|
||||
@@ -498,6 +722,13 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel
|
||||
|
||||
static func navigationShortcutAction(forKeyCode keyCode: UInt16, modifiers: NSEvent.ModifierFlags) -> ClipboardPanelNavigationAction? {
|
||||
let relevantModifiers = modifiers.intersection(.deviceIndependentFlagsMask)
|
||||
if relevantModifiers == .command {
|
||||
switch keyCode {
|
||||
case 126: return .first
|
||||
case 125: return .last
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
guard relevantModifiers.isEmpty else { return nil }
|
||||
switch keyCode {
|
||||
case 115: return .first
|
||||
@@ -510,6 +741,23 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel
|
||||
}
|
||||
}
|
||||
|
||||
static func selectionShortcutAction(forKeyCode keyCode: UInt16, modifiers: NSEvent.ModifierFlags) -> ClipboardPanelSelectionAction? {
|
||||
let relevantModifiers = modifiers.intersection(.deviceIndependentFlagsMask)
|
||||
if relevantModifiers == .command {
|
||||
return keyCode == 0 ? .selectAll : nil
|
||||
}
|
||||
guard relevantModifiers == .shift else { return nil }
|
||||
switch keyCode {
|
||||
case 115: return .extendFirst
|
||||
case 119: return .extendLast
|
||||
case 124: return .extendNext
|
||||
case 121: return .extendPageNext
|
||||
case 116: return .extendPagePrevious
|
||||
case 123: return .extendPrevious
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
|
||||
static func quickPastePlainTextIndex(forKeyCode keyCode: UInt16, modifiers: NSEvent.ModifierFlags) -> Int? {
|
||||
let relevantModifiers = modifiers.intersection(.deviceIndependentFlagsMask)
|
||||
guard relevantModifiers == [.command, .shift] else { return nil }
|
||||
@@ -533,6 +781,10 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel
|
||||
switch keyCode {
|
||||
case 8:
|
||||
return .copy
|
||||
case 3:
|
||||
return .focusSearch
|
||||
case 14:
|
||||
return .edit
|
||||
case 31:
|
||||
return .open
|
||||
case 5:
|
||||
@@ -540,7 +792,15 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel
|
||||
case 16:
|
||||
return .preview
|
||||
case 15:
|
||||
return .reveal
|
||||
return .rename
|
||||
case 17:
|
||||
return .toggleCapturePause
|
||||
case 6:
|
||||
return .undoDelete
|
||||
case 123:
|
||||
return .previousCollection
|
||||
case 124:
|
||||
return .nextCollection
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
@@ -548,12 +808,15 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel
|
||||
|
||||
static func modifiedShortcutAction(forKeyCode keyCode: UInt16, modifiers: NSEvent.ModifierFlags) -> ClipboardPanelShortcutAction? {
|
||||
let relevantModifiers = modifiers.intersection(.deviceIndependentFlagsMask)
|
||||
if relevantModifiers == .shift {
|
||||
return keyCode == 36 ? .pastePlainText : nil
|
||||
}
|
||||
guard relevantModifiers == [.command, .shift] else { return nil }
|
||||
switch keyCode {
|
||||
case 1:
|
||||
return .toggleStack
|
||||
case 8:
|
||||
return .copyPlainText
|
||||
return .toggleStackCapture
|
||||
case 45:
|
||||
return .newCollection
|
||||
case 9:
|
||||
@@ -585,6 +848,34 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel
|
||||
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() {
|
||||
@@ -613,10 +904,22 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel
|
||||
private func reflowPanelForScreenChange() {
|
||||
guard isVisible else { return }
|
||||
guard !isAnimating else { return }
|
||||
guard let screen = preferredScreen() ?? panel.screen ?? NSScreen.screens.first else { return }
|
||||
let point = NSEvent.mouseLocation
|
||||
let pointerScreen = NSScreen.screens.first { NSMouseInRect(point, $0.frame, false) }
|
||||
guard let screen = Self.selectedReflowScreen(
|
||||
currentPanel: panel.screen,
|
||||
lastKnown: screen(matchingFrame: activeScreenSnapshot?.screenFrame),
|
||||
preferred: preferredScreenProvider(),
|
||||
pointer: pointerScreen,
|
||||
fallback: NSScreen.screens.first
|
||||
) else { return }
|
||||
|
||||
activeScreenSnapshot = (screen.frame, screen.visibleFrame)
|
||||
let plan = Self.reflowPlan(forScreenFrame: screen.frame, visibleFrame: screen.visibleFrame)
|
||||
let plan = Self.reflowPlan(
|
||||
forScreenFrame: screen.frame,
|
||||
visibleFrame: screen.visibleFrame,
|
||||
side: settings.panelSide
|
||||
)
|
||||
panelView.setBottomSafeInset(plan.bottomSafeInset)
|
||||
|
||||
isAnimating = true
|
||||
@@ -647,6 +950,11 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel
|
||||
let fallback = preferredScreen() ?? NSScreen.screens.first
|
||||
return (fallback?.frame ?? .zero, fallback?.visibleFrame ?? .zero)
|
||||
}
|
||||
|
||||
private func screen(matchingFrame frame: CGRect?) -> NSScreen? {
|
||||
guard let frame else { return nil }
|
||||
return NSScreen.screens.first { $0.frame == frame }
|
||||
}
|
||||
}
|
||||
|
||||
private final class KeyablePanel: NSPanel {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
404
sources/clipbored/views/LinkPreviewWindowController.swift
Normal file
404
sources/clipbored/views/LinkPreviewWindowController.swift
Normal file
@@ -0,0 +1,404 @@
|
||||
import AppKit
|
||||
import WebKit
|
||||
|
||||
final class LinkPreviewWindowController: NSWindowController, WKNavigationDelegate {
|
||||
private enum Metrics {
|
||||
static let minimumWidth: CGFloat = 760
|
||||
static let minimumHeight: CGFloat = 420
|
||||
static let preferredHeight: CGFloat = 560
|
||||
static let margin: CGFloat = 24
|
||||
static let panelGap: CGFloat = 14
|
||||
static let toolbarHeight: CGFloat = 52
|
||||
static let toolbarLeadingInset: CGFloat = 86
|
||||
}
|
||||
|
||||
private let webView: WKWebView
|
||||
private let titleLabel = NSTextField(labelWithString: "")
|
||||
private let addressLabel = NSTextField(labelWithString: "")
|
||||
private let statusLabel = NSTextField(labelWithString: "")
|
||||
private let progressIndicator = NSProgressIndicator()
|
||||
private let backButton = NSButton()
|
||||
private let forwardButton = NSButton()
|
||||
private let reloadButton = NSButton()
|
||||
private let openExternalButton = NSButton()
|
||||
private let openURL: (URL) -> Void
|
||||
private var progressObservation: NSKeyValueObservation?
|
||||
private var titleObservation: NSKeyValueObservation?
|
||||
private var canGoBackObservation: NSKeyValueObservation?
|
||||
private var canGoForwardObservation: NSKeyValueObservation?
|
||||
private var currentRequest: LinkPreviewRequest?
|
||||
private var currentPageURL: URL?
|
||||
private var acceptsObservedPageTitles = false
|
||||
|
||||
init(openURL: @escaping (URL) -> Void = { _ = NSWorkspace.shared.open($0) }) {
|
||||
self.openURL = openURL
|
||||
let configuration = WKWebViewConfiguration()
|
||||
configuration.websiteDataStore = .nonPersistent()
|
||||
configuration.preferences.javaScriptCanOpenWindowsAutomatically = false
|
||||
|
||||
webView = WKWebView(frame: .zero, configuration: configuration)
|
||||
let window = NSWindow(
|
||||
contentRect: NSRect(x: 0, y: 0, width: Metrics.minimumWidth, height: Metrics.preferredHeight),
|
||||
styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView],
|
||||
backing: .buffered,
|
||||
defer: false
|
||||
)
|
||||
window.title = "Link Preview"
|
||||
window.minSize = NSSize(width: 560, height: 420)
|
||||
window.isReleasedWhenClosed = false
|
||||
window.titleVisibility = .hidden
|
||||
window.titlebarAppearsTransparent = true
|
||||
|
||||
super.init(window: window)
|
||||
configureContent(in: window)
|
||||
configureObservations()
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
func show(_ request: LinkPreviewRequest, relativeTo parent: NSWindow?) {
|
||||
prepareForPreview(request)
|
||||
if let parent, let window {
|
||||
window.setFrame(Self.previewFrame(relativeTo: parent), display: false)
|
||||
}
|
||||
window?.makeKeyAndOrderFront(nil)
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
webView.load(URLRequest(url: request.url))
|
||||
}
|
||||
|
||||
private func prepareForPreview(_ request: LinkPreviewRequest) {
|
||||
currentRequest = request
|
||||
currentPageURL = request.url
|
||||
acceptsObservedPageTitles = false
|
||||
setTitleText(Self.displayTitle(for: request))
|
||||
setAddress(request.url)
|
||||
setStatus("Loading")
|
||||
}
|
||||
|
||||
private static func displayTitle(for request: LinkPreviewRequest) -> String {
|
||||
request.title.clipboardTrimmed.isEmpty ? request.url.host ?? "Link Preview" : request.title
|
||||
}
|
||||
|
||||
static func previewFrame(relativeTo parent: NSWindow) -> NSRect {
|
||||
let visibleFrame = parent.screen?.visibleFrame ?? parent.frame
|
||||
return previewFrame(parentFrame: parent.frame, visibleFrame: visibleFrame)
|
||||
}
|
||||
|
||||
static func previewFrame(parentFrame: NSRect, visibleFrame: NSRect) -> NSRect {
|
||||
let usableWidth = max(1, visibleFrame.width - (Metrics.margin * 2))
|
||||
let width = min(max(Metrics.minimumWidth, floor(parentFrame.width * 0.72)), usableWidth)
|
||||
let usableHeight = max(1, visibleFrame.height - (Metrics.margin * 2))
|
||||
let abovePanelY = parentFrame.maxY + Metrics.panelGap
|
||||
let availableAbovePanel = visibleFrame.maxY - abovePanelY - Metrics.margin
|
||||
let preferredHeight = min(Metrics.preferredHeight, usableHeight)
|
||||
let height = availableAbovePanel >= Metrics.minimumHeight
|
||||
? min(preferredHeight, availableAbovePanel)
|
||||
: preferredHeight
|
||||
let centeredX = parentFrame.midX - (width / 2)
|
||||
let x = min(max(visibleFrame.minX + Metrics.margin, centeredX), visibleFrame.maxX - width - Metrics.margin)
|
||||
let preferredY = availableAbovePanel >= Metrics.minimumHeight ? abovePanelY : visibleFrame.midY - (height / 2)
|
||||
let y = min(max(visibleFrame.minY + Metrics.margin, preferredY), visibleFrame.maxY - height - Metrics.margin)
|
||||
return NSRect(x: floor(x), y: floor(y), width: floor(width), height: floor(height))
|
||||
}
|
||||
|
||||
private func configureContent(in window: NSWindow) {
|
||||
webView.navigationDelegate = self
|
||||
webView.allowsBackForwardNavigationGestures = true
|
||||
webView.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
let content = NSView()
|
||||
content.wantsLayer = true
|
||||
content.layer?.backgroundColor = NSColor.windowBackgroundColor.cgColor
|
||||
content.translatesAutoresizingMaskIntoConstraints = false
|
||||
window.contentView = content
|
||||
|
||||
let toolbar = NSVisualEffectView()
|
||||
toolbar.material = .windowBackground
|
||||
toolbar.blendingMode = .withinWindow
|
||||
toolbar.state = .active
|
||||
toolbar.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
let titleColumn = NSStackView(views: [titleLabel, addressLabel])
|
||||
titleColumn.orientation = .vertical
|
||||
titleColumn.alignment = .leading
|
||||
titleColumn.spacing = 2
|
||||
titleColumn.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
titleLabel.font = .systemFont(ofSize: NSFont.systemFontSize, weight: .semibold)
|
||||
titleLabel.lineBreakMode = .byTruncatingTail
|
||||
titleLabel.maximumNumberOfLines = 1
|
||||
titleLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
||||
|
||||
addressLabel.font = .monospacedSystemFont(ofSize: NSFont.smallSystemFontSize, weight: .regular)
|
||||
addressLabel.textColor = .secondaryLabelColor
|
||||
addressLabel.lineBreakMode = .byTruncatingMiddle
|
||||
addressLabel.maximumNumberOfLines = 1
|
||||
addressLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
||||
|
||||
let controls = NSStackView(views: [
|
||||
configuredButton(backButton, symbol: "chevron.left", toolTip: "Back", action: #selector(goBack)),
|
||||
configuredButton(forwardButton, symbol: "chevron.right", toolTip: "Forward", action: #selector(goForward)),
|
||||
configuredButton(reloadButton, symbol: "arrow.clockwise", toolTip: "Reload", action: #selector(reload)),
|
||||
configuredButton(openExternalButton, symbol: "arrow.up.right.square", toolTip: "Open in Browser", action: #selector(openInBrowser))
|
||||
])
|
||||
controls.orientation = .horizontal
|
||||
controls.alignment = .centerY
|
||||
controls.spacing = 4
|
||||
controls.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
progressIndicator.isIndeterminate = false
|
||||
progressIndicator.minValue = 0
|
||||
progressIndicator.maxValue = 1
|
||||
progressIndicator.controlSize = .small
|
||||
progressIndicator.style = .bar
|
||||
progressIndicator.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
statusLabel.font = .systemFont(ofSize: NSFont.smallSystemFontSize)
|
||||
statusLabel.textColor = .secondaryLabelColor
|
||||
statusLabel.lineBreakMode = .byTruncatingTail
|
||||
statusLabel.maximumNumberOfLines = 1
|
||||
statusLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
content.addSubview(toolbar)
|
||||
toolbar.addSubview(controls)
|
||||
toolbar.addSubview(titleColumn)
|
||||
toolbar.addSubview(statusLabel)
|
||||
toolbar.addSubview(progressIndicator)
|
||||
content.addSubview(webView)
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
toolbar.leadingAnchor.constraint(equalTo: content.leadingAnchor),
|
||||
toolbar.trailingAnchor.constraint(equalTo: content.trailingAnchor),
|
||||
toolbar.topAnchor.constraint(equalTo: content.topAnchor),
|
||||
toolbar.heightAnchor.constraint(equalToConstant: Metrics.toolbarHeight),
|
||||
|
||||
controls.leadingAnchor.constraint(equalTo: toolbar.leadingAnchor, constant: Metrics.toolbarLeadingInset),
|
||||
controls.centerYAnchor.constraint(equalTo: toolbar.centerYAnchor),
|
||||
|
||||
titleColumn.leadingAnchor.constraint(equalTo: controls.trailingAnchor, constant: 14),
|
||||
titleColumn.centerYAnchor.constraint(equalTo: toolbar.centerYAnchor),
|
||||
titleColumn.trailingAnchor.constraint(lessThanOrEqualTo: statusLabel.leadingAnchor, constant: -16),
|
||||
|
||||
statusLabel.trailingAnchor.constraint(equalTo: toolbar.trailingAnchor, constant: -16),
|
||||
statusLabel.centerYAnchor.constraint(equalTo: toolbar.centerYAnchor),
|
||||
statusLabel.widthAnchor.constraint(lessThanOrEqualToConstant: 180),
|
||||
|
||||
progressIndicator.leadingAnchor.constraint(equalTo: toolbar.leadingAnchor),
|
||||
progressIndicator.trailingAnchor.constraint(equalTo: toolbar.trailingAnchor),
|
||||
progressIndicator.bottomAnchor.constraint(equalTo: toolbar.bottomAnchor),
|
||||
progressIndicator.heightAnchor.constraint(equalToConstant: 2),
|
||||
|
||||
webView.leadingAnchor.constraint(equalTo: content.leadingAnchor),
|
||||
webView.trailingAnchor.constraint(equalTo: content.trailingAnchor),
|
||||
webView.topAnchor.constraint(equalTo: toolbar.bottomAnchor),
|
||||
webView.bottomAnchor.constraint(equalTo: content.bottomAnchor)
|
||||
])
|
||||
|
||||
updateNavigationButtons()
|
||||
}
|
||||
|
||||
private func configuredButton(
|
||||
_ button: NSButton,
|
||||
symbol: String,
|
||||
toolTip: String,
|
||||
action: Selector
|
||||
) -> NSButton {
|
||||
let image = NSImage(systemSymbolName: symbol, accessibilityDescription: toolTip)
|
||||
image?.isTemplate = true
|
||||
button.image = image
|
||||
button.imagePosition = .imageOnly
|
||||
button.imageScaling = .scaleProportionallyDown
|
||||
button.isBordered = false
|
||||
button.wantsLayer = true
|
||||
button.layer?.cornerRadius = 6
|
||||
button.layer?.backgroundColor = NSColor.labelColor.withAlphaComponent(0.06).cgColor
|
||||
button.contentTintColor = .labelColor
|
||||
button.toolTip = toolTip
|
||||
button.setAccessibilityLabel(toolTip)
|
||||
button.target = self
|
||||
button.action = action
|
||||
button.translatesAutoresizingMaskIntoConstraints = false
|
||||
button.widthAnchor.constraint(equalToConstant: 30).isActive = true
|
||||
button.heightAnchor.constraint(equalToConstant: 30).isActive = true
|
||||
return button
|
||||
}
|
||||
|
||||
private func configureObservations() {
|
||||
progressObservation = webView.observe(\.estimatedProgress, options: [.initial, .new]) { [weak self] webView, _ in
|
||||
self?.progressIndicator.doubleValue = webView.estimatedProgress
|
||||
self?.progressIndicator.isHidden = webView.estimatedProgress >= 1
|
||||
}
|
||||
titleObservation = webView.observe(\.title, options: [.new]) { [weak self] webView, _ in
|
||||
self?.applyObservedPageTitle(webView.title)
|
||||
}
|
||||
canGoBackObservation = webView.observe(\.canGoBack, options: [.initial, .new]) { [weak self] _, _ in
|
||||
self?.updateNavigationButtons()
|
||||
}
|
||||
canGoForwardObservation = webView.observe(\.canGoForward, options: [.initial, .new]) { [weak self] _, _ in
|
||||
self?.updateNavigationButtons()
|
||||
}
|
||||
}
|
||||
|
||||
private func updateNavigationButtons() {
|
||||
backButton.isEnabled = webView.canGoBack
|
||||
forwardButton.isEnabled = webView.canGoForward
|
||||
}
|
||||
|
||||
private func applyObservedPageTitle(_ title: String?) {
|
||||
guard acceptsObservedPageTitles,
|
||||
let title = title?.clipboardTrimmed,
|
||||
!title.isEmpty else {
|
||||
return
|
||||
}
|
||||
setTitleText(title)
|
||||
}
|
||||
|
||||
private func setTitleText(_ text: String) {
|
||||
titleLabel.stringValue = text
|
||||
titleLabel.toolTip = text
|
||||
window?.title = text
|
||||
}
|
||||
|
||||
private func setAddress(_ url: URL) {
|
||||
currentPageURL = url
|
||||
let text = url.absoluteString
|
||||
addressLabel.stringValue = text
|
||||
addressLabel.toolTip = text
|
||||
}
|
||||
|
||||
private func setStatus(_ text: String) {
|
||||
statusLabel.stringValue = text
|
||||
statusLabel.toolTip = text.isEmpty ? nil : text
|
||||
}
|
||||
|
||||
@objc private func goBack() {
|
||||
guard webView.canGoBack else { return }
|
||||
webView.goBack()
|
||||
}
|
||||
|
||||
@objc private func goForward() {
|
||||
guard webView.canGoForward else { return }
|
||||
webView.goForward()
|
||||
}
|
||||
|
||||
@objc private func reload() {
|
||||
webView.reload()
|
||||
}
|
||||
|
||||
@objc private func openInBrowser() {
|
||||
guard let url = currentPageURL ?? currentRequest?.url else { return }
|
||||
openURL(url)
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) {
|
||||
acceptsObservedPageTitles = true
|
||||
setStatus("Loading")
|
||||
if let url = webView.url {
|
||||
setAddress(url)
|
||||
}
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
||||
setStatus("")
|
||||
if let url = webView.url {
|
||||
setAddress(url)
|
||||
}
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
|
||||
handleNavigationFailure(error)
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
|
||||
handleNavigationFailure(error)
|
||||
}
|
||||
|
||||
private func handleNavigationFailure(_ error: Error) {
|
||||
guard !Self.isNavigationCancellation(error) else { return }
|
||||
setStatus("Could not load")
|
||||
}
|
||||
|
||||
private static func isNavigationCancellation(_ error: Error) -> Bool {
|
||||
let nsError = error as NSError
|
||||
if nsError.domain == NSURLErrorDomain && nsError.code == NSURLErrorCancelled {
|
||||
return true
|
||||
}
|
||||
if let underlying = nsError.userInfo[NSUnderlyingErrorKey] as? Error {
|
||||
return isNavigationCancellation(underlying)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func webView(
|
||||
_ webView: WKWebView,
|
||||
decidePolicyFor navigationAction: WKNavigationAction,
|
||||
decisionHandler: @escaping (WKNavigationActionPolicy) -> Void
|
||||
) {
|
||||
guard let url = navigationAction.request.url else {
|
||||
decisionHandler(.cancel)
|
||||
return
|
||||
}
|
||||
|
||||
let scheme = url.scheme?.lowercased()
|
||||
guard scheme == "http" || scheme == "https" else {
|
||||
openURL(url)
|
||||
decisionHandler(.cancel)
|
||||
return
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
367
sources/clipbored/views/OnboardingWindowController.swift
Normal file
367
sources/clipbored/views/OnboardingWindowController.swift
Normal file
@@ -0,0 +1,367 @@
|
||||
import AppKit
|
||||
|
||||
final class OnboardingWindowController: NSObject, NSWindowDelegate {
|
||||
enum ShortcutChoice: String {
|
||||
case pasteStyle
|
||||
case clipBoredDefault
|
||||
case current
|
||||
}
|
||||
|
||||
struct PresentationChoice: Equatable {
|
||||
let showMenuBarIcon: Bool
|
||||
let showDockIcon: Bool
|
||||
}
|
||||
|
||||
static let pasteStyleOpenShortcut = ShortcutBinding(
|
||||
key: "v",
|
||||
modifierFlags: NSEvent.ModifierFlags.command.rawValue | NSEvent.ModifierFlags.shift.rawValue
|
||||
)
|
||||
|
||||
static func normalizedPresentation(showMenuBarIcon: Bool, showDockIcon: Bool) -> PresentationChoice {
|
||||
if showMenuBarIcon || showDockIcon {
|
||||
return PresentationChoice(showMenuBarIcon: showMenuBarIcon, showDockIcon: showDockIcon)
|
||||
}
|
||||
return PresentationChoice(showMenuBarIcon: true, showDockIcon: false)
|
||||
}
|
||||
|
||||
static func initialShortcutChoice(for binding: ShortcutBinding, onboardingCompleted: Bool) -> ShortcutChoice {
|
||||
if binding == pasteStyleOpenShortcut {
|
||||
return .pasteStyle
|
||||
}
|
||||
if binding == AppConfiguration.defaultOpenShortcut {
|
||||
return onboardingCompleted ? .clipBoredDefault : .pasteStyle
|
||||
}
|
||||
return .current
|
||||
}
|
||||
|
||||
static func shortcutBinding(for choice: ShortcutChoice, current: ShortcutBinding) -> ShortcutBinding {
|
||||
switch choice {
|
||||
case .pasteStyle:
|
||||
return pasteStyleOpenShortcut
|
||||
case .clipBoredDefault:
|
||||
return AppConfiguration.defaultOpenShortcut
|
||||
case .current:
|
||||
return current
|
||||
}
|
||||
}
|
||||
|
||||
private let settings: SettingsModel
|
||||
private let onOpenAccessibility: () -> Void
|
||||
private let onFinish: () -> Void
|
||||
private var window: NSWindow?
|
||||
private var didComplete = false
|
||||
|
||||
private let shortcutPopup = NSPopUpButton()
|
||||
private let historyRetentionPopup = NSPopUpButton()
|
||||
private let showMenuBarIconButton = NSButton()
|
||||
private let showDockIconButton = NSButton()
|
||||
private let launchAtLoginButton = NSButton()
|
||||
private let iCloudSyncButton = NSButton()
|
||||
private let permissionStatusLabel = NSTextField(labelWithString: "")
|
||||
|
||||
init(
|
||||
settings: SettingsModel,
|
||||
onOpenAccessibility: @escaping () -> Void,
|
||||
onFinish: @escaping () -> Void
|
||||
) {
|
||||
self.settings = settings
|
||||
self.onOpenAccessibility = onOpenAccessibility
|
||||
self.onFinish = onFinish
|
||||
super.init()
|
||||
|
||||
let window = NSWindow(
|
||||
contentRect: NSRect(x: 0, y: 0, width: 600, height: 540),
|
||||
styleMask: [.titled, .closable],
|
||||
backing: .buffered,
|
||||
defer: false
|
||||
)
|
||||
window.title = "Set Up ClipBored"
|
||||
window.contentView = makeContentView()
|
||||
window.delegate = self
|
||||
window.isReleasedWhenClosed = false
|
||||
window.center()
|
||||
self.window = window
|
||||
refreshFromSettings()
|
||||
}
|
||||
|
||||
func show() {
|
||||
guard let window else { return }
|
||||
refreshFromSettings()
|
||||
window.makeKeyAndOrderFront(nil)
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
}
|
||||
|
||||
func refreshPermissionStatus() {
|
||||
let isTrusted = AccessibilityPermissionService.isTrusted
|
||||
permissionStatusLabel.stringValue = isTrusted ? "Granted" : "Not granted; paste actions will copy instead."
|
||||
permissionStatusLabel.textColor = isTrusted ? .systemGreen : .systemOrange
|
||||
}
|
||||
|
||||
func windowWillClose(_ notification: Notification) {
|
||||
guard !didComplete else { return }
|
||||
completeSetup(applySelections: false, closeWindow: false)
|
||||
}
|
||||
|
||||
private func makeContentView() -> NSView {
|
||||
configureShortcutPopup()
|
||||
configureHistoryRetentionPopup()
|
||||
configureCheckbox(showMenuBarIconButton, title: "Show ClipBored in the menu bar", action: #selector(entryPointChanged))
|
||||
configureCheckbox(showDockIconButton, title: "Show ClipBored in the Dock", action: #selector(entryPointChanged))
|
||||
configureCheckbox(launchAtLoginButton, title: "Launch at login", action: nil)
|
||||
configureCheckbox(iCloudSyncButton, title: "Sync history with iCloud when available", action: nil)
|
||||
configureStatusLabel(permissionStatusLabel)
|
||||
|
||||
let content = NSView()
|
||||
let stack = NSStackView()
|
||||
stack.orientation = .vertical
|
||||
stack.alignment = .leading
|
||||
stack.spacing = 18
|
||||
stack.translatesAutoresizingMaskIntoConstraints = false
|
||||
content.addSubview(stack)
|
||||
|
||||
let titleLabel = NSTextField(labelWithString: "Set Up ClipBored")
|
||||
titleLabel.font = .boldSystemFont(ofSize: 22)
|
||||
let subtitleLabel = caption("Choose the shortcut, history window, and system entry points for this Mac.")
|
||||
let header = NSStackView(views: [titleLabel, subtitleLabel])
|
||||
header.orientation = .vertical
|
||||
header.alignment = .leading
|
||||
header.spacing = 4
|
||||
|
||||
stack.addArrangedSubview(header)
|
||||
stack.addArrangedSubview(section("Open ClipBored", [
|
||||
labeledRow("Shortcut", shortcutPopup)
|
||||
]))
|
||||
stack.addArrangedSubview(section("History", [
|
||||
labeledRow("Keep History", historyRetentionPopup)
|
||||
]))
|
||||
stack.addArrangedSubview(section("System", [
|
||||
showMenuBarIconButton,
|
||||
showDockIconButton,
|
||||
launchAtLoginButton,
|
||||
iCloudSyncButton
|
||||
]))
|
||||
stack.addArrangedSubview(section("Automatic Paste", [
|
||||
caption("Accessibility is only needed when ClipBored pastes directly into the previous app."),
|
||||
labeledRow("Accessibility", permissionStatusLabel),
|
||||
button("Open Accessibility Settings", #selector(openAccessibilitySettings))
|
||||
]))
|
||||
stack.addArrangedSubview(NSView())
|
||||
stack.addArrangedSubview(buttonRow())
|
||||
|
||||
if let spacer = stack.arrangedSubviews.dropLast().last {
|
||||
spacer.setContentHuggingPriority(.defaultLow, for: .vertical)
|
||||
}
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
stack.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 28),
|
||||
stack.trailingAnchor.constraint(equalTo: content.trailingAnchor, constant: -28),
|
||||
stack.topAnchor.constraint(equalTo: content.topAnchor, constant: 24),
|
||||
stack.bottomAnchor.constraint(equalTo: content.bottomAnchor, constant: -20),
|
||||
shortcutPopup.widthAnchor.constraint(greaterThanOrEqualToConstant: 220),
|
||||
historyRetentionPopup.widthAnchor.constraint(greaterThanOrEqualToConstant: 160)
|
||||
])
|
||||
|
||||
return content
|
||||
}
|
||||
|
||||
private func configureShortcutPopup() {
|
||||
shortcutPopup.removeAllItems()
|
||||
addShortcutItem("Shift-Command-V", .pasteStyle)
|
||||
addShortcutItem("Command-Option-V", .clipBoredDefault)
|
||||
if settings.openShortcut != Self.pasteStyleOpenShortcut,
|
||||
settings.openShortcut != AppConfiguration.defaultOpenShortcut {
|
||||
addShortcutItem("Keep Current (\(settings.openShortcut.displayText))", .current)
|
||||
}
|
||||
shortcutPopup.setAccessibilityLabel("Open ClipBored shortcut")
|
||||
}
|
||||
|
||||
private func configureHistoryRetentionPopup() {
|
||||
historyRetentionPopup.removeAllItems()
|
||||
for retention in HistoryRetention.allCases {
|
||||
historyRetentionPopup.addItem(withTitle: retention.title)
|
||||
historyRetentionPopup.lastItem?.representedObject = retention.rawValue
|
||||
}
|
||||
historyRetentionPopup.setAccessibilityLabel("Keep History")
|
||||
}
|
||||
|
||||
private func addShortcutItem(_ title: String, _ choice: ShortcutChoice) {
|
||||
shortcutPopup.addItem(withTitle: title)
|
||||
shortcutPopup.lastItem?.representedObject = choice.rawValue
|
||||
}
|
||||
|
||||
private func refreshFromSettings() {
|
||||
selectShortcut(Self.initialShortcutChoice(for: settings.openShortcut, onboardingCompleted: settings.onboardingCompleted))
|
||||
select(historyRetentionPopup, rawValue: settings.historyRetention.rawValue)
|
||||
let presentation = Self.normalizedPresentation(
|
||||
showMenuBarIcon: settings.showMenuBarIcon,
|
||||
showDockIcon: settings.showDockIcon
|
||||
)
|
||||
showMenuBarIconButton.state = presentation.showMenuBarIcon ? .on : .off
|
||||
showDockIconButton.state = presentation.showDockIcon ? .on : .off
|
||||
launchAtLoginButton.state = settings.launchAtLogin ? .on : .off
|
||||
iCloudSyncButton.state = settings.iCloudSyncEnabled ? .on : .off
|
||||
refreshPermissionStatus()
|
||||
}
|
||||
|
||||
private func section(_ title: String, _ views: [NSView]) -> NSView {
|
||||
let titleLabel = NSTextField(labelWithString: title)
|
||||
titleLabel.font = .boldSystemFont(ofSize: NSFont.systemFontSize)
|
||||
let stack = NSStackView(views: [titleLabel] + views)
|
||||
stack.orientation = .vertical
|
||||
stack.alignment = .leading
|
||||
stack.spacing = 8
|
||||
stack.widthAnchor.constraint(greaterThanOrEqualToConstant: 520).isActive = true
|
||||
return stack
|
||||
}
|
||||
|
||||
private func row(_ views: [NSView]) -> NSView {
|
||||
let stack = NSStackView(views: views)
|
||||
stack.orientation = .horizontal
|
||||
stack.alignment = .centerY
|
||||
stack.spacing = 10
|
||||
return stack
|
||||
}
|
||||
|
||||
private func labeledRow(_ title: String, _ control: NSView) -> NSView {
|
||||
let label = NSTextField(labelWithString: title)
|
||||
label.widthAnchor.constraint(equalToConstant: 120).isActive = true
|
||||
return row([label, control])
|
||||
}
|
||||
|
||||
private func caption(_ text: String) -> NSTextField {
|
||||
let label = NSTextField(wrappingLabelWithString: text)
|
||||
label.textColor = .secondaryLabelColor
|
||||
label.font = .systemFont(ofSize: NSFont.smallSystemFontSize)
|
||||
label.widthAnchor.constraint(lessThanOrEqualToConstant: 520).isActive = true
|
||||
return label
|
||||
}
|
||||
|
||||
private func button(_ title: String, _ action: Selector) -> NSButton {
|
||||
let control = NSButton(title: title, target: self, action: action)
|
||||
control.bezelStyle = .rounded
|
||||
control.setAccessibilityLabel(title)
|
||||
return control
|
||||
}
|
||||
|
||||
private func buttonRow() -> NSView {
|
||||
let skipButton = NSButton(title: "Skip", target: self, action: #selector(skipSetup))
|
||||
skipButton.bezelStyle = .rounded
|
||||
skipButton.setAccessibilityLabel("Skip setup")
|
||||
|
||||
let finishButton = NSButton(title: "Finish Setup", target: self, action: #selector(finishSetup))
|
||||
finishButton.bezelStyle = .rounded
|
||||
finishButton.keyEquivalent = "\r"
|
||||
finishButton.setAccessibilityLabel("Finish setup")
|
||||
|
||||
let spacer = NSView()
|
||||
let stack = NSStackView(views: [spacer, skipButton, finishButton])
|
||||
stack.orientation = .horizontal
|
||||
stack.alignment = .centerY
|
||||
stack.spacing = 10
|
||||
stack.widthAnchor.constraint(equalToConstant: 520).isActive = true
|
||||
spacer.setContentHuggingPriority(.defaultLow, for: .horizontal)
|
||||
return stack
|
||||
}
|
||||
|
||||
private func configureCheckbox(_ control: NSButton, title: String, action: Selector?) {
|
||||
control.setButtonType(.switch)
|
||||
control.title = title
|
||||
control.target = action == nil ? nil : self
|
||||
control.action = action
|
||||
control.setAccessibilityLabel(title)
|
||||
}
|
||||
|
||||
private func configureStatusLabel(_ label: NSTextField) {
|
||||
label.font = .systemFont(ofSize: NSFont.smallSystemFontSize)
|
||||
label.lineBreakMode = .byTruncatingTail
|
||||
}
|
||||
|
||||
private func selectShortcut(_ choice: ShortcutChoice) {
|
||||
for item in shortcutPopup.itemArray where item.representedObject as? String == choice.rawValue {
|
||||
shortcutPopup.select(item)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
private func select(_ popup: NSPopUpButton, rawValue: Int) {
|
||||
for item in popup.itemArray where item.representedObject as? Int == rawValue {
|
||||
popup.select(item)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
private func selectedShortcutChoice() -> ShortcutChoice {
|
||||
guard let rawValue = shortcutPopup.selectedItem?.representedObject as? String,
|
||||
let choice = ShortcutChoice(rawValue: rawValue)
|
||||
else {
|
||||
return .pasteStyle
|
||||
}
|
||||
return choice
|
||||
}
|
||||
|
||||
@objc private func entryPointChanged() {
|
||||
let presentation = Self.normalizedPresentation(
|
||||
showMenuBarIcon: showMenuBarIconButton.state == .on,
|
||||
showDockIcon: showDockIconButton.state == .on
|
||||
)
|
||||
showMenuBarIconButton.state = presentation.showMenuBarIcon ? .on : .off
|
||||
showDockIconButton.state = presentation.showDockIcon ? .on : .off
|
||||
}
|
||||
|
||||
@objc private func openAccessibilitySettings() {
|
||||
onOpenAccessibility()
|
||||
refreshPermissionStatus()
|
||||
}
|
||||
|
||||
@objc private func finishSetup() {
|
||||
completeSetup(applySelections: true, closeWindow: true)
|
||||
}
|
||||
|
||||
@objc private func skipSetup() {
|
||||
completeSetup(applySelections: false, closeWindow: true)
|
||||
}
|
||||
|
||||
private func completeSetup(applySelections: Bool, closeWindow: Bool) {
|
||||
guard !didComplete else { return }
|
||||
didComplete = true
|
||||
|
||||
if applySelections {
|
||||
applySelectedSettings()
|
||||
}
|
||||
settings.markAccessibilityNoticeShown()
|
||||
settings.markOnboardingCompleted()
|
||||
|
||||
if closeWindow {
|
||||
window?.delegate = nil
|
||||
window?.close()
|
||||
}
|
||||
onFinish()
|
||||
}
|
||||
|
||||
private func applySelectedSettings() {
|
||||
settings.openShortcut = Self.shortcutBinding(for: selectedShortcutChoice(), current: settings.openShortcut)
|
||||
if let rawValue = historyRetentionPopup.selectedItem?.representedObject as? Int,
|
||||
let retention = HistoryRetention(rawValue: rawValue) {
|
||||
settings.historyRetention = retention
|
||||
}
|
||||
|
||||
let presentation = Self.normalizedPresentation(
|
||||
showMenuBarIcon: showMenuBarIconButton.state == .on,
|
||||
showDockIcon: showDockIconButton.state == .on
|
||||
)
|
||||
settings.showMenuBarIcon = presentation.showMenuBarIcon
|
||||
settings.showDockIcon = presentation.showDockIcon
|
||||
settings.launchAtLogin = launchAtLoginButton.state == .on
|
||||
settings.iCloudSyncEnabled = iCloudSyncButton.state == .on
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
var debugShowMenuBarIconIsEnabled: Bool {
|
||||
showMenuBarIconButton.state == .on
|
||||
}
|
||||
|
||||
var debugShowDockIconIsEnabled: Bool {
|
||||
showDockIconButton.state == .on
|
||||
}
|
||||
#endif
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -78,9 +78,14 @@ final class AppDelegateTests: XCTestCase {
|
||||
"Captured text from Safari.",
|
||||
"-",
|
||||
"Show Clipboard",
|
||||
"New Collection",
|
||||
"Stack Capture",
|
||||
settingsTitle,
|
||||
"-",
|
||||
"Pause Capture",
|
||||
"Pause for 5 Minutes",
|
||||
"Pause for 30 Minutes",
|
||||
"Pause for 1 Hour",
|
||||
"-",
|
||||
"Quit ClipBored"
|
||||
]
|
||||
@@ -91,9 +96,25 @@ final class AppDelegateTests: XCTestCase {
|
||||
XCTAssertTrue(showClipboard?.keyEquivalentModifierMask.contains(.command) == true)
|
||||
XCTAssertTrue(showClipboard?.keyEquivalentModifierMask.contains(.option) == true)
|
||||
|
||||
XCTAssertNil(menu.items.first { $0.title == "New Text Clip" })
|
||||
|
||||
let newCollection = menu.items.first { $0.title == "New Collection" }
|
||||
XCTAssertEqual(newCollection?.keyEquivalent, "n")
|
||||
XCTAssertTrue(newCollection?.keyEquivalentModifierMask.contains(.command) == true)
|
||||
XCTAssertTrue(newCollection?.keyEquivalentModifierMask.contains(.shift) == true)
|
||||
|
||||
let stackCapture = menu.items.first { $0.title == "Stack Capture" }
|
||||
XCTAssertEqual(stackCapture?.keyEquivalent, "c")
|
||||
XCTAssertTrue(stackCapture?.keyEquivalentModifierMask.contains(.command) == true)
|
||||
XCTAssertTrue(stackCapture?.keyEquivalentModifierMask.contains(.shift) == true)
|
||||
|
||||
let settings = menu.items.first { $0.title == settingsTitle }
|
||||
XCTAssertEqual(settings?.keyEquivalent, ",")
|
||||
XCTAssertTrue(settings?.keyEquivalentModifierMask.contains(.command) == true)
|
||||
|
||||
let pauseCapture = menu.items.first { $0.title == "Pause Capture" }
|
||||
XCTAssertEqual(pauseCapture?.keyEquivalent, "t")
|
||||
XCTAssertTrue(pauseCapture?.keyEquivalentModifierMask.contains(.command) == true)
|
||||
}
|
||||
|
||||
func testStatusMenuPausedStateTakesPriorityOverOlderCaptureStatus() {
|
||||
@@ -116,8 +137,55 @@ final class AppDelegateTests: XCTestCase {
|
||||
|
||||
XCTAssertEqual(presentation.summary, "Capture Paused - 1 clip")
|
||||
XCTAssertEqual(presentation.detail, "Capture is paused.")
|
||||
XCTAssertEqual(menu.items.first { $0.title == "Resume Capture" }?.state, .on)
|
||||
let resumeCapture = menu.items.first { $0.title == "Resume Capture" }
|
||||
XCTAssertEqual(resumeCapture?.state, .on)
|
||||
XCTAssertEqual(resumeCapture?.keyEquivalent, "t")
|
||||
XCTAssertTrue(resumeCapture?.keyEquivalentModifierMask.contains(.command) == true)
|
||||
XCTAssertNil(menu.items.first { $0.title == "Pause Capture" })
|
||||
XCTAssertNil(menu.items.first { $0.title == "Pause for 5 Minutes" })
|
||||
}
|
||||
|
||||
func testStatusMenuPresentationShowsTimedPauseRemainingBeforeOlderStatuses() {
|
||||
let now = Date(timeIntervalSince1970: 1_000)
|
||||
let presentation = AppDelegate.statusMenuPresentation(
|
||||
historyCount: 12,
|
||||
isCapturePaused: true,
|
||||
pauseCaptureUntil: now.addingTimeInterval(5 * 60),
|
||||
now: now,
|
||||
captureStatus: "Captured text from Safari.",
|
||||
pasteStatus: "",
|
||||
shortcutStatus: "",
|
||||
accessibilityStatus: "",
|
||||
launchAtLoginStatus: ""
|
||||
)
|
||||
|
||||
XCTAssertEqual(presentation.summary, "Capture Paused - 12 clips")
|
||||
XCTAssertEqual(presentation.detail, "Capture is paused for 5 more minutes.")
|
||||
}
|
||||
|
||||
func testCapturePauseExpiryOnlyAppliesToExpiredTimedPauses() {
|
||||
let now = Date(timeIntervalSince1970: 1_000)
|
||||
|
||||
XCTAssertTrue(AppDelegate.shouldResumeExpiredCapturePause(
|
||||
isCapturePaused: true,
|
||||
pauseCaptureUntil: now,
|
||||
now: now
|
||||
))
|
||||
XCTAssertFalse(AppDelegate.shouldResumeExpiredCapturePause(
|
||||
isCapturePaused: true,
|
||||
pauseCaptureUntil: now.addingTimeInterval(1),
|
||||
now: now
|
||||
))
|
||||
XCTAssertFalse(AppDelegate.shouldResumeExpiredCapturePause(
|
||||
isCapturePaused: false,
|
||||
pauseCaptureUntil: now.addingTimeInterval(-1),
|
||||
now: now
|
||||
))
|
||||
XCTAssertFalse(AppDelegate.shouldResumeExpiredCapturePause(
|
||||
isCapturePaused: true,
|
||||
pauseCaptureUntil: nil,
|
||||
now: now
|
||||
))
|
||||
}
|
||||
|
||||
func testStatusMenuPresentationTruncatesLongStatusText() {
|
||||
|
||||
144
tests/clipboredtests/ClipboardCloudSyncServiceTests.swift
Normal file
144
tests/clipboredtests/ClipboardCloudSyncServiceTests.swift
Normal file
@@ -0,0 +1,144 @@
|
||||
import Foundation
|
||||
import XCTest
|
||||
@testable import ClipBored
|
||||
|
||||
final class ClipboardCloudSyncServiceTests: XCTestCase {
|
||||
private var tempRoot: URL!
|
||||
private var defaultsSuites: [String] = []
|
||||
|
||||
override func setUpWithError() throws {
|
||||
try super.setUpWithError()
|
||||
tempRoot = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("clipbored-cloud-sync-tests", isDirectory: true)
|
||||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: tempRoot, withIntermediateDirectories: true)
|
||||
}
|
||||
|
||||
override func tearDownWithError() throws {
|
||||
for suite in defaultsSuites {
|
||||
UserDefaults(suiteName: suite)?.removePersistentDomain(forName: suite)
|
||||
}
|
||||
if let tempRoot {
|
||||
try? FileManager.default.removeItem(at: tempRoot)
|
||||
}
|
||||
defaultsSuites = []
|
||||
tempRoot = nil
|
||||
try super.tearDownWithError()
|
||||
}
|
||||
|
||||
func testUnavailableContainerReportsStatusAndThrows() {
|
||||
let service = ClipboardCloudSyncService(containerProvider: { nil })
|
||||
|
||||
let status = service.status()
|
||||
|
||||
XCTAssertFalse(status.isAvailable)
|
||||
XCTAssertNil(status.archiveURL)
|
||||
XCTAssertTrue(status.message.contains("iCloud Sync is unavailable"))
|
||||
XCTAssertThrowsError(try service.syncArchiveURL()) { error in
|
||||
XCTAssertEqual(error as? ClipboardCloudSyncError, .unavailable)
|
||||
}
|
||||
}
|
||||
|
||||
func testPushWritesArchiveToPrivateDocumentsFolder() throws {
|
||||
let environment = try makeStoreEnvironment(named: "source")
|
||||
environment.store.upsert(makeItem("cloud note", created: Date(timeIntervalSince1970: 10)))
|
||||
environment.store.flushPersistenceForTesting()
|
||||
|
||||
let cloudRoot = tempRoot.appendingPathComponent("cloud", isDirectory: true)
|
||||
let service = ClipboardCloudSyncService(containerProvider: { cloudRoot })
|
||||
|
||||
let summary = try service.push(store: environment.store)
|
||||
let archiveURL = try service.syncArchiveURL()
|
||||
let status = service.status()
|
||||
|
||||
XCTAssertEqual(summary.itemCount, 1)
|
||||
XCTAssertEqual(
|
||||
archiveURL,
|
||||
cloudRoot
|
||||
.appendingPathComponent("Documents", isDirectory: true)
|
||||
.appendingPathComponent(AppConfiguration.appName, isDirectory: true)
|
||||
.appendingPathComponent(ClipboardCloudSyncService.archiveFileName)
|
||||
)
|
||||
XCTAssertTrue(FileManager.default.fileExists(atPath: archiveURL.path))
|
||||
XCTAssertEqual(try posixPermissions(archiveURL), 0o600)
|
||||
XCTAssertTrue(status.isAvailable)
|
||||
XCTAssertEqual(status.archiveURL, archiveURL)
|
||||
XCTAssertNotNil(status.lastModifiedAt)
|
||||
}
|
||||
|
||||
func testPullImportsExistingCloudArchiveIntoAnotherStore() throws {
|
||||
let source = try makeStoreEnvironment(named: "source")
|
||||
let sourceItem = makeItem("shared through icloud", created: Date(timeIntervalSince1970: 20))
|
||||
source.store.upsert(sourceItem)
|
||||
source.store.flushPersistenceForTesting()
|
||||
|
||||
let cloudRoot = tempRoot.appendingPathComponent("cloud", isDirectory: true)
|
||||
let service = ClipboardCloudSyncService(containerProvider: { cloudRoot })
|
||||
try service.push(store: source.store)
|
||||
|
||||
let destination = try makeStoreEnvironment(named: "destination")
|
||||
let summary = try service.pull(store: destination.store)
|
||||
destination.store.flushPersistenceForTesting()
|
||||
|
||||
XCTAssertEqual(summary.itemCount, 1)
|
||||
XCTAssertEqual(destination.store.items.count, 1)
|
||||
XCTAssertEqual(destination.store.items.first?.id, sourceItem.id)
|
||||
XCTAssertEqual(destination.store.items.first?.payload, "shared through icloud")
|
||||
}
|
||||
|
||||
func testPullWithoutRemoteArchiveThrowsNoRemoteArchive() throws {
|
||||
let destination = try makeStoreEnvironment(named: "destination")
|
||||
let cloudRoot = tempRoot.appendingPathComponent("empty-cloud", isDirectory: true)
|
||||
let service = ClipboardCloudSyncService(containerProvider: { cloudRoot })
|
||||
|
||||
XCTAssertThrowsError(try service.pull(store: destination.store)) { error in
|
||||
guard case ClipboardCloudSyncError.noRemoteArchive(let url) = error else {
|
||||
return XCTFail("Expected noRemoteArchive, got \(error)")
|
||||
}
|
||||
XCTAssertEqual(url.lastPathComponent, ClipboardCloudSyncService.archiveFileName)
|
||||
}
|
||||
}
|
||||
|
||||
private func makeStoreEnvironment(named name: String) throws -> (settings: SettingsModel, store: ClipboardStore) {
|
||||
let suiteName = "com.clipbored.cloudsync.\(name).\(UUID().uuidString)"
|
||||
defaultsSuites.append(suiteName)
|
||||
let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName))
|
||||
defaults.removePersistentDomain(forName: suiteName)
|
||||
|
||||
let settings = SettingsModel(defaults: defaults)
|
||||
settings.maxHistoryItems = 50
|
||||
settings.historyRetention = .forever
|
||||
let baseURL = tempRoot.appendingPathComponent(name, isDirectory: true)
|
||||
let encryptionService = ClipboardEncryptionService(keyProvider: { nil })
|
||||
let cacheService = ClipboardCacheService(baseURL: baseURL, encryptionService: encryptionService)
|
||||
let store = ClipboardStore(
|
||||
settings: settings,
|
||||
cacheService: cacheService,
|
||||
baseURL: baseURL,
|
||||
encryptionService: encryptionService
|
||||
)
|
||||
return (settings, store)
|
||||
}
|
||||
|
||||
private func makeItem(_ payload: String, created: Date) -> ClipboardItem {
|
||||
ClipboardItem(
|
||||
id: UUID(),
|
||||
kind: .text,
|
||||
displayText: payload,
|
||||
payload: payload,
|
||||
payloadHash: String(payload.hashValue),
|
||||
createdAt: created,
|
||||
lastUsedAt: created,
|
||||
useCount: 1,
|
||||
sourceApp: nil,
|
||||
imagePath: nil,
|
||||
thumbnailPath: nil,
|
||||
isPinned: false
|
||||
)
|
||||
}
|
||||
|
||||
private func posixPermissions(_ url: URL) throws -> Int {
|
||||
let attributes = try FileManager.default.attributesOfItem(atPath: url.path)
|
||||
return try XCTUnwrap(attributes[.posixPermissions] as? Int)
|
||||
}
|
||||
}
|
||||
@@ -66,6 +66,25 @@ final class ClipboardEncryptionServiceTests: XCTestCase {
|
||||
XCTAssertEqual(service.protectData(Data("available only in memory".utf8)), Data("available only in memory".utf8))
|
||||
}
|
||||
|
||||
func testSystemKeychainBypassIsEnabledForTestsAndExplicitEnvironment() {
|
||||
XCTAssertTrue(ClipboardEncryptionService.shouldBypassSystemKeychain(
|
||||
environment: ["XCTestConfigurationFilePath": "/tmp/ClipBoredTests.xctestconfiguration"],
|
||||
arguments: ["/tmp/ClipBoredTests"]
|
||||
))
|
||||
XCTAssertTrue(ClipboardEncryptionService.shouldBypassSystemKeychain(
|
||||
environment: ["CLIPBORED_DISABLE_KEYCHAIN": "1"],
|
||||
arguments: ["/tmp/ClipBored"]
|
||||
))
|
||||
XCTAssertTrue(ClipboardEncryptionService.shouldBypassSystemKeychain(
|
||||
environment: [:],
|
||||
arguments: ["/tmp/ClipBoredPackageTests.xctest/Contents/MacOS/ClipBoredPackageTests"]
|
||||
))
|
||||
XCTAssertFalse(ClipboardEncryptionService.shouldBypassSystemKeychain(
|
||||
environment: [:],
|
||||
arguments: ["/Applications/ClipBored.app/Contents/MacOS/ClipBored"]
|
||||
))
|
||||
}
|
||||
|
||||
private func makeService(byte: UInt8) -> ClipboardEncryptionService {
|
||||
let keyData = Data(repeating: byte, count: 32)
|
||||
return ClipboardEncryptionService(keyProvider: { SymmetricKey(data: keyData) })
|
||||
|
||||
@@ -20,10 +20,11 @@ final class ClipboardMonitorServiceTests: XCTestCase {
|
||||
func testClampedIntervalEnforcesResponsiveMinimum() {
|
||||
let settings = SettingsModel(defaults: makeTestDefaults())
|
||||
settings.pollProfile = .responsive
|
||||
let (store, cacheService) = makeStoreAndCache(settings: settings)
|
||||
|
||||
let monitor = ClipboardMonitorService(
|
||||
store: makeStore(settings: settings),
|
||||
cacheService: ClipboardCacheService(),
|
||||
store: store,
|
||||
cacheService: cacheService,
|
||||
settings: settings
|
||||
)
|
||||
|
||||
@@ -36,10 +37,11 @@ final class ClipboardMonitorServiceTests: XCTestCase {
|
||||
func testClampedIntervalDoesNotIncreaseBalancedProfileWindow() {
|
||||
let settings = SettingsModel(defaults: makeTestDefaults())
|
||||
settings.pollProfile = .balanced
|
||||
let (store, cacheService) = makeStoreAndCache(settings: settings)
|
||||
|
||||
let monitor = ClipboardMonitorService(
|
||||
store: makeStore(settings: settings),
|
||||
cacheService: ClipboardCacheService(),
|
||||
store: store,
|
||||
cacheService: cacheService,
|
||||
settings: settings
|
||||
)
|
||||
|
||||
@@ -108,6 +110,50 @@ final class ClipboardMonitorServiceTests: XCTestCase {
|
||||
XCTAssertEqual(store.items.filter { $0.payload == text }.count, 1)
|
||||
}
|
||||
|
||||
func testPollNowReportsStoredCapturedItemForStackCapture() {
|
||||
let settings = SettingsModel(defaults: makeTestDefaults())
|
||||
settings.pruneDuplicates = true
|
||||
let (store, cacheService) = makeStoreAndCache(settings: settings)
|
||||
let monitor = ClipboardMonitorService(
|
||||
store: store,
|
||||
cacheService: cacheService,
|
||||
settings: settings
|
||||
)
|
||||
let text = "Stack capture duplicate merge \(UUID().uuidString)"
|
||||
let existing = ClipboardItem(
|
||||
id: UUID(),
|
||||
kind: .text,
|
||||
displayText: text,
|
||||
payload: text,
|
||||
payloadHash: store.hashString(text),
|
||||
createdAt: Date(timeIntervalSince1970: 100),
|
||||
lastUsedAt: Date(timeIntervalSince1970: 100),
|
||||
useCount: 0,
|
||||
sourceApp: nil,
|
||||
imagePath: nil,
|
||||
thumbnailPath: nil
|
||||
)
|
||||
store.upsert(existing)
|
||||
|
||||
let reported = expectation(description: "stored captured item reported")
|
||||
var reportedItem: ClipboardItem?
|
||||
monitor.onCapturedItem = { item in
|
||||
guard item.payload == text else { return }
|
||||
reportedItem = item
|
||||
reported.fulfill()
|
||||
}
|
||||
|
||||
let pasteboard = NSPasteboard.general
|
||||
pasteboard.clearContents()
|
||||
XCTAssertTrue(pasteboard.setString(text, forType: .string))
|
||||
|
||||
monitor.pollNowAndWait()
|
||||
wait(for: [reported], timeout: 1.0)
|
||||
|
||||
XCTAssertEqual(reportedItem?.id, existing.id)
|
||||
XCTAssertEqual(store.items.filter { $0.payload == text }.count, 1)
|
||||
}
|
||||
|
||||
func testPollNowCapturesCodeSnippetAsCode() {
|
||||
let settings = SettingsModel(defaults: makeTestDefaults())
|
||||
settings.pruneDuplicates = false
|
||||
@@ -157,7 +203,7 @@ final class ClipboardMonitorServiceTests: XCTestCase {
|
||||
thumbnailPath: nil
|
||||
)
|
||||
|
||||
XCTAssertEqual(PasteActionService().copy(item), .copied)
|
||||
XCTAssertEqual(PasteActionService(cacheService: cacheService).copy(item), .copied)
|
||||
monitor.pollNowAndWait()
|
||||
RunLoop.main.run(until: Date().addingTimeInterval(0.05))
|
||||
|
||||
@@ -798,7 +844,9 @@ final class ClipboardMonitorServiceTests: XCTestCase {
|
||||
private func makeTestDefaults() -> UserDefaults {
|
||||
let suiteName = "com.clipbored.testmonitor.\(UUID().uuidString)"
|
||||
suiteNames.append(suiteName)
|
||||
return UserDefaults(suiteName: suiteName)!
|
||||
let defaults = UserDefaults(suiteName: suiteName)!
|
||||
defaults.set(HistoryRetention.forever.rawValue, forKey: SettingsModel.Keys.historyRetention)
|
||||
return defaults
|
||||
}
|
||||
|
||||
private func makeStore(settings: SettingsModel) -> ClipboardStore {
|
||||
|
||||
@@ -1,16 +1,80 @@
|
||||
import AppKit
|
||||
import XCTest
|
||||
@testable import ClipBored
|
||||
|
||||
final class ClipboardPanelControllerTests: XCTestCase {
|
||||
func testPanelFrameUsesFullWidthBottomShelf() {
|
||||
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)
|
||||
|
||||
XCTAssertEqual(frames.shown.minX, screenFrame.minX)
|
||||
XCTAssertEqual(frames.shown.maxX, screenFrame.maxX)
|
||||
XCTAssertEqual(frames.shown.width, 336)
|
||||
XCTAssertEqual(frames.shown.minY, screenFrame.minY)
|
||||
XCTAssertEqual(frames.shown.height, 408)
|
||||
XCTAssertLessThan(frames.hidden.maxY, screenFrame.minY)
|
||||
XCTAssertEqual(frames.shown.maxY, screenFrame.maxY)
|
||||
XCTAssertEqual(frames.hidden.minX, screenFrame.maxX + 1)
|
||||
XCTAssertEqual(frames.hidden.minY, frames.shown.minY)
|
||||
}
|
||||
|
||||
func testOpenScreenSelectionUsesStatusClickScreenWhenProvided() {
|
||||
XCTAssertEqual(
|
||||
ClipboardPanelController.selectedOpenScreen(
|
||||
explicit: "status-item-screen",
|
||||
preferred: nil,
|
||||
pointer: "pointer-screen",
|
||||
fallback: "fallback-screen"
|
||||
),
|
||||
"status-item-screen"
|
||||
)
|
||||
}
|
||||
|
||||
func testOpenScreenSelectionFallsBackToPointerForGlobalShortcut() {
|
||||
XCTAssertEqual(
|
||||
ClipboardPanelController.selectedOpenScreen(
|
||||
explicit: Optional<String>.none,
|
||||
preferred: nil,
|
||||
pointer: "pointer-screen",
|
||||
fallback: "fallback-screen"
|
||||
),
|
||||
"pointer-screen"
|
||||
)
|
||||
}
|
||||
|
||||
func testReflowScreenSelectionKeepsCurrentPanelScreenAheadOfPointer() {
|
||||
XCTAssertEqual(
|
||||
ClipboardPanelController.selectedReflowScreen(
|
||||
currentPanel: "current-panel-screen",
|
||||
lastKnown: "last-known-screen",
|
||||
preferred: nil,
|
||||
pointer: "pointer-screen",
|
||||
fallback: "fallback-screen"
|
||||
),
|
||||
"current-panel-screen"
|
||||
)
|
||||
XCTAssertEqual(
|
||||
ClipboardPanelController.selectedReflowScreen(
|
||||
currentPanel: Optional<String>.none,
|
||||
lastKnown: "last-known-screen",
|
||||
preferred: nil,
|
||||
pointer: "pointer-screen",
|
||||
fallback: "fallback-screen"
|
||||
),
|
||||
"last-known-screen"
|
||||
)
|
||||
}
|
||||
|
||||
func testPanelFrameUsesVisibleFrameAroundDock() {
|
||||
@@ -19,33 +83,48 @@ final class ClipboardPanelControllerTests: XCTestCase {
|
||||
|
||||
let frames = ClipboardPanelController.panelFrames(forScreenFrame: screenFrame, visibleFrame: visibleFrame)
|
||||
|
||||
XCTAssertEqual(frames.shown.minX, visibleFrame.minX)
|
||||
XCTAssertEqual(frames.shown.maxX, visibleFrame.maxX)
|
||||
XCTAssertEqual(frames.shown.minY, visibleFrame.minY)
|
||||
XCTAssertEqual(frames.shown.height, 408)
|
||||
XCTAssertEqual(frames.shown.minX, visibleFrame.maxX - 336)
|
||||
XCTAssertEqual(frames.shown.minY, screenFrame.minY)
|
||||
XCTAssertEqual(frames.shown.maxY, visibleFrame.maxY)
|
||||
XCTAssertEqual(frames.shown.width, 336)
|
||||
}
|
||||
|
||||
func testPanelFrameSitsAboveVisibleBottomDock() {
|
||||
func testPanelFrameUsesAvailableHeightWhenBottomDockIsVisible() {
|
||||
let screenFrame = CGRect(x: 0, y: 0, width: 1512, height: 982)
|
||||
let visibleFrame = CGRect(x: 0, y: 96, width: 1512, height: 861)
|
||||
|
||||
let frames = ClipboardPanelController.panelFrames(forScreenFrame: screenFrame, visibleFrame: visibleFrame)
|
||||
|
||||
XCTAssertEqual(frames.shown.minX, visibleFrame.minX)
|
||||
XCTAssertEqual(frames.shown.maxX, visibleFrame.maxX)
|
||||
XCTAssertEqual(frames.shown.minY, visibleFrame.minY)
|
||||
XCTAssertEqual(frames.shown.height, 408)
|
||||
XCTAssertLessThan(frames.hidden.maxY, visibleFrame.minY)
|
||||
XCTAssertEqual(frames.shown.minY, screenFrame.minY)
|
||||
XCTAssertEqual(frames.shown.maxY, visibleFrame.maxY)
|
||||
XCTAssertEqual(frames.shown.width, 336)
|
||||
XCTAssertEqual(frames.hidden.minX, visibleFrame.maxX + 1)
|
||||
}
|
||||
|
||||
func testPanelFrameClampsTallDisplaysToShelfMaximum() {
|
||||
func testPanelFrameTouchesScreenBottomWhenBottomDockIsAutoHidden() {
|
||||
let screenFrame = CGRect(x: 0, y: 0, width: 1512, height: 982)
|
||||
let visibleFrame = CGRect(x: 0, y: 4, width: 1512, height: 953)
|
||||
|
||||
let frames = ClipboardPanelController.panelFrames(forScreenFrame: screenFrame, visibleFrame: visibleFrame)
|
||||
|
||||
XCTAssertEqual(frames.shown.maxX, visibleFrame.maxX)
|
||||
XCTAssertEqual(frames.shown.minY, screenFrame.minY)
|
||||
XCTAssertEqual(frames.shown.maxY, visibleFrame.maxY)
|
||||
XCTAssertEqual(frames.shown.width, 336)
|
||||
XCTAssertEqual(frames.hidden.minX, visibleFrame.maxX + 1)
|
||||
}
|
||||
|
||||
func testPanelFrameKeepsSideShelfWidthOnTallDisplays() {
|
||||
let screenFrame = CGRect(x: 0, y: 0, width: 3008, height: 2000)
|
||||
|
||||
let frames = ClipboardPanelController.panelFrames(forScreenFrame: screenFrame, visibleFrame: screenFrame)
|
||||
|
||||
XCTAssertEqual(frames.shown.width, 3008)
|
||||
XCTAssertEqual(frames.shown.height, 430)
|
||||
XCTAssertEqual(frames.shown.width, 336)
|
||||
XCTAssertEqual(frames.shown.height, 2000)
|
||||
XCTAssertEqual(frames.shown.minY, screenFrame.minY)
|
||||
XCTAssertEqual(frames.shown.maxX, screenFrame.maxX)
|
||||
}
|
||||
|
||||
func testPanelFrameFitsTinyVisibleFrameWithoutOverflowing() {
|
||||
@@ -54,9 +133,64 @@ final class ClipboardPanelControllerTests: XCTestCase {
|
||||
|
||||
let frames = ClipboardPanelController.panelFrames(forScreenFrame: screenFrame, visibleFrame: visibleFrame)
|
||||
|
||||
XCTAssertEqual(frames.shown.minY, visibleFrame.minY)
|
||||
XCTAssertEqual(frames.shown.height, visibleFrame.height)
|
||||
XCTAssertLessThan(frames.hidden.maxY, visibleFrame.minY)
|
||||
XCTAssertEqual(frames.shown.minY, screenFrame.minY)
|
||||
XCTAssertEqual(frames.shown.maxY, visibleFrame.maxY)
|
||||
XCTAssertEqual(frames.shown.width, 320)
|
||||
XCTAssertLessThanOrEqual(frames.shown.width, visibleFrame.width)
|
||||
XCTAssertEqual(frames.hidden.minX, screenFrame.maxX + 1)
|
||||
}
|
||||
|
||||
func testPanelFrameIgnoresLegacyPreferredResizableHeight() {
|
||||
let screenFrame = CGRect(x: 0, y: 0, width: 1512, height: 982)
|
||||
let visibleFrame = CGRect(x: 0, y: 96, width: 1512, height: 861)
|
||||
|
||||
let frames = ClipboardPanelController.panelFrames(
|
||||
forScreenFrame: screenFrame,
|
||||
visibleFrame: visibleFrame,
|
||||
preferredHeight: 600
|
||||
)
|
||||
|
||||
XCTAssertEqual(frames.shown.minY, screenFrame.minY)
|
||||
XCTAssertEqual(frames.shown.maxY, visibleFrame.maxY)
|
||||
XCTAssertEqual(frames.shown.height, 957)
|
||||
XCTAssertEqual(frames.hidden.minX, visibleFrame.maxX + 1)
|
||||
}
|
||||
|
||||
func testPanelFrameLegacyPreferredHeightDoesNotAffectSideShelf() {
|
||||
let screenFrame = CGRect(x: 0, y: 0, width: 1512, height: 982)
|
||||
let visibleFrame = CGRect(x: 0, y: 0, width: 1512, height: 982)
|
||||
|
||||
let tooTall = ClipboardPanelController.panelFrames(
|
||||
forScreenFrame: screenFrame,
|
||||
visibleFrame: visibleFrame,
|
||||
preferredHeight: 900
|
||||
)
|
||||
let tooShort = ClipboardPanelController.panelFrames(
|
||||
forScreenFrame: screenFrame,
|
||||
visibleFrame: visibleFrame,
|
||||
preferredHeight: 120
|
||||
)
|
||||
|
||||
XCTAssertEqual(tooTall.shown.height, 982)
|
||||
XCTAssertEqual(tooShort.shown.height, 982)
|
||||
XCTAssertEqual(tooTall.shown.width, 336)
|
||||
XCTAssertEqual(tooShort.shown.width, 336)
|
||||
}
|
||||
|
||||
func testPanelFrameLegacyPreferredHeightDoesNotOverflowTinyVisibleFrame() {
|
||||
let screenFrame = CGRect(x: 0, y: 0, width: 640, height: 320)
|
||||
let visibleFrame = CGRect(x: 0, y: 48, width: 640, height: 220)
|
||||
|
||||
let frames = ClipboardPanelController.panelFrames(
|
||||
forScreenFrame: screenFrame,
|
||||
visibleFrame: visibleFrame,
|
||||
preferredHeight: 620
|
||||
)
|
||||
|
||||
XCTAssertEqual(frames.shown.height, 268)
|
||||
XCTAssertEqual(frames.shown.width, 320)
|
||||
XCTAssertLessThanOrEqual(frames.shown.height, visibleFrame.maxY - screenFrame.minY)
|
||||
XCTAssertEqual(frames.shown.minY, screenFrame.minY)
|
||||
}
|
||||
|
||||
func testPanelFramePlanningIsDeterministicAcrossRepeatedToggles() {
|
||||
@@ -68,48 +202,161 @@ final class ClipboardPanelControllerTests: XCTestCase {
|
||||
let frames = ClipboardPanelController.panelFrames(forScreenFrame: screenFrame, visibleFrame: visibleFrame)
|
||||
XCTAssertEqual(frames.shown, first.shown)
|
||||
XCTAssertEqual(frames.hidden, first.hidden)
|
||||
XCTAssertEqual(frames.hidden.maxY, frames.shown.minY - 1)
|
||||
XCTAssertEqual(frames.hidden.minX, frames.shown.maxX + 1)
|
||||
}
|
||||
}
|
||||
|
||||
func testPanelAnimationProfileStaysShortForSixtyFpsFeel() {
|
||||
let profile = ClipboardPanelController.animationProfile
|
||||
|
||||
XCTAssertEqual(profile.showDuration, 0.16)
|
||||
XCTAssertEqual(profile.hideDuration, 0.12)
|
||||
XCTAssertEqual(profile.reflowDuration, 0.10)
|
||||
XCTAssertLessThanOrEqual(profile.showDuration * 60, 10)
|
||||
XCTAssertLessThanOrEqual(profile.hideDuration * 60, 8)
|
||||
XCTAssertLessThanOrEqual(profile.reflowDuration * 60, 6)
|
||||
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 testReflowPlanMovesOpenPanelAboveNewBottomDockVisibleFrame() {
|
||||
func testPanelCollectionBehaviorStaysLocalToActiveSpaceAndSupportsFullscreen() {
|
||||
let behavior = ClipboardPanelController.panelCollectionBehavior
|
||||
|
||||
XCTAssertTrue(behavior.contains(.moveToActiveSpace))
|
||||
XCTAssertTrue(behavior.contains(.fullScreenAuxiliary))
|
||||
XCTAssertTrue(behavior.contains(.transient))
|
||||
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, 164, accuracy: 0.5)
|
||||
XCTAssertEqual(controller.debugSearchFieldPlaceholderText, "Search clips")
|
||||
XCTAssertTrue(controller.debugSearchFieldIsVisible)
|
||||
XCTAssertFalse(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.minX, visibleFrame.minX)
|
||||
XCTAssertEqual(plan.frame.maxX, visibleFrame.maxX)
|
||||
XCTAssertEqual(plan.frame.minY, visibleFrame.minY)
|
||||
XCTAssertEqual(plan.frame.height, 408)
|
||||
XCTAssertEqual(plan.frame.minY, screenFrame.minY)
|
||||
XCTAssertEqual(plan.frame.maxY, visibleFrame.maxY)
|
||||
XCTAssertEqual(plan.frame.width, 336)
|
||||
XCTAssertEqual(plan.bottomSafeInset, 20)
|
||||
}
|
||||
|
||||
func testReflowPlanTracksSideDockVisibleWidthWithoutBottomInsetInflation() {
|
||||
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 testReflowPlanIgnoresLegacyPreferredResizableHeight() {
|
||||
let screenFrame = CGRect(x: 0, y: 0, width: 1512, height: 982)
|
||||
let visibleFrame = CGRect(x: 0, y: 96, width: 1512, height: 861)
|
||||
|
||||
let plan = ClipboardPanelController.reflowPlan(
|
||||
forScreenFrame: screenFrame,
|
||||
visibleFrame: visibleFrame,
|
||||
preferredHeight: 600
|
||||
)
|
||||
|
||||
XCTAssertEqual(plan.frame.minY, screenFrame.minY)
|
||||
XCTAssertEqual(plan.frame.height, 957)
|
||||
XCTAssertEqual(plan.frame.width, 336)
|
||||
XCTAssertEqual(plan.bottomSafeInset, 20)
|
||||
}
|
||||
|
||||
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.minX, visibleFrame.minX)
|
||||
XCTAssertEqual(plan.frame.maxX, visibleFrame.maxX)
|
||||
XCTAssertEqual(plan.frame.minY, visibleFrame.minY)
|
||||
XCTAssertEqual(plan.frame.height, 408)
|
||||
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)
|
||||
|
||||
let frames = ClipboardPanelController.panelFrames(
|
||||
forScreenFrame: screenFrame,
|
||||
visibleFrame: visibleFrame,
|
||||
side: .right
|
||||
)
|
||||
|
||||
XCTAssertEqual(frames.shown.minY, screenFrame.minY)
|
||||
XCTAssertEqual(frames.shown.maxY, visibleFrame.maxY)
|
||||
XCTAssertEqual(frames.shown.maxX, visibleFrame.maxX)
|
||||
XCTAssertEqual(frames.shown.width, 336)
|
||||
XCTAssertEqual(frames.hidden.minY, frames.shown.minY)
|
||||
XCTAssertEqual(frames.hidden.minX, screenFrame.maxX + 1)
|
||||
}
|
||||
|
||||
func testPanelFrameUsesConfiguredLeftSideShelf() {
|
||||
let screenFrame = CGRect(x: 0, y: 0, width: 1512, height: 982)
|
||||
let visibleFrame = CGRect(x: 80, y: 0, width: 1432, height: 957)
|
||||
|
||||
let frames = ClipboardPanelController.panelFrames(
|
||||
forScreenFrame: screenFrame,
|
||||
visibleFrame: visibleFrame,
|
||||
side: .left
|
||||
)
|
||||
|
||||
XCTAssertEqual(frames.shown.minX, visibleFrame.minX)
|
||||
XCTAssertEqual(frames.shown.minY, screenFrame.minY)
|
||||
XCTAssertEqual(frames.shown.maxY, visibleFrame.maxY)
|
||||
XCTAssertEqual(frames.shown.width, 336)
|
||||
XCTAssertEqual(frames.hidden.maxX, visibleFrame.minX - 1)
|
||||
XCTAssertEqual(frames.hidden.minY, frames.shown.minY)
|
||||
}
|
||||
|
||||
func testPanelFrameUsesFullScreenHeightWhenVisibleFrameMatchesScreen() {
|
||||
let screenFrame = CGRect(x: -1512, y: -120, width: 1512, height: 982)
|
||||
|
||||
let frames = ClipboardPanelController.panelFrames(
|
||||
forScreenFrame: screenFrame,
|
||||
visibleFrame: screenFrame,
|
||||
side: .right
|
||||
)
|
||||
|
||||
XCTAssertEqual(frames.shown.minY, screenFrame.minY)
|
||||
XCTAssertEqual(frames.shown.maxY, screenFrame.maxY)
|
||||
XCTAssertEqual(frames.shown.maxX, screenFrame.maxX)
|
||||
XCTAssertEqual(frames.shown.width, 336)
|
||||
}
|
||||
|
||||
func testContentBottomInsetReservesBottomDockSpace() {
|
||||
let screenFrame = CGRect(x: 0, y: 0, width: 1512, height: 982)
|
||||
let visibleFrame = CGRect(x: 0, y: 96, width: 1512, height: 861)
|
||||
@@ -128,6 +375,24 @@ final class ClipboardPanelControllerTests: XCTestCase {
|
||||
XCTAssertEqual(inset, 18)
|
||||
}
|
||||
|
||||
func testPanelSharingTypeHidesWindowFromScreenCaptureWhenEnabled() {
|
||||
XCTAssertEqual(ClipboardPanelController.panelSharingType(hideFromScreenCapture: false), .readOnly)
|
||||
XCTAssertEqual(ClipboardPanelController.panelSharingType(hideFromScreenCapture: true), .none)
|
||||
}
|
||||
|
||||
func testLinkPreviewFrameSitsAboveBottomShelfWhenSpaceAllows() {
|
||||
let visibleFrame = NSRect(x: 0, y: 0, width: 1512, height: 982)
|
||||
let shelfFrame = NSRect(x: 0, y: 0, width: 1512, height: 408)
|
||||
|
||||
let frame = LinkPreviewWindowController.previewFrame(parentFrame: shelfFrame, visibleFrame: visibleFrame)
|
||||
|
||||
XCTAssertGreaterThanOrEqual(frame.minY, shelfFrame.maxY + 14)
|
||||
XCTAssertLessThanOrEqual(frame.maxY, visibleFrame.maxY - 24)
|
||||
XCTAssertEqual(frame.width, 1088)
|
||||
XCTAssertEqual(frame.height, 536)
|
||||
XCTAssertEqual(frame.midX, shelfFrame.midX, accuracy: 0.5)
|
||||
}
|
||||
|
||||
func testCommandNumberShortcutsMapToQuickPasteSlots() {
|
||||
XCTAssertEqual(ClipboardPanelController.quickPasteIndex(forKeyCode: 18, modifiers: .command), 0)
|
||||
XCTAssertEqual(ClipboardPanelController.quickPasteIndex(forKeyCode: 19, modifiers: .command), 1)
|
||||
@@ -181,20 +446,48 @@ final class ClipboardPanelControllerTests: XCTestCase {
|
||||
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 testNavigationShortcutsRequireNoModifiers() {
|
||||
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: []))
|
||||
}
|
||||
|
||||
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]))
|
||||
}
|
||||
|
||||
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), .reveal)
|
||||
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() {
|
||||
@@ -203,17 +496,68 @@ final class ClipboardPanelControllerTests: XCTestCase {
|
||||
XCTAssertNil(ClipboardPanelController.commandShortcutAction(forKeyCode: 9, modifiers: .command))
|
||||
}
|
||||
|
||||
func testModifiedShortcutsMapToPlainTextActions() {
|
||||
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]), .copyPlainText)
|
||||
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)
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -237,10 +237,12 @@ final class ClipboardStoreTests: XCTestCase {
|
||||
XCTAssertEqual(store.items.first?.id, itemID)
|
||||
XCTAssertEqual(store.items.first?.payload, "legacy payload")
|
||||
XCTAssertEqual(store.items.first?.useCount, 3)
|
||||
XCTAssertEqual(store.items.first?.sourceDeviceName, ClipboardItem.localDeviceName)
|
||||
|
||||
let restored = makeStore(settings: settings)
|
||||
restored.flushPersistenceForTesting()
|
||||
XCTAssertEqual(restored.items.first?.payload, "legacy payload")
|
||||
XCTAssertEqual(restored.items.first?.sourceDeviceName, ClipboardItem.localDeviceName)
|
||||
}
|
||||
|
||||
func testPinnedItemsSurviveNormalHistoryPrune() {
|
||||
@@ -266,6 +268,114 @@ final class ClipboardStoreTests: XCTestCase {
|
||||
XCTAssertTrue(restored.items.contains(where: { $0.payload == "pinned-old" && $0.isPinned }))
|
||||
}
|
||||
|
||||
func testCollectionItemsSurviveNormalHistoryPrune() {
|
||||
let settings = makeSettings(maxHistory: 50)
|
||||
let store = makeStore(settings: settings)
|
||||
let start = Date()
|
||||
var saved = makeItem("collection-old", displayText: "Saved", created: start.addingTimeInterval(-500))
|
||||
saved.collectionName = "Client Work"
|
||||
|
||||
store.upsert(saved)
|
||||
for index in 0..<60 {
|
||||
store.upsert(makeItem("new-\(index)", displayText: "New \(index)", created: start.addingTimeInterval(Double(index))))
|
||||
}
|
||||
store.flushPersistenceForTesting()
|
||||
|
||||
XCTAssertTrue(store.items.contains(where: { $0.payload == "collection-old" && $0.collectionName == "Client Work" }))
|
||||
XCTAssertEqual(store.items.filter { !$0.isPinned && $0.collectionName == nil }.count, 50)
|
||||
XCTAssertEqual(store.items.count, 51)
|
||||
|
||||
let restored = makeStore(settings: settings)
|
||||
restored.flushPersistenceForTesting()
|
||||
XCTAssertTrue(restored.items.contains(where: { $0.payload == "collection-old" && $0.collectionName == "Client Work" }))
|
||||
}
|
||||
|
||||
func testRemovingCollectionMakesOverflowItemEligibleForHistoryPrune() {
|
||||
let settings = makeSettings(maxHistory: 50)
|
||||
let store = makeStore(settings: settings)
|
||||
let start = Date()
|
||||
var saved = makeItem("collection-old", displayText: "Saved", created: start.addingTimeInterval(-500))
|
||||
saved.collectionName = "Client Work"
|
||||
|
||||
store.upsert(saved)
|
||||
for index in 0..<60 {
|
||||
store.upsert(makeItem("new-\(index)", displayText: "New \(index)", created: start.addingTimeInterval(Double(index))))
|
||||
}
|
||||
let savedID = try! XCTUnwrap(store.items.first(where: { $0.payload == "collection-old" })?.id)
|
||||
|
||||
store.setCollection(savedID, name: nil)
|
||||
store.flushPersistenceForTesting()
|
||||
|
||||
XCTAssertFalse(store.items.contains(where: { $0.payload == "collection-old" }))
|
||||
XCTAssertEqual(store.items.count, 50)
|
||||
|
||||
let restored = makeStore(settings: settings)
|
||||
restored.flushPersistenceForTesting()
|
||||
XCTAssertFalse(restored.items.contains(where: { $0.payload == "collection-old" }))
|
||||
}
|
||||
|
||||
func testHistoryRetentionPrunesExpiredUnpinnedItems() {
|
||||
let settings = makeSettings(maxHistory: 50)
|
||||
settings.historyRetention = .oneDay
|
||||
let store = makeStore(settings: settings)
|
||||
let now = Date()
|
||||
|
||||
store.upsert(makeItem("old", displayText: "Old", created: now.addingTimeInterval(-2 * 24 * 60 * 60)))
|
||||
store.upsert(makeItem("recent", displayText: "Recent", created: now.addingTimeInterval(-60)))
|
||||
store.flushPersistenceForTesting()
|
||||
|
||||
XCTAssertEqual(store.items.map(\.payload), ["recent"])
|
||||
XCTAssertFalse(store.items.contains(where: { $0.payload == "old" }))
|
||||
|
||||
let restored = makeStore(settings: settings)
|
||||
restored.flushPersistenceForTesting()
|
||||
XCTAssertEqual(restored.items.map(\.payload), ["recent"])
|
||||
}
|
||||
|
||||
func testHistoryRetentionKeepsPinnedAndCollectionItems() {
|
||||
let settings = makeSettings(maxHistory: 50)
|
||||
settings.historyRetention = .oneDay
|
||||
let store = makeStore(settings: settings)
|
||||
let oldDate = Date().addingTimeInterval(-2 * 24 * 60 * 60)
|
||||
var pinned = makeItem("pinned-old", displayText: "Pinned", created: oldDate)
|
||||
pinned.isPinned = true
|
||||
var saved = makeItem("collection-old", displayText: "Saved", created: oldDate)
|
||||
saved.collectionName = "Client Work"
|
||||
|
||||
store.upsert(makeItem("plain-old", displayText: "Plain", created: oldDate))
|
||||
store.upsert(pinned)
|
||||
store.upsert(saved)
|
||||
store.flushPersistenceForTesting()
|
||||
|
||||
XCTAssertFalse(store.items.contains(where: { $0.payload == "plain-old" }))
|
||||
XCTAssertTrue(store.items.contains(where: { $0.payload == "pinned-old" && $0.isPinned }))
|
||||
XCTAssertTrue(store.items.contains(where: { $0.payload == "collection-old" && $0.collectionName == "Client Work" }))
|
||||
|
||||
let restored = makeStore(settings: settings)
|
||||
restored.flushPersistenceForTesting()
|
||||
XCTAssertFalse(restored.items.contains(where: { $0.payload == "plain-old" }))
|
||||
XCTAssertTrue(restored.items.contains(where: { $0.payload == "pinned-old" && $0.isPinned }))
|
||||
XCTAssertTrue(restored.items.contains(where: { $0.payload == "collection-old" && $0.collectionName == "Client Work" }))
|
||||
}
|
||||
|
||||
func testDuplicateCopyRefreshesRetentionAge() {
|
||||
let settings = makeSettings(maxHistory: 50)
|
||||
let store = makeStore(settings: settings)
|
||||
let oldDate = Date().addingTimeInterval(-2 * 24 * 60 * 60)
|
||||
|
||||
store.upsert(makeItem("same", displayText: "Original", created: oldDate))
|
||||
let originalCreatedAt = try! XCTUnwrap(store.items.first?.createdAt)
|
||||
store.upsert(makeItem("same", displayText: "Copied again", created: oldDate))
|
||||
|
||||
XCTAssertGreaterThan(try! XCTUnwrap(store.items.first?.createdAt), originalCreatedAt)
|
||||
|
||||
settings.historyRetention = .oneDay
|
||||
store.normalizeHistoryLength()
|
||||
|
||||
XCTAssertEqual(store.items.map(\.payload), ["same"])
|
||||
XCTAssertEqual(store.items.first?.displayText, "Copied again")
|
||||
}
|
||||
|
||||
func testStorageFilesUsePrivatePermissions() throws {
|
||||
let settings = makeSettings(maxHistory: 50)
|
||||
let store = makeStore(settings: settings)
|
||||
@@ -360,7 +470,8 @@ final class ClipboardStoreTests: XCTestCase {
|
||||
isPinned: false,
|
||||
sourceAppBundleId: "com.example.secret.\(UUID().uuidString)",
|
||||
ocrText: "OCR secret \(UUID().uuidString)",
|
||||
collectionName: "Collection secret \(UUID().uuidString)"
|
||||
collectionName: "Collection secret \(UUID().uuidString)",
|
||||
sourceDeviceName: "Device secret \(UUID().uuidString)"
|
||||
)
|
||||
|
||||
store.upsert(item)
|
||||
@@ -375,6 +486,7 @@ final class ClipboardStoreTests: XCTestCase {
|
||||
XCTAssertFalse(rawDatabaseText.contains(item.sourceAppBundleId!))
|
||||
XCTAssertFalse(rawDatabaseText.contains(item.ocrText!))
|
||||
XCTAssertFalse(rawDatabaseText.contains(item.collectionName!))
|
||||
XCTAssertFalse(rawDatabaseText.contains(item.sourceDeviceName!))
|
||||
|
||||
let restored = makeStore(settings: settings, encryptionService: encryptionService)
|
||||
restored.flushPersistenceForTesting()
|
||||
@@ -386,6 +498,7 @@ final class ClipboardStoreTests: XCTestCase {
|
||||
XCTAssertEqual(restored.items.first?.sourceAppBundleId, item.sourceAppBundleId)
|
||||
XCTAssertEqual(restored.items.first?.ocrText, item.ocrText)
|
||||
XCTAssertEqual(restored.items.first?.collectionName, item.collectionName)
|
||||
XCTAssertEqual(restored.items.first?.sourceDeviceName, item.sourceDeviceName)
|
||||
}
|
||||
|
||||
func testPlaintextDatabaseMigratesToEncryptedFieldsOnLoad() throws {
|
||||
@@ -406,7 +519,8 @@ final class ClipboardStoreTests: XCTestCase {
|
||||
isPinned: false,
|
||||
sourceAppBundleId: "com.example.legacy.\(UUID().uuidString)",
|
||||
ocrText: "Legacy OCR \(UUID().uuidString)",
|
||||
collectionName: "Legacy collection \(UUID().uuidString)"
|
||||
collectionName: "Legacy collection \(UUID().uuidString)",
|
||||
sourceDeviceName: "Legacy device \(UUID().uuidString)"
|
||||
)
|
||||
|
||||
plaintextStore.upsert(item)
|
||||
@@ -424,6 +538,7 @@ final class ClipboardStoreTests: XCTestCase {
|
||||
XCTAssertEqual(restored.items.first?.sourceAppBundleId, item.sourceAppBundleId)
|
||||
XCTAssertEqual(restored.items.first?.ocrText, item.ocrText)
|
||||
XCTAssertEqual(restored.items.first?.collectionName, item.collectionName)
|
||||
XCTAssertEqual(restored.items.first?.sourceDeviceName, item.sourceDeviceName)
|
||||
|
||||
let migratedDatabaseText = try databaseText()
|
||||
XCTAssertTrue(migratedDatabaseText.contains(ClipboardEncryptionService.marker))
|
||||
@@ -434,6 +549,7 @@ final class ClipboardStoreTests: XCTestCase {
|
||||
XCTAssertFalse(migratedDatabaseText.contains(item.sourceAppBundleId!))
|
||||
XCTAssertFalse(migratedDatabaseText.contains(item.ocrText!))
|
||||
XCTAssertFalse(migratedDatabaseText.contains(item.collectionName!))
|
||||
XCTAssertFalse(migratedDatabaseText.contains(item.sourceDeviceName!))
|
||||
}
|
||||
|
||||
func testDuplicatePDFReplacementRemovesOldAttachment() throws {
|
||||
@@ -454,6 +570,47 @@ final class ClipboardStoreTests: XCTestCase {
|
||||
XCTAssertTrue(FileManager.default.fileExists(atPath: newPath))
|
||||
}
|
||||
|
||||
func testRemoveCanDeferManagedCachePurgeForUndoRestore() throws {
|
||||
let settings = makeSettings(maxHistory: 50)
|
||||
let store = makeStore(settings: settings)
|
||||
let cachedImage = try XCTUnwrap(cacheService.cacheImage(makeImage(color: .systemBlue), id: UUID()))
|
||||
let item = makeImageItem(
|
||||
fullPath: cachedImage.full,
|
||||
thumbPath: cachedImage.thumb,
|
||||
hash: "undo-image",
|
||||
ocrText: "undo searchable text",
|
||||
created: Date(timeIntervalSince1970: 10)
|
||||
)
|
||||
store.upsert(item)
|
||||
store.flushPersistenceForTesting()
|
||||
|
||||
let removal = try XCTUnwrap(store.remove(item.id, purgeManagedCache: false))
|
||||
store.flushPersistenceForTesting()
|
||||
cacheService.flushForTesting()
|
||||
|
||||
XCTAssertTrue(store.items.isEmpty)
|
||||
XCTAssertTrue(FileManager.default.fileExists(atPath: cachedImage.full))
|
||||
XCTAssertTrue(FileManager.default.fileExists(atPath: cachedImage.thumb))
|
||||
|
||||
store.restore([removal])
|
||||
store.flushPersistenceForTesting()
|
||||
|
||||
XCTAssertEqual(store.items.map(\.id), [item.id])
|
||||
XCTAssertTrue(FileManager.default.fileExists(atPath: cachedImage.full))
|
||||
XCTAssertTrue(FileManager.default.fileExists(atPath: cachedImage.thumb))
|
||||
|
||||
let restored = makeStore(settings: settings)
|
||||
restored.flushPersistenceForTesting()
|
||||
XCTAssertEqual(restored.items.map(\.id), [item.id])
|
||||
|
||||
_ = store.remove(item.id)
|
||||
store.flushPersistenceForTesting()
|
||||
cacheService.flushForTesting()
|
||||
|
||||
XCTAssertFalse(FileManager.default.fileExists(atPath: cachedImage.full))
|
||||
XCTAssertFalse(FileManager.default.fileExists(atPath: cachedImage.thumb))
|
||||
}
|
||||
|
||||
func testDuplicateReplacementClearsStaleImageSearchMetadata() throws {
|
||||
let settings = makeSettings(maxHistory: 50)
|
||||
settings.keepFirstImage = false
|
||||
@@ -501,9 +658,204 @@ final class ClipboardStoreTests: XCTestCase {
|
||||
XCTAssertFalse(FileManager.default.fileExists(atPath: staleImage.thumb))
|
||||
}
|
||||
|
||||
func testArchiveExportImportMovesHistoryAndManagedAttachmentsToNewStorage() throws {
|
||||
let settings = makeSettings(maxHistory: 50)
|
||||
let store = makeStore(settings: settings)
|
||||
let cachedImage = try XCTUnwrap(cacheService.cacheImage(makeImage(color: .systemBlue), id: UUID()))
|
||||
let sourceFullImageData = try XCTUnwrap(cacheService.data(for: cachedImage.full))
|
||||
let sourceThumbData = try XCTUnwrap(cacheService.data(for: cachedImage.thumb))
|
||||
let sourcePDFData = Data("%PDF archive payload".utf8)
|
||||
let sourcePDFPath = try XCTUnwrap(cacheService.cachePDF(sourcePDFData, id: UUID()))
|
||||
let created = Date(timeIntervalSince1970: 300)
|
||||
|
||||
var note = makeItem("archive note", displayText: "Archive Note", created: created)
|
||||
note.collectionName = "Read Later"
|
||||
note.customTitle = "Migration Note"
|
||||
note.sourceDeviceName = "Studio Mac"
|
||||
var image = makeImageItem(
|
||||
fullPath: cachedImage.full,
|
||||
thumbPath: cachedImage.thumb,
|
||||
hash: "archive-image",
|
||||
ocrText: "blue archive image",
|
||||
created: created.addingTimeInterval(1)
|
||||
)
|
||||
image.isPinned = true
|
||||
image.collectionName = "Client Work"
|
||||
image.customTitle = "Launch Screenshot"
|
||||
image.sourceDeviceName = "MacBook Pro"
|
||||
var pdf = makePDFItem(
|
||||
path: sourcePDFPath,
|
||||
hash: "archive-pdf",
|
||||
created: created.addingTimeInterval(2)
|
||||
)
|
||||
pdf.collectionName = "Research"
|
||||
pdf.sourceDeviceName = "Design Mac"
|
||||
|
||||
store.upsert(note)
|
||||
store.upsert(image)
|
||||
store.upsert(pdf)
|
||||
store.flushPersistenceForTesting()
|
||||
|
||||
let archiveURL = baseURL.appendingPathComponent("backup.clipboredarchive")
|
||||
let exportSummary = try store.exportArchive(to: archiveURL)
|
||||
|
||||
XCTAssertEqual(exportSummary.itemCount, 3)
|
||||
XCTAssertEqual(exportSummary.sidecarCount, 3)
|
||||
XCTAssertEqual(try posixPermissions(archiveURL), 0o600)
|
||||
|
||||
let destinationBaseURL = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("clipboredtests", isDirectory: true)
|
||||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: destinationBaseURL, withIntermediateDirectories: true)
|
||||
defer { try? FileManager.default.removeItem(at: destinationBaseURL) }
|
||||
|
||||
let destinationDefaultsSuite = "com.clipbored.archiveimport.\(UUID().uuidString)"
|
||||
let destinationDefaults = try XCTUnwrap(UserDefaults(suiteName: destinationDefaultsSuite))
|
||||
destinationDefaults.removePersistentDomain(forName: destinationDefaultsSuite)
|
||||
defer { destinationDefaults.removePersistentDomain(forName: destinationDefaultsSuite) }
|
||||
|
||||
let destinationSettings = SettingsModel(defaults: destinationDefaults)
|
||||
destinationSettings.maxHistoryItems = 50
|
||||
destinationSettings.historyRetention = .forever
|
||||
let destinationCache = ClipboardCacheService(
|
||||
baseURL: destinationBaseURL,
|
||||
encryptionService: noOpEncryptionService()
|
||||
)
|
||||
let destinationStore = ClipboardStore(
|
||||
settings: destinationSettings,
|
||||
cacheService: destinationCache,
|
||||
baseURL: destinationBaseURL,
|
||||
encryptionService: noOpEncryptionService()
|
||||
)
|
||||
destinationStore.upsert(makeItem("destination only", displayText: "Destination", created: created.addingTimeInterval(3)))
|
||||
destinationStore.flushPersistenceForTesting()
|
||||
|
||||
let importSummary = try destinationStore.importArchive(from: archiveURL)
|
||||
destinationStore.flushPersistenceForTesting()
|
||||
|
||||
XCTAssertEqual(importSummary.itemCount, 3)
|
||||
XCTAssertEqual(importSummary.sidecarCount, 3)
|
||||
XCTAssertEqual(destinationStore.items.count, 4)
|
||||
XCTAssertTrue(destinationStore.items.contains { $0.payload == "destination only" })
|
||||
|
||||
let importedNote = try XCTUnwrap(destinationStore.items.first { $0.id == note.id })
|
||||
XCTAssertEqual(importedNote.collectionName, "Read Later")
|
||||
XCTAssertEqual(importedNote.customTitle, "Migration Note")
|
||||
XCTAssertEqual(importedNote.sourceDeviceName, "Studio Mac")
|
||||
|
||||
let importedImage = try XCTUnwrap(destinationStore.items.first { $0.id == image.id })
|
||||
XCTAssertTrue(importedImage.isPinned)
|
||||
XCTAssertEqual(importedImage.collectionName, "Client Work")
|
||||
XCTAssertEqual(importedImage.customTitle, "Launch Screenshot")
|
||||
XCTAssertEqual(importedImage.sourceDeviceName, "MacBook Pro")
|
||||
let importedImagePath = try XCTUnwrap(importedImage.imagePath)
|
||||
let importedThumbPath = try XCTUnwrap(importedImage.thumbnailPath)
|
||||
XCTAssertTrue(importedImagePath.hasPrefix(destinationBaseURL.path))
|
||||
XCTAssertTrue(importedThumbPath.hasPrefix(destinationBaseURL.path))
|
||||
XCTAssertNotEqual(importedImagePath, cachedImage.full)
|
||||
XCTAssertEqual(destinationCache.data(for: importedImagePath), sourceFullImageData)
|
||||
XCTAssertEqual(destinationCache.data(for: importedThumbPath), sourceThumbData)
|
||||
|
||||
let importedPDF = try XCTUnwrap(destinationStore.items.first { $0.id == pdf.id })
|
||||
XCTAssertEqual(importedPDF.collectionName, "Research")
|
||||
XCTAssertTrue(importedPDF.payload.hasPrefix(destinationBaseURL.path))
|
||||
XCTAssertNotEqual(importedPDF.payload, sourcePDFPath)
|
||||
XCTAssertEqual(destinationCache.data(for: importedPDF.payload), sourcePDFData)
|
||||
|
||||
let reloaded = ClipboardStore(
|
||||
settings: destinationSettings,
|
||||
cacheService: destinationCache,
|
||||
baseURL: destinationBaseURL,
|
||||
encryptionService: noOpEncryptionService()
|
||||
)
|
||||
reloaded.flushPersistenceForTesting()
|
||||
XCTAssertEqual(reloaded.items.count, 4)
|
||||
XCTAssertTrue(reloaded.items.contains { $0.id == image.id && $0.collectionName == "Client Work" })
|
||||
XCTAssertTrue(reloaded.items.contains { $0.id == pdf.id && $0.collectionName == "Research" })
|
||||
}
|
||||
|
||||
func testPinboardExportImportsOnlyThatCollectionAndPreservesColor() throws {
|
||||
let settings = makeSettings(maxHistory: 50)
|
||||
settings.ensureCollection(named: "Client Work", colorHex: "#3366FF")
|
||||
let store = makeStore(settings: settings)
|
||||
let created = Date(timeIntervalSince1970: 500)
|
||||
var client = makeItem("client note", displayText: "Client Note", created: created)
|
||||
client.collectionName = "Client Work"
|
||||
let unrelated = makeItem("other note", displayText: "Other Note", created: created.addingTimeInterval(1))
|
||||
|
||||
store.upsert(client)
|
||||
store.upsert(unrelated)
|
||||
store.flushPersistenceForTesting()
|
||||
|
||||
let archiveURL = baseURL.appendingPathComponent("client-work.clipboredarchive")
|
||||
let exportSummary = try store.exportCollection(named: "Client Work", to: archiveURL)
|
||||
|
||||
XCTAssertEqual(exportSummary.itemCount, 1)
|
||||
XCTAssertEqual(exportSummary.sidecarCount, 0)
|
||||
|
||||
let destinationDefaultsSuite = "com.clipbored.pinboardimport.\(UUID().uuidString)"
|
||||
let destinationDefaults = try XCTUnwrap(UserDefaults(suiteName: destinationDefaultsSuite))
|
||||
destinationDefaults.removePersistentDomain(forName: destinationDefaultsSuite)
|
||||
defer { destinationDefaults.removePersistentDomain(forName: destinationDefaultsSuite) }
|
||||
let destinationSettings = SettingsModel(defaults: destinationDefaults)
|
||||
destinationSettings.maxHistoryItems = 50
|
||||
destinationSettings.historyRetention = .forever
|
||||
let destinationBaseURL = baseURL.appendingPathComponent("pinboard-destination", isDirectory: true)
|
||||
let destinationCache = ClipboardCacheService(
|
||||
baseURL: destinationBaseURL,
|
||||
encryptionService: noOpEncryptionService()
|
||||
)
|
||||
let destinationStore = ClipboardStore(
|
||||
settings: destinationSettings,
|
||||
cacheService: destinationCache,
|
||||
baseURL: destinationBaseURL,
|
||||
encryptionService: noOpEncryptionService()
|
||||
)
|
||||
|
||||
let importSummary = try destinationStore.importArchive(from: archiveURL)
|
||||
|
||||
XCTAssertEqual(importSummary.itemCount, 1)
|
||||
XCTAssertEqual(destinationStore.items.map(\.payload), ["client note"])
|
||||
XCTAssertEqual(destinationStore.items.first?.collectionName, "Client Work")
|
||||
XCTAssertEqual(destinationSettings.customCollectionNames, ["Client Work"])
|
||||
XCTAssertEqual(destinationSettings.collectionColorHex(forCollectionNamed: "Client Work"), "#3366FF")
|
||||
}
|
||||
|
||||
func testEmptyPinboardExportImportsCollectionMetadata() throws {
|
||||
let settings = makeSettings(maxHistory: 50)
|
||||
settings.ensureCollection(named: "Read Later", colorHex: "#0A9EB8")
|
||||
let store = makeStore(settings: settings)
|
||||
let archiveURL = baseURL.appendingPathComponent("read-later-empty.clipboredarchive")
|
||||
|
||||
let exportSummary = try store.exportCollection(named: "Read Later", to: archiveURL)
|
||||
|
||||
XCTAssertEqual(exportSummary.itemCount, 0)
|
||||
|
||||
let destinationDefaultsSuite = "com.clipbored.emptypinboard.\(UUID().uuidString)"
|
||||
let destinationDefaults = try XCTUnwrap(UserDefaults(suiteName: destinationDefaultsSuite))
|
||||
destinationDefaults.removePersistentDomain(forName: destinationDefaultsSuite)
|
||||
defer { destinationDefaults.removePersistentDomain(forName: destinationDefaultsSuite) }
|
||||
let destinationSettings = SettingsModel(defaults: destinationDefaults)
|
||||
let destinationBaseURL = baseURL.appendingPathComponent("empty-pinboard-destination", isDirectory: true)
|
||||
let destinationStore = ClipboardStore(
|
||||
settings: destinationSettings,
|
||||
cacheService: ClipboardCacheService(baseURL: destinationBaseURL, encryptionService: noOpEncryptionService()),
|
||||
baseURL: destinationBaseURL,
|
||||
encryptionService: noOpEncryptionService()
|
||||
)
|
||||
|
||||
let importSummary = try destinationStore.importArchive(from: archiveURL)
|
||||
|
||||
XCTAssertEqual(importSummary.itemCount, 0)
|
||||
XCTAssertTrue(destinationStore.items.isEmpty)
|
||||
XCTAssertEqual(destinationSettings.customCollectionNames, ["Read Later"])
|
||||
XCTAssertEqual(destinationSettings.collectionColorHex(forCollectionNamed: "Read Later"), "#0A9EB8")
|
||||
}
|
||||
|
||||
private func makeSettings(maxHistory: Int) -> SettingsModel {
|
||||
let settings = SettingsModel(defaults: defaults)
|
||||
settings.maxHistoryItems = maxHistory
|
||||
settings.historyRetention = .forever
|
||||
settings.pruneDuplicates = true
|
||||
settings.keepFirstImage = true
|
||||
return settings
|
||||
|
||||
@@ -12,8 +12,8 @@ final class DiagnosticsServiceTests: XCTestCase {
|
||||
diagnostics.incrementDatabaseMutation()
|
||||
diagnostics.incrementCachePurge()
|
||||
|
||||
// The counters use a serial async queue for low overhead; sync through reset's queue by reading a snapshot.
|
||||
let snapshot = waitForSnapshot { diagnostics.currentSnapshot() }
|
||||
// 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)
|
||||
@@ -23,13 +23,4 @@ final class DiagnosticsServiceTests: XCTestCase {
|
||||
diagnostics.reset()
|
||||
XCTAssertEqual(diagnostics.currentSnapshot(), .init(monitorTicks: 0, pasteboardChanges: 0, extractionAttempts: 0, databaseMutations: 0, cachePurges: 0))
|
||||
}
|
||||
|
||||
private func waitForSnapshot(_ snapshot: @escaping () -> DiagnosticsService.Snapshot) -> DiagnosticsService.Snapshot {
|
||||
let expectation = expectation(description: "diagnostics queue")
|
||||
DispatchQueue.global().asyncAfter(deadline: .now() + 0.05) {
|
||||
expectation.fulfill()
|
||||
}
|
||||
wait(for: [expectation], timeout: 1)
|
||||
return snapshot()
|
||||
}
|
||||
}
|
||||
|
||||
81
tests/clipboredtests/LinkPreviewWindowControllerTests.swift
Normal file
81
tests/clipboredtests/LinkPreviewWindowControllerTests.swift
Normal file
@@ -0,0 +1,81 @@
|
||||
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])
|
||||
}
|
||||
}
|
||||
75
tests/clipboredtests/OnboardingWindowControllerTests.swift
Normal file
75
tests/clipboredtests/OnboardingWindowControllerTests.swift
Normal file
@@ -0,0 +1,75 @@
|
||||
import AppKit
|
||||
import XCTest
|
||||
@testable import ClipBored
|
||||
|
||||
final class OnboardingWindowControllerTests: XCTestCase {
|
||||
func testPasteStyleShortcutUsesShiftCommandV() {
|
||||
let shortcut = OnboardingWindowController.pasteStyleOpenShortcut
|
||||
|
||||
XCTAssertEqual(shortcut.key, "v")
|
||||
XCTAssertTrue(shortcut.has(.command))
|
||||
XCTAssertTrue(shortcut.has(.shift))
|
||||
XCTAssertFalse(shortcut.has(.option))
|
||||
XCTAssertFalse(shortcut.has(.control))
|
||||
}
|
||||
|
||||
func testFreshDefaultShortcutStartsWithPasteStyleChoice() {
|
||||
let choice = OnboardingWindowController.initialShortcutChoice(
|
||||
for: AppConfiguration.defaultOpenShortcut,
|
||||
onboardingCompleted: false
|
||||
)
|
||||
|
||||
XCTAssertEqual(choice, .pasteStyle)
|
||||
}
|
||||
|
||||
func testCompletedDefaultShortcutKeepsClipBoredDefaultChoice() {
|
||||
let choice = OnboardingWindowController.initialShortcutChoice(
|
||||
for: AppConfiguration.defaultOpenShortcut,
|
||||
onboardingCompleted: true
|
||||
)
|
||||
|
||||
XCTAssertEqual(choice, .clipBoredDefault)
|
||||
}
|
||||
|
||||
func testPresentationChoiceKeepsAVisibleEntryPoint() {
|
||||
let presentation = OnboardingWindowController.normalizedPresentation(
|
||||
showMenuBarIcon: false,
|
||||
showDockIcon: false
|
||||
)
|
||||
|
||||
XCTAssertTrue(presentation.showMenuBarIcon)
|
||||
XCTAssertFalse(presentation.showDockIcon)
|
||||
}
|
||||
|
||||
func testPresentationChoicePreservesExplicitDockOnlySetup() {
|
||||
let presentation = OnboardingWindowController.normalizedPresentation(
|
||||
showMenuBarIcon: false,
|
||||
showDockIcon: true
|
||||
)
|
||||
|
||||
XCTAssertFalse(presentation.showMenuBarIcon)
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@ final class PasteActionServiceTests: XCTestCase {
|
||||
}
|
||||
|
||||
func testCopyWritesTextToPasteboard() {
|
||||
let service = PasteActionService()
|
||||
let service = makeService()
|
||||
let item = ClipboardItem(
|
||||
id: UUID(),
|
||||
kind: .text,
|
||||
@@ -33,7 +33,7 @@ final class PasteActionServiceTests: XCTestCase {
|
||||
}
|
||||
|
||||
func testCopyWritesCodeSnippetAsPlainString() {
|
||||
let service = PasteActionService()
|
||||
let service = makeService()
|
||||
let snippet = "func greet(name: String) -> String {\n return \"Hi \\(name)\"\n}"
|
||||
let item = ClipboardItem(
|
||||
id: UUID(),
|
||||
@@ -54,7 +54,7 @@ final class PasteActionServiceTests: XCTestCase {
|
||||
}
|
||||
|
||||
func testPasteboardWritersExposeTextForDragOut() {
|
||||
let service = PasteActionService()
|
||||
let service = makeService()
|
||||
let item = ClipboardItem(
|
||||
id: UUID(),
|
||||
kind: .text,
|
||||
@@ -77,7 +77,7 @@ final class PasteActionServiceTests: XCTestCase {
|
||||
}
|
||||
|
||||
func testPasteboardWritersExposeURLAndTitleForDragOut() {
|
||||
let service = PasteActionService()
|
||||
let service = makeService()
|
||||
let item = ClipboardItem(
|
||||
id: UUID(),
|
||||
kind: .url,
|
||||
@@ -104,8 +104,64 @@ final class PasteActionServiceTests: XCTestCase {
|
||||
)
|
||||
}
|
||||
|
||||
func testCopyMultipleItemsPreservesOriginalPasteboardRepresentations() throws {
|
||||
let service = makeService()
|
||||
let link = ClipboardItem(
|
||||
id: UUID(),
|
||||
kind: .url,
|
||||
displayText: "Apple",
|
||||
payload: "https://apple.com",
|
||||
payloadHash: "hash",
|
||||
createdAt: Date(),
|
||||
lastUsedAt: Date(),
|
||||
useCount: 0,
|
||||
sourceApp: nil,
|
||||
imagePath: nil,
|
||||
thumbnailPath: nil
|
||||
)
|
||||
let text = makeTextItem("Batch note")
|
||||
|
||||
XCTAssertEqual(service.copy([link, text]), .copied)
|
||||
let pasteboardItems = try XCTUnwrap(NSPasteboard.general.pasteboardItems)
|
||||
XCTAssertEqual(pasteboardItems.count, 2)
|
||||
XCTAssertEqual(pasteboardItems[0].string(forType: .URL), "https://apple.com")
|
||||
XCTAssertEqual(
|
||||
pasteboardItems[0].string(forType: NSPasteboard.PasteboardType(rawValue: "public.url-name")),
|
||||
"Apple"
|
||||
)
|
||||
XCTAssertEqual(pasteboardItems[1].string(forType: .string), "Batch note")
|
||||
}
|
||||
|
||||
func testCopyMultipleItemsDoesNotClearPasteboardWhenAnyItemIsUnwritable() {
|
||||
let service = makeService()
|
||||
let missingPath = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("clipbored-missing-\(UUID().uuidString)")
|
||||
.path
|
||||
let missingFile = ClipboardItem(
|
||||
id: UUID(),
|
||||
kind: .file,
|
||||
displayText: "Missing file",
|
||||
payload: missingPath,
|
||||
payloadHash: "hash",
|
||||
createdAt: Date(),
|
||||
lastUsedAt: Date(),
|
||||
useCount: 0,
|
||||
sourceApp: nil,
|
||||
imagePath: nil,
|
||||
thumbnailPath: nil
|
||||
)
|
||||
NSPasteboard.general.clearContents()
|
||||
NSPasteboard.general.setString("keep me", forType: .string)
|
||||
|
||||
XCTAssertEqual(
|
||||
service.copy([makeTextItem("Writable"), missingFile]),
|
||||
.failed("Could not write items to clipboard.")
|
||||
)
|
||||
XCTAssertEqual(NSPasteboard.general.string(forType: .string), "keep me")
|
||||
}
|
||||
|
||||
func testCopyWritesColorToPasteboardWithHexFallback() throws {
|
||||
let service = PasteActionService()
|
||||
let service = makeService()
|
||||
let item = ClipboardItem(
|
||||
id: UUID(),
|
||||
kind: .color,
|
||||
@@ -129,7 +185,7 @@ final class PasteActionServiceTests: XCTestCase {
|
||||
}
|
||||
|
||||
func testPasteWithoutTargetCopiesWithoutRequestingAutomaticPaste() {
|
||||
let service = PasteActionService()
|
||||
let service = makeService()
|
||||
let item = ClipboardItem(
|
||||
id: UUID(),
|
||||
kind: .text,
|
||||
@@ -152,7 +208,7 @@ final class PasteActionServiceTests: XCTestCase {
|
||||
var activatedProcessID: pid_t?
|
||||
let targetApp = try makeRunningTargetApp()
|
||||
var didScheduleKeyboardPaste = false
|
||||
let service = PasteActionService(
|
||||
let service = makeService(
|
||||
accessibilityPermissionProvider: { true },
|
||||
targetActivator: { app in
|
||||
activatedProcessID = app.processIdentifier
|
||||
@@ -173,7 +229,7 @@ final class PasteActionServiceTests: XCTestCase {
|
||||
var activatedProcessID: pid_t?
|
||||
let targetApp = try makeRunningTargetApp()
|
||||
var didScheduleKeyboardPaste = false
|
||||
let service = PasteActionService(
|
||||
let service = makeService(
|
||||
accessibilityPermissionProvider: { true },
|
||||
targetActivator: { app in
|
||||
activatedProcessID = app.processIdentifier
|
||||
@@ -207,7 +263,7 @@ final class PasteActionServiceTests: XCTestCase {
|
||||
func testAutomaticPasteDoesNotPostShortcutWhenTargetActivationFails() throws {
|
||||
var didAttemptActivation = false
|
||||
let targetApp = try makeRunningTargetApp()
|
||||
let service = PasteActionService(
|
||||
let service = makeService(
|
||||
accessibilityPermissionProvider: { true },
|
||||
targetActivator: { _ in
|
||||
didAttemptActivation = true
|
||||
@@ -225,7 +281,7 @@ final class PasteActionServiceTests: XCTestCase {
|
||||
|
||||
func testAutomaticPasteWithoutPermissionDoesNotActivateTarget() throws {
|
||||
let targetApp = try makeRunningTargetApp()
|
||||
let service = PasteActionService(
|
||||
let service = makeService(
|
||||
accessibilityPermissionProvider: { false },
|
||||
targetActivator: { _ in
|
||||
XCTFail("Target should not be activated without Accessibility permission")
|
||||
@@ -241,7 +297,7 @@ final class PasteActionServiceTests: XCTestCase {
|
||||
}
|
||||
|
||||
func testCopyMissingFileDoesNotClearExistingPasteboard() {
|
||||
let service = PasteActionService()
|
||||
let service = makeService()
|
||||
let board = NSPasteboard.general
|
||||
board.clearContents()
|
||||
XCTAssertTrue(board.setString("keep me", forType: .string))
|
||||
@@ -265,7 +321,7 @@ final class PasteActionServiceTests: XCTestCase {
|
||||
}
|
||||
|
||||
func testCopyEmptyTextDoesNotClearExistingPasteboard() {
|
||||
let service = PasteActionService()
|
||||
let service = makeService()
|
||||
let board = NSPasteboard.general
|
||||
board.clearContents()
|
||||
XCTAssertTrue(board.setString("keep me", forType: .string))
|
||||
@@ -289,7 +345,7 @@ final class PasteActionServiceTests: XCTestCase {
|
||||
}
|
||||
|
||||
func testCopyWritesURLType() {
|
||||
let service = PasteActionService()
|
||||
let service = makeService()
|
||||
let item = ClipboardItem(
|
||||
id: UUID(),
|
||||
kind: .url,
|
||||
@@ -376,7 +432,7 @@ final class PasteActionServiceTests: XCTestCase {
|
||||
}
|
||||
|
||||
func testCopyLegacyRichTextWritesPlainPayloadWhenRTFCacheIsUnavailable() {
|
||||
let service = PasteActionService()
|
||||
let service = makeService()
|
||||
let item = ClipboardItem(
|
||||
id: UUID(),
|
||||
kind: .richText,
|
||||
@@ -398,7 +454,7 @@ final class PasteActionServiceTests: XCTestCase {
|
||||
|
||||
func testCopyRichTextWithMissingCacheWritesDisplayTextInsteadOfPath() throws {
|
||||
let missingPath = try makeTempDirectory().appendingPathComponent("missing-rich-text.rtf").path
|
||||
let service = PasteActionService()
|
||||
let service = makeService()
|
||||
let item = ClipboardItem(
|
||||
id: UUID(),
|
||||
kind: .richText,
|
||||
@@ -419,7 +475,7 @@ final class PasteActionServiceTests: XCTestCase {
|
||||
}
|
||||
|
||||
func testCopyPlainTextForURLOmitsURLPasteboardTypes() {
|
||||
let service = PasteActionService()
|
||||
let service = makeService()
|
||||
let item = ClipboardItem(
|
||||
id: UUID(),
|
||||
kind: .url,
|
||||
@@ -442,7 +498,7 @@ final class PasteActionServiceTests: XCTestCase {
|
||||
|
||||
func testCopyWritesFileReferenceType() throws {
|
||||
let fileURL = try makeTempFile(contents: "file contents")
|
||||
let service = PasteActionService()
|
||||
let service = makeService()
|
||||
let item = ClipboardItem(
|
||||
id: UUID(),
|
||||
kind: .file,
|
||||
@@ -467,7 +523,7 @@ final class PasteActionServiceTests: XCTestCase {
|
||||
let firstURL = try makeTempFile(contents: "first file")
|
||||
let secondURL = try makeTempFile(contents: "second file")
|
||||
let payload = FilePayload.payload(from: [firstURL, secondURL])
|
||||
let service = PasteActionService()
|
||||
let service = makeService()
|
||||
let item = ClipboardItem(
|
||||
id: UUID(),
|
||||
kind: .file,
|
||||
@@ -491,7 +547,7 @@ final class PasteActionServiceTests: XCTestCase {
|
||||
func testCopyWritesPDFData() throws {
|
||||
let pdfData = Data("%PDF-1.4\nclipbored\n%%EOF".utf8)
|
||||
let fileURL = try makeTempFile(contents: pdfData)
|
||||
let service = PasteActionService()
|
||||
let service = makeService()
|
||||
let item = ClipboardItem(
|
||||
id: UUID(),
|
||||
kind: .pdf,
|
||||
@@ -585,6 +641,36 @@ final class PasteActionServiceTests: XCTestCase {
|
||||
XCTAssertEqual(NSPasteboard.general.data(forType: .pdf), pdfData)
|
||||
}
|
||||
|
||||
private func makeService(
|
||||
accessibilityPermissionProvider: @escaping () -> Bool = { false },
|
||||
targetActivator: @escaping (NSRunningApplication) -> Bool = { _ in false },
|
||||
keyboardPasteScheduler: @escaping (@escaping () -> Void) -> Void = { _ in }
|
||||
) -> PasteActionService {
|
||||
PasteActionService(
|
||||
cacheService: makeNoOpCacheService(),
|
||||
accessibilityPermissionProvider: accessibilityPermissionProvider,
|
||||
targetActivator: targetActivator,
|
||||
keyboardPasteScheduler: keyboardPasteScheduler
|
||||
)
|
||||
}
|
||||
|
||||
private func makeNoOpCacheService() -> ClipboardCacheService {
|
||||
let directory = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("clipboredtests", isDirectory: true)
|
||||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
do {
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
tempURLs.append(directory)
|
||||
return ClipboardCacheService(baseURL: directory, encryptionService: noOpEncryptionService())
|
||||
} catch {
|
||||
XCTFail("Could not create no-op cache directory: \(error)")
|
||||
return ClipboardCacheService(
|
||||
baseURL: FileManager.default.temporaryDirectory,
|
||||
encryptionService: noOpEncryptionService()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func makeTempFile(contents: String) throws -> URL {
|
||||
try makeTempFile(contents: Data(contents.utf8))
|
||||
}
|
||||
@@ -633,4 +719,8 @@ final class PasteActionServiceTests: XCTestCase {
|
||||
let keyData = Data(repeating: byte, count: 32)
|
||||
return ClipboardEncryptionService(keyProvider: { SymmetricKey(data: keyData) })
|
||||
}
|
||||
|
||||
private func noOpEncryptionService() -> ClipboardEncryptionService {
|
||||
ClipboardEncryptionService(keyProvider: { nil })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,43 @@ import XCTest
|
||||
@testable import ClipBored
|
||||
|
||||
final class SettingsModelTests: XCTestCase {
|
||||
func testFreshProfileRequiresOnboardingUntilCompleted() {
|
||||
let suiteName = "com.clipbored.settingsmodel.\(UUID().uuidString)"
|
||||
let defaults = UserDefaults(suiteName: suiteName)!
|
||||
defer {
|
||||
defaults.removePersistentDomain(forName: suiteName)
|
||||
}
|
||||
let settings = SettingsModel(defaults: defaults)
|
||||
var changes: [SettingsModel.Change] = []
|
||||
settings.observe { changes.append($0) }
|
||||
|
||||
XCTAssertFalse(settings.onboardingCompleted)
|
||||
XCTAssertFalse(defaults.bool(forKey: SettingsModel.Keys.onboardingCompleted))
|
||||
|
||||
settings.markOnboardingCompleted()
|
||||
|
||||
XCTAssertTrue(settings.onboardingCompleted)
|
||||
XCTAssertTrue(defaults.bool(forKey: SettingsModel.Keys.onboardingCompleted))
|
||||
XCTAssertEqual(changes, [.other])
|
||||
|
||||
let restored = SettingsModel(defaults: defaults)
|
||||
XCTAssertTrue(restored.onboardingCompleted)
|
||||
}
|
||||
|
||||
func testLegacyProfileDoesNotShowOnboardingAfterUpgrade() {
|
||||
let suiteName = "com.clipbored.settingsmodel.\(UUID().uuidString)"
|
||||
let defaults = UserDefaults(suiteName: suiteName)!
|
||||
defer {
|
||||
defaults.removePersistentDomain(forName: suiteName)
|
||||
}
|
||||
defaults.set(AppConfiguration.defaultHistoryLength, forKey: SettingsModel.Keys.maxHistoryItems)
|
||||
|
||||
let settings = SettingsModel(defaults: defaults)
|
||||
|
||||
XCTAssertTrue(settings.onboardingCompleted)
|
||||
XCTAssertTrue(defaults.bool(forKey: SettingsModel.Keys.onboardingCompleted))
|
||||
}
|
||||
|
||||
func testShowDockIconPersistsAndNotifies() {
|
||||
let suiteName = "com.clipbored.settingsmodel.\(UUID().uuidString)"
|
||||
let defaults = UserDefaults(suiteName: suiteName)!
|
||||
@@ -26,6 +63,383 @@ final class SettingsModelTests: XCTestCase {
|
||||
XCTAssertTrue(restored.showDockIcon)
|
||||
}
|
||||
|
||||
func testHideFromScreenCapturePersistsAndNotifies() {
|
||||
let suiteName = "com.clipbored.settingsmodel.\(UUID().uuidString)"
|
||||
let defaults = UserDefaults(suiteName: suiteName)!
|
||||
defer {
|
||||
defaults.removePersistentDomain(forName: suiteName)
|
||||
}
|
||||
let settings = SettingsModel(defaults: defaults)
|
||||
var changes: [SettingsModel.Change] = []
|
||||
settings.observe { changes.append($0) }
|
||||
|
||||
XCTAssertFalse(settings.hideFromScreenCapture)
|
||||
|
||||
settings.hideFromScreenCapture = true
|
||||
|
||||
XCTAssertTrue(defaults.bool(forKey: SettingsModel.Keys.hideFromScreenCapture))
|
||||
XCTAssertEqual(changes, [.hideFromScreenCapture])
|
||||
|
||||
let restored = SettingsModel(defaults: defaults)
|
||||
XCTAssertTrue(restored.hideFromScreenCapture)
|
||||
}
|
||||
|
||||
func testCompactModePersistsAndNotifies() {
|
||||
let suiteName = "com.clipbored.settingsmodel.\(UUID().uuidString)"
|
||||
let defaults = UserDefaults(suiteName: suiteName)!
|
||||
defer {
|
||||
defaults.removePersistentDomain(forName: suiteName)
|
||||
}
|
||||
let settings = SettingsModel(defaults: defaults)
|
||||
var changes: [SettingsModel.Change] = []
|
||||
settings.observe { changes.append($0) }
|
||||
|
||||
XCTAssertFalse(settings.compactMode)
|
||||
|
||||
settings.compactMode = true
|
||||
|
||||
XCTAssertTrue(defaults.bool(forKey: SettingsModel.Keys.compactMode))
|
||||
XCTAssertEqual(changes, [.compactMode])
|
||||
|
||||
let restored = SettingsModel(defaults: defaults)
|
||||
XCTAssertTrue(restored.compactMode)
|
||||
}
|
||||
|
||||
func testPanelSidePersistsAndNotifies() {
|
||||
let suiteName = "com.clipbored.settingsmodel.\(UUID().uuidString)"
|
||||
let defaults = UserDefaults(suiteName: suiteName)!
|
||||
defer {
|
||||
defaults.removePersistentDomain(forName: suiteName)
|
||||
}
|
||||
let settings = SettingsModel(defaults: defaults)
|
||||
var changes: [SettingsModel.Change] = []
|
||||
settings.observe { changes.append($0) }
|
||||
|
||||
XCTAssertEqual(settings.panelLayout, .vertical)
|
||||
XCTAssertEqual(settings.panelSide, .right)
|
||||
|
||||
settings.panelSide = .left
|
||||
|
||||
XCTAssertEqual(defaults.integer(forKey: SettingsModel.Keys.panelLayout), ClipboardPanelLayout.vertical.rawValue)
|
||||
XCTAssertEqual(defaults.integer(forKey: SettingsModel.Keys.panelSide), ClipboardPanelSide.left.rawValue)
|
||||
XCTAssertEqual(changes, [.panelLayout])
|
||||
|
||||
let restored = SettingsModel(defaults: defaults)
|
||||
XCTAssertEqual(restored.panelLayout, .vertical)
|
||||
XCTAssertEqual(restored.panelSide, .left)
|
||||
}
|
||||
|
||||
func testImageCacheMinimumAllowsTwoMegabytes() {
|
||||
let suiteName = "com.clipbored.settingsmodel.\(UUID().uuidString)"
|
||||
let defaults = UserDefaults(suiteName: suiteName)!
|
||||
defer {
|
||||
defaults.removePersistentDomain(forName: suiteName)
|
||||
}
|
||||
let twoMegabytes = Int64(2 * 1024 * 1024)
|
||||
defaults.set(twoMegabytes, forKey: SettingsModel.Keys.imageCacheMaxBytes)
|
||||
|
||||
let settings = SettingsModel(defaults: defaults)
|
||||
|
||||
XCTAssertEqual(AppConfiguration.minCacheMaxBytes, twoMegabytes)
|
||||
XCTAssertEqual(settings.imageCacheMaxBytes, twoMegabytes)
|
||||
}
|
||||
|
||||
func testICloudSyncPersistsAndNotifies() {
|
||||
let suiteName = "com.clipbored.settingsmodel.\(UUID().uuidString)"
|
||||
let defaults = UserDefaults(suiteName: suiteName)!
|
||||
defer {
|
||||
defaults.removePersistentDomain(forName: suiteName)
|
||||
}
|
||||
let settings = SettingsModel(defaults: defaults)
|
||||
var changes: [SettingsModel.Change] = []
|
||||
settings.observe { changes.append($0) }
|
||||
|
||||
XCTAssertFalse(settings.iCloudSyncEnabled)
|
||||
|
||||
settings.setCloudSyncStatus(message: "Synced 3 clips to iCloud.")
|
||||
XCTAssertEqual(settings.cloudSyncStatusMessage, "Synced 3 clips to iCloud.")
|
||||
changes.removeAll()
|
||||
|
||||
settings.iCloudSyncEnabled = true
|
||||
|
||||
XCTAssertTrue(defaults.bool(forKey: SettingsModel.Keys.iCloudSyncEnabled))
|
||||
XCTAssertEqual(settings.cloudSyncStatusMessage, "")
|
||||
XCTAssertEqual(changes, [.cloudSync])
|
||||
|
||||
settings.setCloudSyncStatus(message: "Synced 3 clips to iCloud.")
|
||||
changes.removeAll()
|
||||
settings.iCloudSyncEnabled = false
|
||||
|
||||
XCTAssertFalse(defaults.bool(forKey: SettingsModel.Keys.iCloudSyncEnabled))
|
||||
XCTAssertEqual(settings.cloudSyncStatusMessage, "")
|
||||
XCTAssertEqual(changes, [.cloudSync])
|
||||
|
||||
let restored = SettingsModel(defaults: defaults)
|
||||
XCTAssertFalse(restored.iCloudSyncEnabled)
|
||||
}
|
||||
|
||||
func testPanelShelfHeightPersistsAndNotifies() {
|
||||
let suiteName = "com.clipbored.settingsmodel.\(UUID().uuidString)"
|
||||
let defaults = UserDefaults(suiteName: suiteName)!
|
||||
defer {
|
||||
defaults.removePersistentDomain(forName: suiteName)
|
||||
}
|
||||
let settings = SettingsModel(defaults: defaults)
|
||||
var changes: [SettingsModel.Change] = []
|
||||
settings.observe { changes.append($0) }
|
||||
|
||||
XCTAssertEqual(settings.panelShelfHeight, 0)
|
||||
|
||||
settings.panelShelfHeight = 620
|
||||
|
||||
XCTAssertEqual(defaults.double(forKey: SettingsModel.Keys.panelShelfHeight), 620)
|
||||
XCTAssertEqual(changes, [.panelSizing])
|
||||
|
||||
let restored = SettingsModel(defaults: defaults)
|
||||
XCTAssertEqual(restored.panelShelfHeight, 620)
|
||||
}
|
||||
|
||||
func testPauseCaptureUntilPersistsAndCanBeCleared() {
|
||||
let suiteName = "com.clipbored.settingsmodel.\(UUID().uuidString)"
|
||||
let defaults = UserDefaults(suiteName: suiteName)!
|
||||
defer {
|
||||
defaults.removePersistentDomain(forName: suiteName)
|
||||
}
|
||||
let settings = SettingsModel(defaults: defaults)
|
||||
let pauseUntil = Date(timeIntervalSince1970: 1_234_567)
|
||||
var changes: [SettingsModel.Change] = []
|
||||
settings.observe { changes.append($0) }
|
||||
|
||||
settings.pauseCapture = true
|
||||
settings.pauseCaptureUntil = pauseUntil
|
||||
|
||||
XCTAssertTrue(defaults.bool(forKey: SettingsModel.Keys.pauseCapture))
|
||||
XCTAssertEqual(defaults.double(forKey: SettingsModel.Keys.pauseCaptureUntil), pauseUntil.timeIntervalSince1970)
|
||||
XCTAssertEqual(changes, [.pauseCapture, .pauseCapture])
|
||||
|
||||
let restored = SettingsModel(defaults: defaults)
|
||||
XCTAssertTrue(restored.pauseCapture)
|
||||
XCTAssertEqual(restored.pauseCaptureUntil, pauseUntil)
|
||||
|
||||
restored.pauseCaptureUntil = nil
|
||||
|
||||
XCTAssertNil(defaults.object(forKey: SettingsModel.Keys.pauseCaptureUntil))
|
||||
}
|
||||
|
||||
func testIgnoredAppsPersistsAndNotifiesNarrowChange() {
|
||||
let suiteName = "com.clipbored.settingsmodel.\(UUID().uuidString)"
|
||||
let defaults = UserDefaults(suiteName: suiteName)!
|
||||
defer {
|
||||
defaults.removePersistentDomain(forName: suiteName)
|
||||
}
|
||||
let settings = SettingsModel(defaults: defaults)
|
||||
var changes: [SettingsModel.Change] = []
|
||||
settings.observe { changes.append($0) }
|
||||
|
||||
settings.ignoredApps = ["Safari", "Xcode"]
|
||||
|
||||
XCTAssertEqual(defaults.stringArray(forKey: SettingsModel.Keys.ignoredApps), ["Safari", "Xcode"])
|
||||
XCTAssertEqual(changes, [.ignoredApps])
|
||||
|
||||
let restored = SettingsModel(defaults: defaults)
|
||||
XCTAssertEqual(restored.ignoredApps, ["Safari", "Xcode"])
|
||||
}
|
||||
|
||||
func testCommonControlSettingsNotifyNarrowChanges() {
|
||||
let suiteName = "com.clipbored.settingsmodel.\(UUID().uuidString)"
|
||||
let defaults = UserDefaults(suiteName: suiteName)!
|
||||
defer {
|
||||
defaults.removePersistentDomain(forName: suiteName)
|
||||
}
|
||||
let settings = SettingsModel(defaults: defaults)
|
||||
var changes: [SettingsModel.Change] = []
|
||||
settings.observe { changes.append($0) }
|
||||
|
||||
settings.defaultSortMode = .links
|
||||
settings.includeImageTextInSearch = true
|
||||
settings.pruneDuplicates = false
|
||||
settings.ignoredItemKindsRaw = [ClipboardItemKind.image.rawValue]
|
||||
settings.keepFirstImage = false
|
||||
settings.excludeSensitive = true
|
||||
settings.clearHistoryOnQuit = true
|
||||
|
||||
XCTAssertEqual(changes, [
|
||||
.defaultSortMode,
|
||||
.includeImageTextInSearch,
|
||||
.pruneDuplicates,
|
||||
.ignoredItemKinds,
|
||||
.keepFirstImage,
|
||||
.excludeSensitive,
|
||||
.clearHistoryOnQuit
|
||||
])
|
||||
|
||||
let restored = SettingsModel(defaults: defaults)
|
||||
XCTAssertEqual(restored.defaultSortMode, .links)
|
||||
XCTAssertTrue(restored.includeImageTextInSearch)
|
||||
XCTAssertFalse(restored.pruneDuplicates)
|
||||
XCTAssertEqual(restored.ignoredItemKindsRaw, [ClipboardItemKind.image.rawValue])
|
||||
XCTAssertFalse(restored.keepFirstImage)
|
||||
XCTAssertTrue(restored.excludeSensitive)
|
||||
XCTAssertTrue(restored.clearHistoryOnQuit)
|
||||
}
|
||||
|
||||
func testIgnoredItemKindsCannotDisableEveryVisibleKind() {
|
||||
let suiteName = "com.clipbored.settingsmodel.\(UUID().uuidString)"
|
||||
let defaults = UserDefaults(suiteName: suiteName)!
|
||||
defer {
|
||||
defaults.removePersistentDomain(forName: suiteName)
|
||||
}
|
||||
let settings = SettingsModel(defaults: defaults)
|
||||
let allVisibleKindRawValues = Self.visibleItemKinds.map(\.rawValue)
|
||||
let expectedIgnoredKindRawValues = allVisibleKindRawValues.filter {
|
||||
$0 != ClipboardItemKind.text.rawValue
|
||||
}
|
||||
var changes: [SettingsModel.Change] = []
|
||||
settings.observe { changes.append($0) }
|
||||
|
||||
settings.ignoredItemKindsRaw = allVisibleKindRawValues
|
||||
|
||||
XCTAssertEqual(settings.ignoredItemKindsRaw, expectedIgnoredKindRawValues)
|
||||
XCTAssertEqual(
|
||||
defaults.object(forKey: SettingsModel.Keys.ignoredItemKinds) as? [Int],
|
||||
expectedIgnoredKindRawValues
|
||||
)
|
||||
XCTAssertEqual(changes, [.ignoredItemKinds])
|
||||
|
||||
let restored = SettingsModel(defaults: defaults)
|
||||
XCTAssertEqual(restored.ignoredItemKindsRaw, expectedIgnoredKindRawValues)
|
||||
}
|
||||
|
||||
func testStoredIgnoredItemKindsCannotDisableEveryVisibleKind() {
|
||||
let suiteName = "com.clipbored.settingsmodel.\(UUID().uuidString)"
|
||||
let defaults = UserDefaults(suiteName: suiteName)!
|
||||
defer {
|
||||
defaults.removePersistentDomain(forName: suiteName)
|
||||
}
|
||||
let allVisibleKindRawValues = Self.visibleItemKinds.map(\.rawValue)
|
||||
let expectedIgnoredKindRawValues = allVisibleKindRawValues.filter {
|
||||
$0 != ClipboardItemKind.text.rawValue
|
||||
}
|
||||
defaults.set(allVisibleKindRawValues, forKey: SettingsModel.Keys.ignoredItemKinds)
|
||||
|
||||
let settings = SettingsModel(defaults: defaults)
|
||||
|
||||
XCTAssertEqual(settings.ignoredItemKindsRaw, expectedIgnoredKindRawValues)
|
||||
XCTAssertEqual(
|
||||
defaults.object(forKey: SettingsModel.Keys.ignoredItemKinds) as? [Int],
|
||||
expectedIgnoredKindRawValues
|
||||
)
|
||||
}
|
||||
|
||||
func testStoredImageCacheLimitIsClampedToSettingsRange() {
|
||||
let suiteName = "com.clipbored.settingsmodel.\(UUID().uuidString)"
|
||||
let zeroDefaults = UserDefaults(suiteName: "\(suiteName).zero")!
|
||||
let lowDefaults = UserDefaults(suiteName: "\(suiteName).low")!
|
||||
let highDefaults = UserDefaults(suiteName: "\(suiteName).high")!
|
||||
defer {
|
||||
zeroDefaults.removePersistentDomain(forName: "\(suiteName).zero")
|
||||
lowDefaults.removePersistentDomain(forName: "\(suiteName).low")
|
||||
highDefaults.removePersistentDomain(forName: "\(suiteName).high")
|
||||
}
|
||||
|
||||
zeroDefaults.set(0, forKey: SettingsModel.Keys.imageCacheMaxBytes)
|
||||
let zeroSettings = SettingsModel(defaults: zeroDefaults)
|
||||
|
||||
XCTAssertEqual(zeroSettings.imageCacheMaxBytes, AppConfiguration.defaultCacheMaxBytes)
|
||||
XCTAssertEqual(Int64(zeroDefaults.integer(forKey: SettingsModel.Keys.imageCacheMaxBytes)), AppConfiguration.defaultCacheMaxBytes)
|
||||
|
||||
lowDefaults.set(1 * 1024 * 1024, forKey: SettingsModel.Keys.imageCacheMaxBytes)
|
||||
let lowSettings = SettingsModel(defaults: lowDefaults)
|
||||
|
||||
XCTAssertEqual(lowSettings.imageCacheMaxBytes, AppConfiguration.minCacheMaxBytes)
|
||||
XCTAssertEqual(Int64(lowDefaults.integer(forKey: SettingsModel.Keys.imageCacheMaxBytes)), AppConfiguration.minCacheMaxBytes)
|
||||
|
||||
highDefaults.set(2048 * 1024 * 1024, forKey: SettingsModel.Keys.imageCacheMaxBytes)
|
||||
let highSettings = SettingsModel(defaults: highDefaults)
|
||||
|
||||
XCTAssertEqual(highSettings.imageCacheMaxBytes, AppConfiguration.maxCacheMaxBytes)
|
||||
XCTAssertEqual(Int64(highDefaults.integer(forKey: SettingsModel.Keys.imageCacheMaxBytes)), AppConfiguration.maxCacheMaxBytes)
|
||||
}
|
||||
|
||||
func testStoredHistoryLimitIsClampedToSettingsRange() {
|
||||
let suiteName = "com.clipbored.settingsmodel.\(UUID().uuidString)"
|
||||
let zeroDefaults = UserDefaults(suiteName: "\(suiteName).zero")!
|
||||
let lowDefaults = UserDefaults(suiteName: "\(suiteName).low")!
|
||||
let highDefaults = UserDefaults(suiteName: "\(suiteName).high")!
|
||||
defer {
|
||||
zeroDefaults.removePersistentDomain(forName: "\(suiteName).zero")
|
||||
lowDefaults.removePersistentDomain(forName: "\(suiteName).low")
|
||||
highDefaults.removePersistentDomain(forName: "\(suiteName).high")
|
||||
}
|
||||
|
||||
zeroDefaults.set(0, forKey: SettingsModel.Keys.maxHistoryItems)
|
||||
let zeroSettings = SettingsModel(defaults: zeroDefaults)
|
||||
|
||||
XCTAssertEqual(zeroSettings.maxHistoryItems, AppConfiguration.defaultHistoryLength)
|
||||
XCTAssertEqual(zeroDefaults.integer(forKey: SettingsModel.Keys.maxHistoryItems), AppConfiguration.defaultHistoryLength)
|
||||
|
||||
lowDefaults.set(1, forKey: SettingsModel.Keys.maxHistoryItems)
|
||||
let lowSettings = SettingsModel(defaults: lowDefaults)
|
||||
|
||||
XCTAssertEqual(lowSettings.maxHistoryItems, AppConfiguration.minHistoryLength)
|
||||
XCTAssertEqual(lowDefaults.integer(forKey: SettingsModel.Keys.maxHistoryItems), AppConfiguration.minHistoryLength)
|
||||
|
||||
highDefaults.set(100_000, forKey: SettingsModel.Keys.maxHistoryItems)
|
||||
let highSettings = SettingsModel(defaults: highDefaults)
|
||||
|
||||
XCTAssertEqual(highSettings.maxHistoryItems, AppConfiguration.maxHistoryLength)
|
||||
XCTAssertEqual(highDefaults.integer(forKey: SettingsModel.Keys.maxHistoryItems), AppConfiguration.maxHistoryLength)
|
||||
}
|
||||
|
||||
func testLimitAssignmentsAreClampedAndPersisted() {
|
||||
let suiteName = "com.clipbored.settingsmodel.\(UUID().uuidString)"
|
||||
let defaults = UserDefaults(suiteName: suiteName)!
|
||||
defer {
|
||||
defaults.removePersistentDomain(forName: suiteName)
|
||||
}
|
||||
let settings = SettingsModel(defaults: defaults)
|
||||
var changes: [SettingsModel.Change] = []
|
||||
settings.observe { changes.append($0) }
|
||||
|
||||
settings.maxHistoryItems = 1
|
||||
settings.maxHistoryItems = 100_000
|
||||
settings.imageCacheMaxBytes = 1
|
||||
settings.imageCacheMaxBytes = 2048 * 1024 * 1024
|
||||
|
||||
XCTAssertEqual(settings.maxHistoryItems, AppConfiguration.maxHistoryLength)
|
||||
XCTAssertEqual(defaults.integer(forKey: SettingsModel.Keys.maxHistoryItems), AppConfiguration.maxHistoryLength)
|
||||
XCTAssertEqual(settings.imageCacheMaxBytes, AppConfiguration.maxCacheMaxBytes)
|
||||
XCTAssertEqual(Int64(defaults.integer(forKey: SettingsModel.Keys.imageCacheMaxBytes)), AppConfiguration.maxCacheMaxBytes)
|
||||
XCTAssertEqual(changes, [
|
||||
.maxHistoryItems,
|
||||
.maxHistoryItems,
|
||||
.imageCacheMaxBytes,
|
||||
.imageCacheMaxBytes
|
||||
])
|
||||
}
|
||||
|
||||
func testHistoryRetentionDefaultsToOneMonthAndPersists() {
|
||||
let suiteName = "com.clipbored.settingsmodel.\(UUID().uuidString)"
|
||||
let defaults = UserDefaults(suiteName: suiteName)!
|
||||
defer {
|
||||
defaults.removePersistentDomain(forName: suiteName)
|
||||
}
|
||||
let settings = SettingsModel(defaults: defaults)
|
||||
var changes: [SettingsModel.Change] = []
|
||||
settings.observe { changes.append($0) }
|
||||
|
||||
XCTAssertEqual(settings.historyRetention, .oneMonth)
|
||||
XCTAssertEqual(defaults.integer(forKey: SettingsModel.Keys.historyRetention), HistoryRetention.oneMonth.rawValue)
|
||||
|
||||
settings.historyRetention = .oneWeek
|
||||
|
||||
XCTAssertEqual(defaults.integer(forKey: SettingsModel.Keys.historyRetention), HistoryRetention.oneWeek.rawValue)
|
||||
XCTAssertEqual(changes, [.historyRetention])
|
||||
|
||||
let restored = SettingsModel(defaults: defaults)
|
||||
XCTAssertEqual(restored.historyRetention, .oneWeek)
|
||||
}
|
||||
|
||||
func testCustomCollectionsPersistWithNormalizedColors() {
|
||||
let suiteName = "com.clipbored.settingsmodel.\(UUID().uuidString)"
|
||||
let defaults = UserDefaults(suiteName: suiteName)!
|
||||
@@ -77,4 +491,17 @@ final class SettingsModelTests: XCTestCase {
|
||||
XCTAssertEqual(restored.customCollectionNames, ["Product Research"])
|
||||
XCTAssertEqual(restored.collectionColorHex(forCollectionNamed: "Product Research"), "#3366FF")
|
||||
}
|
||||
|
||||
private static let visibleItemKinds: [ClipboardItemKind] = [
|
||||
.text,
|
||||
.code,
|
||||
.url,
|
||||
.image,
|
||||
.color,
|
||||
.audio,
|
||||
.video,
|
||||
.richText,
|
||||
.pdf,
|
||||
.file
|
||||
]
|
||||
}
|
||||
|
||||
1064
tests/clipboredtests/SettingsWindowControllerTests.swift
Normal file
1064
tests/clipboredtests/SettingsWindowControllerTests.swift
Normal file
File diff suppressed because it is too large
Load Diff
@@ -56,6 +56,13 @@ final class ShortcutManagerTests: XCTestCase {
|
||||
manager.stop()
|
||||
}
|
||||
|
||||
func testRejectsConfiguredShortcutConflictWithFixedStackCaptureShortcut() {
|
||||
let manager = makeManager(openShortcut: ShortcutManager.stackCaptureShortcut)
|
||||
|
||||
XCTAssertEqual(manager.start(), .conflict(ShortcutManager.stackCaptureShortcut.displayText))
|
||||
manager.stop()
|
||||
}
|
||||
|
||||
private func makeManager(
|
||||
openShortcut: ShortcutBinding,
|
||||
settingsShortcut: ShortcutBinding = AppConfiguration.defaultSettingsShortcut
|
||||
|
||||
Reference in New Issue
Block a user