Compare commits
14 Commits
4705e687fc
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
54fac96dec | ||
|
|
52f712eb73 | ||
|
|
23cd8b64a9 | ||
|
|
d86d392c3a | ||
|
|
c7316105c7 | ||
|
|
e1557f42b2 | ||
|
|
23788fd136 | ||
|
|
26e4b15093 | ||
|
|
ca3bdfbc70 | ||
|
|
c5bafa3a83 | ||
|
|
c9ef1d5e84 | ||
|
|
d22c0c23ec | ||
|
|
fdf9c6f326 | ||
|
|
6dcf0d523e |
24
.github/workflows/ci.yml
vendored
24
.github/workflows/ci.yml
vendored
@@ -1,24 +0,0 @@
|
|||||||
name: CI
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [main]
|
|
||||||
pull_request:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
test:
|
|
||||||
name: Test And Build
|
|
||||||
runs-on: macos-14
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Check out repository
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Show Swift version
|
|
||||||
run: swift --version
|
|
||||||
|
|
||||||
- name: Run tests
|
|
||||||
run: swift test -q
|
|
||||||
|
|
||||||
- name: Build app bundle
|
|
||||||
run: ./scripts/build-macos-app.sh
|
|
||||||
@@ -24,6 +24,7 @@ Run the full local check before opening a pull request:
|
|||||||
- Avoid storing new classes of sensitive data. If capture behavior expands, add tests and update `docs/SECURITY.md`.
|
- Avoid storing new classes of sensitive data. If capture behavior expands, add tests and update `docs/SECURITY.md`.
|
||||||
- Keep idle work bounded. Polling, timers, file scans, and cache purges should have clear caps or backoff behavior.
|
- Keep idle work bounded. Polling, timers, file scans, and cache purges should have clear caps or backoff behavior.
|
||||||
- Add tests for persistence, pruning, sensitive filtering, shortcut parsing, pasteboard behavior, and search/sort changes.
|
- Add tests for persistence, pruning, sensitive filtering, shortcut parsing, pasteboard behavior, and search/sort changes.
|
||||||
|
- Prefer behavior-level tests. Use the smoke checklist for visual layout instead of adding production debug accessors for private UI details.
|
||||||
- Keep UI native and compact. This is a utility, not a marketing surface.
|
- Keep UI native and compact. This is a utility, not a marketing surface.
|
||||||
|
|
||||||
## Pull Request Checklist
|
## Pull Request Checklist
|
||||||
|
|||||||
@@ -16,11 +16,13 @@ let package = Package(
|
|||||||
exclude: ["resources"],
|
exclude: ["resources"],
|
||||||
linkerSettings: [
|
linkerSettings: [
|
||||||
.linkedFramework("AppKit"),
|
.linkedFramework("AppKit"),
|
||||||
|
.linkedFramework("AVFoundation"),
|
||||||
.linkedFramework("Carbon"),
|
.linkedFramework("Carbon"),
|
||||||
.linkedFramework("LocalAuthentication"),
|
.linkedFramework("LocalAuthentication"),
|
||||||
.linkedFramework("QuickLookUI"),
|
.linkedFramework("QuickLookUI"),
|
||||||
.linkedFramework("Security"),
|
.linkedFramework("Security"),
|
||||||
.linkedFramework("Vision"),
|
.linkedFramework("Vision"),
|
||||||
|
.linkedFramework("WebKit"),
|
||||||
.linkedLibrary("sqlite3")
|
.linkedLibrary("sqlite3")
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
|
|||||||
79
README.md
79
README.md
@@ -1,33 +1,51 @@
|
|||||||
# ClipBored
|
# 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 native macOS clipboard manager with a keyboard-first side shelf. It captures local clipboard history and makes clips easy to find, preview, organize, copy, paste, pin, and delete. It runs as a dockless menu-bar utility by default, with an optional Dock icon.
|
||||||
|
|
||||||
The project is intentionally dependency-light: Swift Package Manager, AppKit, Carbon hotkeys, SQLite, and system frameworks only.
|
The project is intentionally dependency-light: Swift Package Manager, AppKit, Carbon hotkeys, SQLite, and system frameworks only.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- Dockless menu-bar utility by default (`LSUIElement=true`), with a Settings toggle for normal Dock presence
|
- A compact toolbar with an expanding search control, clear-history and settings actions, plus a vertically scrollable category icon rail beside the cards
|
||||||
- Right-click menu-bar status menu with capture state, history count, settings, pause/resume, and quit
|
- Category filtering without a separate filter panel: click a chip to select it, or Command-click chips to show their union; unused empty built-in categories stay hidden while named Pinboards remain available even when empty
|
||||||
- Global shortcuts:
|
- A vertically scrolling list of cards with full-color kind or Pinboard headers that fills the space beside the category rail. Hover expands a card's preview without changing keyboard selection or filtering; commands live in card context menus and keyboard shortcuts
|
||||||
- `Command + Option + V` toggles the clipboard panel
|
- Search that collapses to a magnifying-glass button when it is empty and unfocused, expands on click, typing, or `Command + F`, and stays expanded while a query is active
|
||||||
|
- Fast, short panel, search, category, and card transitions that honor the macOS Reduce Motion accessibility setting
|
||||||
|
- First-run setup for the open shortcut, Keep History retention, menu-bar/Dock presence, launch at login, iCloud sync, and Accessibility permission
|
||||||
|
- A menu-bar status menu with capture state, history count, settings, manual or timed pause/resume, and quit
|
||||||
|
- Global and panel shortcuts:
|
||||||
|
- The configured open shortcut toggles the clipboard shelf
|
||||||
- `Command + ,` opens settings
|
- `Command + ,` opens settings
|
||||||
- `Command + 1` through `Command + 9` paste the numbered visible card; add `Shift` to paste that card as plain text
|
- `Command + F` focuses search
|
||||||
- `Command + G` shows a filtered result back in the full clipboard history
|
- `Command + 1` through `Command + 9` pastes the numbered visible card; add `Shift` to use plain text
|
||||||
- `Shift + Command + N` creates a new collection
|
- `Return` pastes the selected clip; `Shift + Return` or `Command + Shift + V` uses plain text
|
||||||
- `Space` previews the selected card when the focused search field is empty
|
- `Command + C` copies the selected clip or selected clips
|
||||||
- Clipboard history for text, URLs with local preview thumbnails when available, images, audio, RTF/HTML rich text, PDFs, and file references
|
- `Command + E` edits the selected text or code clip, and `Command + R` renames it
|
||||||
- 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
|
- `Delete` removes selected clips, and `Command + Z` restores the last deleted batch
|
||||||
- Shelf navigation keys for focused cards: Left/Right, Page Up/Page Down, Home, and End
|
- `Command + G` shows a filtered result in the full clipboard history
|
||||||
- Shelf navigation keys for focused collection chips: Left/Right, Home, and End
|
- `Command + O` opens the selected link, file, or media clip when possible
|
||||||
- SQLite persistence with bounded history, pinned-item retention, and encrypted app-managed payloads
|
- `Shift + Command + N` creates a Pinboard collection
|
||||||
- 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
|
- `Shift + Command + C` toggles Stack capture for queued multi-paste workflows
|
||||||
- Sort modes for recent, most used, images, links, text, files, audio, and pinned items
|
- `Command + Left` and `Command + Right` move between collections
|
||||||
- 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
|
- `Command + Up` and `Command + Down` jump to the first or last visible clip
|
||||||
- Searchable custom titles for clips, so media, files, links, PDFs, audio, and text can be renamed without changing the copied payload
|
- `Command + T` pauses or resumes clipboard capture
|
||||||
- Copy and paste actions with Accessibility permission fallback
|
- `Command + A` selects the visible list from a focused card; Shift-modified navigation extends a range
|
||||||
- Image thumbnail cache with byte and file-count pruning
|
- `Space` or `Command + Y` previews the selected card when the focused search field is empty
|
||||||
- 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
|
- Keyboard-focusable cards and category chips with type-to-search, visible focus chrome, VoiceOver descriptions, context menus, Up/Down card navigation, and direct category navigation
|
||||||
- Local-only storage, with optional sensitive-content exclusion for common secrets
|
- Clipboard history for text, code, colors, URLs, images, audio, video, RTF/HTML rich text, PDFs, and file references
|
||||||
|
- Immediate cards with previews loaded asynchronously. Image, link, document, and movie thumbnails fill in without blocking search, selection, or scrolling
|
||||||
|
- Built-in browser previews for links, Quick Look for files and media, image rotation, and local text extraction for images
|
||||||
|
- Independent-token and structured search, including `app:Safari`, `type:image,pdf`, `device:MacBook`, `pinboard:"Client Work","Read Later"`, and `date:2026-06-30`
|
||||||
|
- Custom color-coded Pinboards, including empty Pinboards, with drag-and-drop collection assignment, context-menu editing and export, and durable retention
|
||||||
|
- Searchable custom clip titles that do not alter the copied payload
|
||||||
|
- Multi-selection, original-format or plain-text batch copy/paste, Stack capture, next-item Stack actions, and Stack-as-text workflows
|
||||||
|
- SQLite persistence with bounded history, time-based retention, pinned-item and Pinboard retention, and encrypted app-managed payloads
|
||||||
|
- A bounded thumbnail cache with asynchronous loading and byte/file-count pruning
|
||||||
|
- Portable local archive export/import for history, Pinboards, and managed attachments; external file references stay path-based
|
||||||
|
- Optional iCloud archive sync for signed builds with an iCloud entitlement
|
||||||
|
- A redesigned, resizable Settings window with top-level `General`, `Shortcuts`, `Capture`, `Privacy`, `Performance`, and `Data` tabs. Each page is vertically scrollable and keeps controls aligned at narrow window sizes
|
||||||
|
- Settings for shelf side, retention, history length, default sort, cache limit, adaptive polling profile, ignored apps, content kinds, shortcuts, launch at login, Dock/menu-bar presence, screen-capture privacy, capture pause, and clear-on-quit behavior
|
||||||
|
- Local-first storage, with optional sensitive-content exclusion and iCloud sync disabled by default
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
@@ -42,7 +60,7 @@ swift test
|
|||||||
open build/ClipBored.app
|
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
|
## Development
|
||||||
|
|
||||||
@@ -61,8 +79,7 @@ swift test -q
|
|||||||
./scripts/idle-soak-report.sh 900
|
./scripts/idle-soak-report.sh 900
|
||||||
```
|
```
|
||||||
|
|
||||||
For app-level behavior that cannot be fully covered by unit tests, run the manual checklist in [docs/SMOKE_TEST.md](docs/SMOKE_TEST.md).
|
For app-level behavior that cannot be fully covered by unit tests, run [docs/SMOKE_TEST.md](docs/SMOKE_TEST.md). For distribution builds, see [docs/RELEASE.md](docs/RELEASE.md).
|
||||||
For distribution builds, see [docs/RELEASE.md](docs/RELEASE.md).
|
|
||||||
|
|
||||||
Project layout:
|
Project layout:
|
||||||
|
|
||||||
@@ -71,16 +88,16 @@ Project layout:
|
|||||||
- `sources/clipbored/extensions` - small AppKit/Foundation helpers
|
- `sources/clipbored/extensions` - small AppKit/Foundation helpers
|
||||||
- `sources/clipbored/models` - clipboard item and settings models
|
- `sources/clipbored/models` - clipboard item and settings models
|
||||||
- `sources/clipbored/resources` - app bundle metadata and icon assets
|
- `sources/clipbored/resources` - app bundle metadata and icon assets
|
||||||
- `sources/clipbored/services` - clipboard capture, persistence, cache, shortcuts, paste, diagnostics, privacy filters
|
- `sources/clipbored/services` - capture, persistence, cache, shortcuts, paste, diagnostics, and privacy filters
|
||||||
- `sources/clipbored/views` - panel and settings UI
|
- `sources/clipbored/views` - panel, onboarding, preview, and settings UI
|
||||||
- `tests/clipboredtests` - unit tests for persistence, filtering, shortcuts, pasteboard writes, diagnostics, and sensitive-content detection
|
- `tests/clipboredtests` - focused behavior tests for capture, persistence, search, paste, and settings decisions
|
||||||
- `docs` - architecture, security notes, and roadmap
|
- `docs` - architecture, security, release, smoke-test, and roadmap notes
|
||||||
|
|
||||||
## Privacy And Security
|
## 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, rich text sidecars, and PDF attachments are encrypted with AES-GCM using a Keychain-held key when Keychain access is available. If Keychain access blocks or fails, ClipBored uses an owner-only app-local fallback key so capture does not stall. Full history clears remove the local fallback key when present and reset cached key state for future captures. Temporary decrypted preview files may be created when opening or revealing encrypted media; stale previews are cleared on launch, cache/history clear, and quit. Use sensitive-content exclusion and ignored app settings for high-risk sources. See [docs/SECURITY.md](docs/SECURITY.md) for details and responsible disclosure.
|
Textual SQLite fields, image cache files, audio clips, video clips, rich text sidecars, and PDF attachments are encrypted with AES-GCM using a Keychain-held key when Keychain access is available. If Keychain access blocks or fails, ClipBored uses an owner-only app-local fallback key so capture does not stall. Portable archive exports and iCloud sync archives are not encrypted by ClipBored, so treat them as sensitive backups. Temporary decrypted preview files are cleared on launch, cache/history clear, and quit. See [docs/SECURITY.md](docs/SECURITY.md) for details and responsible disclosure.
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
|
|||||||
@@ -1,55 +1,76 @@
|
|||||||
# Architecture
|
# Architecture
|
||||||
|
|
||||||
ClipBored is a single-process AppKit utility built with Swift Package Manager.
|
ClipBored is a single-process AppKit utility built with Swift Package Manager. UI, capture, persistence, preview generation, and paste orchestration stay in-process. Capture persistence, card-thumbnail loading, archive and sync operations, image rotation, and OCR use bounded background queues; their UI state and completion feedback return to the main thread.
|
||||||
|
|
||||||
## Runtime Shape
|
## Runtime Shape
|
||||||
|
|
||||||
- `ClipBoredApp` creates `NSApplication`, sets accessory activation, installs `AppDelegate`, and starts the run loop.
|
- `ClipBoredApp` creates `NSApplication`, installs `AppDelegate`, and starts the run loop.
|
||||||
- `AppDelegate` wires shared services, status menu items, settings observers, and global shortcuts.
|
- `AppDelegate` wires services, menu-bar commands, settings observers, and global shortcuts.
|
||||||
- `ClipboardMonitorService` polls `NSPasteboard.changeCount` on a utility queue with adaptive active/idle intervals.
|
- `ClipboardMonitorService` watches `NSPasteboard.changeCount` on a utility queue with adaptive active/idle polling intervals selected by the Performance setting.
|
||||||
- `ClipboardStore` keeps the in-memory item list and persists rows to SQLite on a serial queue.
|
- `ClipboardStore` owns the in-memory item list and SQLite persistence on a serial queue.
|
||||||
- `ClipboardCacheService` stores bounded image previews under Application Support and keeps a small `NSCache`.
|
- `ClipboardCacheService` stores bounded encrypted preview sidecars under Application Support and maintains a small in-memory cache.
|
||||||
- `ShortcutManager` registers Carbon hotkeys for app-wide commands.
|
- `ClipboardCloudSyncService` resolves the private iCloud ubiquity container and pushes or pulls portable archives only when sync is enabled.
|
||||||
- `ClipboardPanelController` owns the bottom panel lifecycle and target-app tracking.
|
- `ShortcutManager` registers only intentional system-wide Carbon hotkeys (open shelf and Stack capture). The Settings binding is handled by the shelf's local key monitor and is never registered globally.
|
||||||
- `ClipboardPanelViewModel` filters, sorts, selects, copies, pastes, pins, organizes, deletes, opens, and reveals items.
|
- `ClipboardPanelController` owns shelf lifecycle, current-screen placement, left/right frame planning, target-app tracking, and show/hide/reflow animation.
|
||||||
- `SettingsWindowController` exposes native controls for capture, privacy, performance, shortcuts, and data management.
|
- `ClipboardPanelViewModel` owns query parsing, indexed category unions, sorting, selection, copy/paste, pinning, Pinboards, Stack, deletion, opening, and asynchronous thumbnail request coalescing.
|
||||||
|
- `ClipboardPanelView` renders the toolbar, vertical category icon rail, and viewport-aware card list beside it.
|
||||||
|
- `LinkPreviewWindowController` opens user-selected HTTP(S) links in an ephemeral WebKit preview window.
|
||||||
|
- `OnboardingWindowController` handles first-run shortcut, retention, lifecycle, sync, and Accessibility choices.
|
||||||
|
- `SettingsWindowController` presents six resizable, vertically scrollable settings pages and routes common settings changes through targeted control refreshes.
|
||||||
|
|
||||||
## Data Flow
|
## Capture And Presentation Flow
|
||||||
|
|
||||||
1. The monitor notices a pasteboard change.
|
1. The monitor notices a pasteboard change.
|
||||||
2. Source app metadata is checked against ignored apps.
|
2. Capture rules check paused state, ignored source apps, allowed content kinds, and optional sensitive-text exclusion.
|
||||||
3. Pasteboard content is normalized into a `ClipboardItem`.
|
3. Pasteboard content is normalized into a `ClipboardItem`; local Vision OCR runs when image-label search is enabled.
|
||||||
4. Sensitive text is skipped when exclusion is enabled.
|
4. The store deduplicates, preserves pinned and Pinboard-assigned items, enforces retention/length limits, and persists the mutation.
|
||||||
5. Copied images run local Vision OCR only when `Search in image labels` is enabled.
|
5. The panel view model maintains category/Pinboard indexes, applies the text query and selected category union, and caches parsed search matches across counts and category changes.
|
||||||
6. The store deduplicates, preserves pinned and collection-assigned items, enforces limits, and persists the mutation.
|
6. The view lays out card slots beside the category rail in one vertical document and materializes cards near the visible viewport.
|
||||||
7. The panel view model receives store updates and recomputes the visible list.
|
7. Cards render immediately with a fallback presentation. Preview thumbnails load on a bounded operation queue; identical in-flight requests share work, and a still-relevant card is replaced in place on the main thread when its image arrives.
|
||||||
|
|
||||||
## Persistence
|
## Shelf Interaction Model
|
||||||
|
|
||||||
History is stored in SQLite at:
|
The shelf uses a fixed vertical layout on the configured left or right edge of the active screen.
|
||||||
|
|
||||||
|
- Header row one contains the collapsed/expanded search control plus clear-history and settings actions.
|
||||||
|
- Header row two is a labeled, horizontally scrollable category rail. Built-in categories without clips are omitted unless currently selected; custom Pinboards remain available when empty.
|
||||||
|
- A normal chip click replaces the active category filter. Command-click adds or removes chips from a union. Hover changes chrome only and never changes filtering.
|
||||||
|
- An empty, unfocused search field collapses to an icon. Click, typing, or `Command + F` expands and focuses it; clicking elsewhere collapses it only when the query is empty. Repeated `Command + F` keeps focus in the same field.
|
||||||
|
- Cards scroll vertically and fill the usable shelf width. Hover expansion changes visual presentation only: it does not mutate keyboard selection, the selected range, or query/category state.
|
||||||
|
- Card commands are discoverable through context menus, VoiceOver descriptions, and keyboard shortcuts. Hover-only action controls are not part of the interaction contract.
|
||||||
|
- Category changes and card/search expansion use short AppKit/Core Animation transitions. Both panel-controller and panel-view durations resolve to zero when macOS Reduce Motion is enabled.
|
||||||
|
|
||||||
|
The panel has no user-facing resize lip, alternate density/layout mode, close button, new-text action, or persistent status bar. It is dismissed with `Esc` or the configured global shortcut.
|
||||||
|
|
||||||
|
## Settings UI
|
||||||
|
|
||||||
|
Settings uses a custom segmented selector backed by a borderless `NSTabView`:
|
||||||
|
|
||||||
|
- `General` - history, sorting, shelf side, launch, and menu-bar/Dock presence
|
||||||
|
- `Shortcuts` - a system-wide open-shelf binding and a pane-local open-settings binding
|
||||||
|
- `Capture` - pause state, content kinds, image-label search, likely-secret exclusion, ignored apps, and capture status
|
||||||
|
- `Privacy` - local-data behavior, screen-capture hiding, Accessibility permission, and paste status
|
||||||
|
- `Performance` - adaptive polling profile and thumbnail-cache cap
|
||||||
|
- `Data` - iCloud archive sync, local archive import/export, storage location, and destructive clears
|
||||||
|
|
||||||
|
Each tab is a top-aligned document inside its own vertical scroll view. The window has a practical minimum size, no horizontal scrollers, and commits focused text/shortcut drafts when it closes. Common narrow settings changes update their bound controls without rebuilding the whole window; expensive cloud status checks are cached across unrelated refreshes.
|
||||||
|
|
||||||
|
## Persistence And Privacy Boundaries
|
||||||
|
|
||||||
|
History is stored in:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
~/Library/Application Support/ClipBored/history.sqlite
|
~/Library/Application Support/ClipBored/history.sqlite
|
||||||
```
|
```
|
||||||
|
|
||||||
Images are stored under:
|
Image previews are stored under `images/`; restorable audio, video, rich-text, and PDF payloads are stored under `attachments/`. Legacy JSON import remains for migration from early builds.
|
||||||
|
|
||||||
```text
|
Portable `.clipboredarchive` files preserve item metadata, Pinboards, and decrypted bytes for app-managed sidecars so another Mac can re-cache them with its own storage paths and encryption key. External file references remain path-based. Optional iCloud sync uses the same unencrypted archive format inside the app-private ubiquity container and is disabled by default.
|
||||||
~/Library/Application Support/ClipBored/images/
|
|
||||||
```
|
|
||||||
|
|
||||||
Restorable non-image payloads such as audio clips, rich text, and PDFs are stored under:
|
Textual SQLite fields are encrypted and decrypted at the `ClipboardStore` boundary. Managed cache and attachment files are encrypted and decrypted at the `ClipboardCacheService` boundary. The key lives in Keychain when available, with an owner-only local fallback if Keychain access fails. Runtime `ClipboardItem` values remain plaintext in memory for search and clipboard operations. Opening encrypted media may create a temporary decrypted file; stale previews are cleared on launch, cache/history clear, and quit. Link previews are user-triggered and use a non-persistent WebKit data store.
|
||||||
|
|
||||||
```text
|
|
||||||
~/Library/Application Support/ClipBored/attachments/
|
|
||||||
```
|
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
## Size And Power Constraints
|
## 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 release build intentionally avoids SwiftUI, Combine, Swift Concurrency, third-party packages, and bundled media. The shelf avoids continuous layout work, renders only cards near the viewport, coalesces preview requests, keeps card-thumbnail decoding off the main thread, and bounds both memory and disk caches. Clipboard monitoring uses change-count polling with selectable adaptive profiles rather than continuous file scans.
|
||||||
|
|
||||||
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 build script uses `-Osize`, whole-module optimization, disabled reflection metadata, linker dead stripping, symbol stripping, and hardened-runtime signing. `scripts/build-macos-app.sh` enforces 2 MiB gates for both the executable and app bundle.
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ Run:
|
|||||||
./scripts/check.sh
|
./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
|
## 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`.
|
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
|
## Notarization
|
||||||
|
|
||||||
Preferred: configure a notarytool keychain profile once:
|
Preferred: configure a notarytool keychain profile once:
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Roadmap
|
# 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
|
## Near Term
|
||||||
|
|
||||||
@@ -10,16 +10,20 @@ This roadmap keeps future work aligned with the project's constraints: small exe
|
|||||||
## Privacy And Security
|
## Privacy And Security
|
||||||
|
|
||||||
- Keep improving secure cleanup semantics for cleared cache/history/key material where macOS storage behavior allows it.
|
- 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
|
## Product Polish
|
||||||
|
|
||||||
- Improve keyboard focus states and VoiceOver labels.
|
- Keep keyboard focus, VoiceOver descriptions, Command-click category unions, and context-menu parity covered as the side-rail shelf evolves.
|
||||||
- Add import/export only if the storage and privacy story remains clear.
|
- Validate motion, cross-display placement, and card expansion on each supported macOS release, including the system Reduce Motion path.
|
||||||
|
- 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
|
## Performance
|
||||||
|
|
||||||
- Keep measuring binary size after each feature.
|
- Keep measuring binary size after each feature.
|
||||||
- Avoid continuous background file scans.
|
- Avoid continuous background file scans.
|
||||||
- Revisit polling intervals only with measured idle wakeup evidence.
|
- Revisit polling intervals only with measured idle wakeup evidence.
|
||||||
- Keep image decoding lazy and cache bounded.
|
- Track asynchronous preview latency and viewport materialization under large histories.
|
||||||
|
- Keep card-thumbnail decoding and user-triggered image transforms off the main thread, with bounded queues and caches.
|
||||||
|
|||||||
@@ -1,20 +1,23 @@
|
|||||||
# Security Notes
|
# 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
|
## Current Protections
|
||||||
|
|
||||||
- No networking or telemetry in production source.
|
- No telemetry or background networking in production source.
|
||||||
- No shell/process execution.
|
- No shell/process execution.
|
||||||
- No Apple Events scripting.
|
- 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.
|
- Hardened runtime is applied by the local build script, and the release script supports Developer ID signing plus notarization when credentials are configured.
|
||||||
- Clipboard persistence uses prepared SQLite statements and bound values.
|
- Clipboard persistence uses prepared SQLite statements and bound values.
|
||||||
- Textual SQLite fields, including optional local image OCR text, are encrypted with AES-GCM using a Keychain-held key when Keychain access is available.
|
- Textual SQLite fields, including optional local image OCR text, are encrypted with AES-GCM using a Keychain-held key when Keychain access is available.
|
||||||
- App-managed image cache files, audio clips, rich text sidecars, and PDF attachments are encrypted with the same encryption service.
|
- App-managed image cache files, audio clips, video clips, rich text sidecars, and PDF attachments are encrypted with the same encryption service.
|
||||||
- If Keychain access blocks or fails, ClipBored uses an owner-only app-local fallback key so clipboard capture and persistence continue without a Keychain UI stall.
|
- If Keychain access blocks or fails, ClipBored uses an owner-only app-local fallback key so clipboard capture and persistence continue without a Keychain UI stall.
|
||||||
- Full history clears remove the app-local fallback key when present and reset cached key state after the database clear succeeds.
|
- Full history clears remove the app-local fallback key when present and reset cached key state after the database clear succeeds.
|
||||||
- App-owned storage directories are restricted to the current user, and saved history/cache files are written with owner-only permissions where the filesystem supports POSIX modes.
|
- App-owned storage directories are restricted to the current user, and saved history/cache files are written with owner-only permissions where the filesystem supports POSIX modes.
|
||||||
|
- 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.
|
- 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:
|
- Sensitive-content exclusion can skip common high-risk values:
|
||||||
- private key blocks
|
- private key blocks
|
||||||
- bearer tokens
|
- bearer tokens
|
||||||
@@ -35,12 +38,16 @@ ClipBored is designed as a local macOS utility. Its primary privacy promise is t
|
|||||||
|
|
||||||
- SQLite item metadata such as identifiers, kinds, timestamps, pin state, and use counts is not encrypted.
|
- SQLite item metadata such as identifiers, kinds, timestamps, pin state, and use counts is not encrypted.
|
||||||
- The app-local fallback key prevents plaintext app-managed history/media files, but it does not protect against a process or user account that can read the full ClipBored Application Support directory before history is cleared.
|
- The app-local fallback key prevents plaintext app-managed history/media files, but it does not protect against a process or user account that can read the full ClipBored Application Support directory before history is cleared.
|
||||||
- Opening or revealing encrypted images, audio clips, or PDFs creates temporary decrypted preview files so macOS can hand them to other apps. ClipBored clears stale preview files on launch, cache/history clear, and quit.
|
- Thumbnailing, opening, or revealing encrypted images, audio clips, video clips, or PDFs creates temporary decrypted preview files so macOS can hand them to system media APIs or other apps. ClipBored clears stale preview files on launch, cache/history clear, and quit.
|
||||||
- Existing plaintext SQLite rows and legacy sidecar files are migrated when encryption becomes available, but system snapshots, backups, live temporary previews, or filesystem remnants may retain older plaintext copies.
|
- Existing plaintext SQLite rows and legacy sidecar files are migrated when encryption becomes available, but system snapshots, backups, live temporary previews, or filesystem remnants may retain older plaintext copies.
|
||||||
|
- 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.
|
- The local development build is ad-hoc signed; use `scripts/release-macos-app.sh` with Developer ID credentials for notarized distribution builds.
|
||||||
- Accessibility permission is required for automatic paste simulation.
|
- Accessibility permission is required for automatic paste simulation.
|
||||||
|
- 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.
|
- 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.
|
- 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
|
## 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 `codesign --verify --deep --strict --verbose=2 build/ClipBored.app`.
|
||||||
- Verify hardened runtime appears in `codesign -d --verbose=4 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`.
|
- 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.
|
- Review any new persistence paths for unencrypted sensitive data.
|
||||||
|
|||||||
@@ -1,109 +1,93 @@
|
|||||||
# Manual Smoke Test Checklist
|
# Manual Smoke Test Checklist
|
||||||
|
|
||||||
Use this checklist before a release or after changes to panel, pasteboard, settings, permissions, storage, launch-at-login, or packaging behavior.
|
Use this checklist before a release or after changes to the shelf, pasteboard, settings, permissions, storage, launch-at-login, or packaging behavior.
|
||||||
|
|
||||||
## Setup
|
## Setup
|
||||||
|
|
||||||
1. Build the app:
|
1. Run `./scripts/check.sh`, quit any running copy, and open `build/ClipBored.app`.
|
||||||
|
2. With fresh preferences, confirm the setup assistant appears before the shelf and covers the open shortcut, Keep History, menu-bar/Dock presence, launch at login, iCloud sync, and Accessibility.
|
||||||
|
3. Finish setup, relaunch, and confirm the assistant does not return.
|
||||||
|
4. Confirm the menu-bar icon appears when `Show ClipBored in the menu bar` is enabled.
|
||||||
|
|
||||||
```bash
|
## Capture And Preview Loading
|
||||||
./scripts/check.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
2. Quit any running ClipBored copy.
|
1. Copy plain text, code, a URL, a color, an image, audio, video, rich text, a PDF, one Finder file, and multiple Finder files. Confirm each appears with the correct kind and grouped files remain one clip.
|
||||||
3. Open `build/ClipBored.app`.
|
2. Confirm a new card appears immediately even when its preview is not cached. Keep searching and scrolling while the thumbnail loads, then confirm the card updates in place without changing selection or scroll position.
|
||||||
4. Confirm ClipBored appears in the menu bar when `Show ClipBored in the menu bar` is enabled.
|
3. Confirm URL and video thumbnails appear when source data is available, and that a missing or failed thumbnail leaves a usable fallback card.
|
||||||
|
4. Enable `Search in image labels`, copy an image containing readable text, and confirm the OCR text is searchable after processing finishes.
|
||||||
|
5. Disable a content kind in Settings > Capture, copy that kind again, and confirm it is skipped. Re-enable it afterward.
|
||||||
|
6. Copy from an ignored source app and confirm the capture status reports that the item was skipped.
|
||||||
|
|
||||||
## Capture
|
## Shelf Chrome, Search, And Categories
|
||||||
|
|
||||||
1. Copy plain text from TextEdit, Notes, or a browser.
|
1. Open the shelf and confirm the collapsed search icon is at the toolbar's leading edge. Activate it and confirm the same liquid-glass pill expands smoothly to the right toward Clear History and Settings without moving its left edge or icon, with text and caret aligned after the icon.
|
||||||
2. Open the panel with `Command + Option + V`.
|
2. With an empty query, confirm search is a circular magnifying-glass button. Click it or press `Command + F` and confirm it expands to an aligned search field without moving the card list.
|
||||||
3. Confirm the copied text appears in Most Recent.
|
3. Click a card or category while the empty search field is focused and confirm search collapses again. Enter a query and confirm it remains expanded after focus moves elsewhere; clearing the idle query collapses it.
|
||||||
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.
|
4. Press `Command + F` repeatedly and confirm it keeps focus in the same search field.
|
||||||
5. Copy an image and confirm it appears as an Image with a thumbnail.
|
5. Type a query and confirm results update immediately. Press `Esc` once to clear an active query and again to close the shelf.
|
||||||
6. Enable `Search in image labels`, copy an image containing readable text, and confirm searching for that text finds the Image.
|
6. Type a structured query such as `pinboard:"Client Work" type:image,pdf device:<part of this Mac's name>` and confirm the terms are combined with the text query.
|
||||||
7. Copy a sound clip and confirm it appears as Audio.
|
7. Confirm the category icon rail scrolls vertically when its icons exceed the available height, while the card list scrolls independently beside it.
|
||||||
8. Copy a PDF or PDF selection and confirm it appears as a PDF.
|
8. With sparse history, confirm built-in type/sort categories with zero matches are absent. Clipboard remains available, and empty custom Pinboards remain visible.
|
||||||
9. Copy one Finder file and confirm it appears as a File.
|
9. Click Text and confirm it replaces the current category filter. Command-click Links and confirm the list becomes the union of Text and Links; Command-click either selected chip again to remove it from the union.
|
||||||
10. Copy multiple Finder files at once and confirm they appear as one grouped File item with the file count.
|
10. Hover several category chips without clicking and confirm neither the active filters nor the visible clips change.
|
||||||
11. Copy formatted text from a browser or Mail message and confirm it appears as Rich Text rather than flattened plain text.
|
11. Tab to search, toolbar controls, chips, and cards. Confirm focus is visible and VoiceOver labels explain each action. Use Left/Right and Home/End on category icons, and Up/Down/Page/Home/End navigation on cards; Left/Right must not move card selection.
|
||||||
12. Disable Images, Audio, Rich Text, PDFs, or Files in Settings > Capture, copy that type again, and confirm it is not captured.
|
|
||||||
|
|
||||||
## Panel
|
## Cards, Selection, And Actions
|
||||||
|
|
||||||
1. Open the panel and confirm the search field is focused.
|
1. Confirm cards form one vertical list beside the category rail, with full-color kind or Pinboard headers and readable alignment at both left and right shelf positions.
|
||||||
2. Type a query and confirm results filter immediately.
|
2. Hover an unselected card and confirm only that card's preview expands. The selected/focused card, selected range, visible results, and keyboard navigation must not change, and the content must remain unobscured.
|
||||||
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.
|
3. Move focus with the keyboard while another card is hovered. Confirm focus moves from the keyboard selection and stale hover expansion clears.
|
||||||
4. Clear the search field, press `Space`, and confirm the selected previewable clip opens in Quick Look instead of inserting a blank query.
|
4. Confirm card and category transitions feel continuous rather than snapping. Enable macOS System Settings > Accessibility > Display > Reduce Motion and confirm panel, search, category, and card transitions become immediate; restore the original system setting afterward.
|
||||||
5. Use arrow keys to move selection while the search field is focused.
|
5. Right-click representative text, link, image, file, and media cards. Confirm applicable commands appear in the context menu: paste/copy, plain-text variants, preview/open, edit/rename, pin/collect, Stack, image rotate/extract text, capture rules, show in Clipboard, and delete.
|
||||||
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.
|
6. With a card focused, confirm `Return` pastes, `Shift + Return` uses plain text, `Command + C` copies, `Space` or `Command + Y` previews, `Command + O` opens applicable clips, `Command + E` edits text/code, and `Command + R` renames without changing the payload.
|
||||||
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.
|
7. Press `Command + 1` through `Command + 9` and confirm the matching numbered cards are used; add `Shift` and confirm plain-text output.
|
||||||
8. With a card focused, use Left/Right, Page Up/Page Down, Home, and End; confirm selection and focus move together across the shelf.
|
8. Command-click non-adjacent cards and Shift-click a range. Confirm context-menu batch actions use the selected set and `Command + A` selects all visible cards.
|
||||||
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.
|
9. Delete multiple selected clips, press `Command + Z`, and confirm the batch returns selected.
|
||||||
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.
|
10. From a filtered result, choose Show in Clipboard or press `Command + G`; confirm search clears and the same clip stays selected in Clipboard.
|
||||||
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.
|
11. Double-click a card and confirm paste or copy fallback occurs without creating a duplicate history item.
|
||||||
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.
|
12. Confirm text cards do not repeat a one-line title in the body, multi-line text shows the remaining lines, files/PDFs use document previews, and missing source apps do not display `Unknown`.
|
||||||
13. Press `Esc` again and confirm the panel closes.
|
13. On multiple displays and Spaces, confirm the shortcut opens on the pointer's active display/Space and the menu-bar icon opens on its display. Switch Settings > General > Shelf side and verify both left and right placement.
|
||||||
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.
|
## Pinboards And Stack
|
||||||
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.
|
1. Press `Shift + Command + N` or use the category-row add button to create an empty color-coded Pinboard named `Client Work`; confirm its labeled chip remains visible with no count pill.
|
||||||
18. Right-click the Client Work chip, choose Edit Collection..., rename it, change its color, and confirm the chip and assigned card headers update.
|
2. Assign an existing card through Collect or drag it onto the Pinboard. Confirm its count, color, and assignment survive relaunch and normal history pruning.
|
||||||
19. Confirm collection chips with 0 clips do not show a visible count pill, while chips with clips still show their counts.
|
3. Right-click the Pinboard chip to edit its name/color, export it, and delete it. Import the archive into a fresh profile and confirm clips plus empty Pinboard metadata are restored.
|
||||||
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.
|
4. Press `Shift + Command + C`, copy two clips, and confirm Stack capture records them in copy order. Toggle capture off again.
|
||||||
21. Double-click an item and confirm it attempts to paste or falls back to copy without creating a duplicate history entry.
|
5. From a card or Stack context menu, test Add Visible Clips to Stack, next-item paste/copy, Stack-as-text, and Clear Stack. Confirm queue order and duplicate protection.
|
||||||
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.
|
|
||||||
|
|
||||||
## Copy And Paste
|
## Copy And Paste
|
||||||
|
|
||||||
1. Select a text item and press the Copy button. Confirm the system clipboard contains that text.
|
1. Copy/paste text, URL, image, audio, video, PDF, rich text, and file clips into suitable apps. Confirm original pasteboard representations are preserved.
|
||||||
2. Select a URL item and confirm the system clipboard contains both string and URL data by pasting into a browser address bar.
|
2. Paste a multi-file clip into Finder and confirm all file references are present.
|
||||||
3. Select one-file and multi-file File items and paste into Finder or an app that accepts file references. Confirm all files are preserved for the multi-file item.
|
3. Use plain-text paste on URL and rich-text clips and confirm formatting and non-text representations are omitted.
|
||||||
4. Select an audio item and paste into an app that accepts sound pasteboard data.
|
4. Without Accessibility permission, confirm paste falls back to copying and Settings > Privacy reports the permission requirement.
|
||||||
5. Select a PDF item and paste into Preview, Finder, or an app that accepts PDF pasteboard data.
|
5. With Accessibility permission, confirm paste returns focus to the previous app and inserts the selected clip.
|
||||||
6. Select a rich text item and paste into TextEdit rich text mode or Mail. Confirm basic formatting is preserved and plain-text paste still works in a text-only field.
|
|
||||||
7. Press `Command + 1` through `Command + 9` on visible numbered cards and confirm the matching card is pasted or copied; add `Shift` and confirm URL/rich items paste as plain text only.
|
|
||||||
8. Without Accessibility permission, confirm paste actions copy and show the permission fallback status.
|
|
||||||
9. With Accessibility permission granted, confirm paste returns focus to the previous app and inserts the selected item.
|
|
||||||
|
|
||||||
## Settings
|
## Settings
|
||||||
|
|
||||||
1. Open Settings with `Command + ,`.
|
1. With the ClipBored pane open, open Settings with `Command + ,` and confirm a resizable window with six clean selector tabs: `General`, `Shortcuts`, `Capture`, `Privacy`, `Performance`, and `Data`. Close the pane, switch to another app, and confirm `Command + ,` opens that app's settings instead of ClipBored.
|
||||||
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. Resize to the practical minimum. Visit every tab and confirm content stays anchored to the top-left, scrolls vertically when needed, and has no horizontal clipping, zero-sized controls, or duplicate selector labels.
|
||||||
3. Change the open-panel shortcut and confirm the old shortcut no longer opens the panel and the new shortcut does.
|
3. In General, change shelf side, Keep History, history length, default sort, launch-at-login, and menu-bar/Dock presence. Relaunch and confirm persistence.
|
||||||
4. Toggle `Pause clipboard capture`, copy text, and confirm paused capture does not record it.
|
4. In Shortcuts, change the open shortcut, close Settings while a field is focused, and confirm the edited binding is committed and the old shortcut no longer opens the shelf.
|
||||||
5. Toggle `Exclude likely secrets`, copy a representative token, and confirm it is not recorded.
|
5. In Capture, pause/resume capture, toggle likely-secret exclusion and image-label search, edit ignored apps, and toggle allowed content types. Confirm at least one content type must remain enabled and status feedback stays inside this tab.
|
||||||
6. Use `Open Accessibility Settings` and confirm System Settings opens to the permission area or fallback settings app.
|
6. In Privacy, test clear-on-quit, screen-capture hiding, Accessibility settings, permission refresh, and paste-status feedback.
|
||||||
7. Use `Clear Clipboard History` and `Clear Thumbnail Cache`; confirm each shows a warning confirmation before deleting data.
|
7. In Performance, change the polling profile and image-cache cap. Confirm capture continues at the selected cadence, UI input remains responsive while polling, and the values persist.
|
||||||
|
8. In Data, export/import an archive, exercise available iCloud controls, open the history folder, and confirm destructive clear actions require confirmation and show success or error feedback in the Data page.
|
||||||
|
9. Change one control and confirm unrelated tabs do not jump, reset, or flash; long status messages should wrap instead of widening the window.
|
||||||
|
|
||||||
## Storage And Privacy
|
## Storage And Privacy
|
||||||
|
|
||||||
1. Open the data folder from Settings > Data.
|
1. Open the data folder and confirm `history.sqlite`, `images/`, and `attachments/` appear as applicable.
|
||||||
2. Confirm `history.sqlite` exists after capture.
|
2. Copy unique text and managed attachment data; confirm `strings` does not expose the test content from the SQLite database or encrypted sidecars.
|
||||||
3. Copy unique text and confirm `strings ~/Library/Application\ Support/ClipBored/history.sqlite | grep "unique text"` does not find it.
|
3. Treat exported and iCloud `.clipboredarchive` files as sensitive because they are portable and not encrypted by ClipBored.
|
||||||
4. Copy uniquely identifiable rich text/audio/PDF data and confirm `strings ~/Library/Application\ Support/ClipBored/attachments/* | grep "unique text"` does not find it.
|
4. Open or reveal encrypted media, quit ClipBored, and confirm temporary preview files under `/tmp/ClipBored/Previews` are removed.
|
||||||
5. If `history-encryption.key` exists, confirm it is readable only by the current user.
|
5. Clear history and confirm saved rows, managed attachments, temporary previews, and the fallback encryption key are removed when present. Clear the thumbnail cache separately and confirm history remains.
|
||||||
6. Confirm image files are under `images/` and rich text/audio/PDF attachments are under `attachments/`.
|
6. Enable `Clear history on quit`, relaunch, and confirm history and managed cache/attachment files were removed.
|
||||||
7. Confirm app storage is local to `~/Library/Application Support/ClipBored`.
|
|
||||||
8. Open or reveal an encrypted image/audio/PDF, then quit ClipBored and confirm `/tmp/ClipBored/Previews` is removed.
|
|
||||||
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.
|
|
||||||
|
|
||||||
## Launch And Lifecycle
|
## Launch And Lifecycle
|
||||||
|
|
||||||
1. Enable Launch at Login, log out and back in, and confirm ClipBored starts.
|
1. Enable and disable Launch at Login and verify the next login behavior each time.
|
||||||
2. Disable Launch at Login and confirm it no longer starts after the next login.
|
2. Right-click or Control-click the menu-bar icon and confirm the menu includes capture state/count, Show Clipboard, New Collection, Stack Capture, Settings, pause/resume options, 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, and Quit.
|
3. Test a timed pause, manual pause, and resume; confirm copied items are skipped only while capture is paused.
|
||||||
4. Control-click the menu-bar icon and confirm the same status menu opens without toggling the panel.
|
4. Quit from the menu-bar menu and confirm no `ClipBored` process remains.
|
||||||
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.
|
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ BIN_NAME="$APP_NAME"
|
|||||||
BIN_PATH="$REPO_ROOT/.build/release/$BIN_NAME"
|
BIN_PATH="$REPO_ROOT/.build/release/$BIN_NAME"
|
||||||
INFO_PLIST="$REPO_ROOT/sources/clipbored/resources/Info.plist"
|
INFO_PLIST="$REPO_ROOT/sources/clipbored/resources/Info.plist"
|
||||||
ICON_FILE="$REPO_ROOT/sources/clipbored/resources/AppIcon.icns"
|
ICON_FILE="$REPO_ROOT/sources/clipbored/resources/AppIcon.icns"
|
||||||
|
SIZE_LIMIT_BYTES=$((2 * 1024 * 1024))
|
||||||
|
SIZE_LIMIT_LABEL="2 MiB"
|
||||||
|
|
||||||
cd "$REPO_ROOT"
|
cd "$REPO_ROOT"
|
||||||
|
|
||||||
@@ -20,7 +22,8 @@ swift build -c release --product "$APP_NAME" \
|
|||||||
-Xswiftc -Xfrontend \
|
-Xswiftc -Xfrontend \
|
||||||
-Xswiftc -disable-reflection-metadata \
|
-Xswiftc -disable-reflection-metadata \
|
||||||
-Xlinker -dead_strip \
|
-Xlinker -dead_strip \
|
||||||
-Xlinker -no_function_starts
|
-Xlinker -no_function_starts \
|
||||||
|
-Xlinker -no_compact_unwind
|
||||||
rm -rf "$APP_BUNDLE"
|
rm -rf "$APP_BUNDLE"
|
||||||
mkdir -p "$APP_BUNDLE/Contents/MacOS" "$APP_BUNDLE/Contents/Resources"
|
mkdir -p "$APP_BUNDLE/Contents/MacOS" "$APP_BUNDLE/Contents/Resources"
|
||||||
cp "$INFO_PLIST" "$APP_BUNDLE/Contents/Info.plist"
|
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"
|
touch "$APP_BUNDLE"
|
||||||
|
|
||||||
APP_SIZE=$(stat -f%z "$APP_BUNDLE/Contents/MacOS/$APP_NAME")
|
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)
|
HUMAN_SIZE=$(du -h "$APP_BUNDLE/Contents/MacOS/$APP_NAME" | cut -f1)
|
||||||
APP_BUNDLE_SIZE=$(du -sh "$APP_BUNDLE" | cut -f1)
|
APP_BUNDLE_SIZE=$(du -sh "$APP_BUNDLE" | cut -f1)
|
||||||
APP_BUNDLE_BYTES=$(du -sk "$APP_BUNDLE" | awk '{print $1*1024}')
|
APP_BUNDLE_BYTES=$(du -sk "$APP_BUNDLE" | awk '{print $1*1024}')
|
||||||
echo "Built $APP_BUNDLE"
|
echo "Built $APP_BUNDLE"
|
||||||
echo "Binary size: $HUMAN_SIZE ($APP_SIZE bytes)"
|
echo "Binary size: $HUMAN_SIZE ($APP_SIZE bytes)"
|
||||||
echo "Bundle size: $APP_BUNDLE_SIZE"
|
echo "Bundle size: $APP_BUNDLE_SIZE"
|
||||||
if [ "$APP_SIZE" -gt "$APP_SIZE_LIMIT" ]; then
|
if [ "$APP_SIZE" -gt "$SIZE_LIMIT_BYTES" ]; then
|
||||||
echo "FAIL: executable exceeds 1MiB target ($APP_SIZE bytes)"
|
echo "FAIL: executable exceeds $SIZE_LIMIT_LABEL target ($APP_SIZE bytes)"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
if [ "$APP_BUNDLE_BYTES" -gt 1800000 ]; then
|
if [ "$APP_BUNDLE_BYTES" -gt "$SIZE_LIMIT_BYTES" ]; then
|
||||||
echo "FAIL: bundle exceeds 1.8MB target ($APP_BUNDLE_BYTES bytes)"
|
echo "FAIL: bundle exceeds $SIZE_LIMIT_LABEL target ($APP_BUNDLE_BYTES bytes)"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -17,39 +17,70 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
let detail: String?
|
let detail: String?
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct CapturePauseDuration: Equatable {
|
||||||
|
let title: String
|
||||||
|
let seconds: TimeInterval
|
||||||
|
let symbolName: String
|
||||||
|
}
|
||||||
|
|
||||||
private static let statusMenuTextLimit = 68
|
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 cacheService: ClipboardCacheService!
|
||||||
|
private var cloudSyncService: ClipboardCloudSyncService!
|
||||||
private var settings: SettingsModel!
|
private var settings: SettingsModel!
|
||||||
private var store: ClipboardStore!
|
private var store: ClipboardStore!
|
||||||
private var monitor: ClipboardMonitorService!
|
private var monitor: ClipboardMonitorService!
|
||||||
private var panelController: ClipboardPanelController!
|
private var panelController: ClipboardPanelController!
|
||||||
private var settingsController: SettingsWindowController!
|
private var settingsController: SettingsWindowController!
|
||||||
|
private var onboardingController: OnboardingWindowController?
|
||||||
private var shortcutManager: ShortcutManager!
|
private var shortcutManager: ShortcutManager!
|
||||||
private var lifecycleService: AppLifecycleService!
|
private var lifecycleService: AppLifecycleService!
|
||||||
private var statusItem: NSStatusItem?
|
private var statusItem: NSStatusItem?
|
||||||
private var statusMenu: NSMenu?
|
private var statusMenu: NSMenu?
|
||||||
|
private var pauseResumeTimer: Timer?
|
||||||
|
private var cloudSyncPushWorkItem: DispatchWorkItem?
|
||||||
|
private let cloudSyncOperationQueue: OperationQueue = {
|
||||||
|
let queue = OperationQueue()
|
||||||
|
queue.name = "clipbored.cloud-sync"
|
||||||
|
queue.qualityOfService = .utility
|
||||||
|
queue.maxConcurrentOperationCount = 1
|
||||||
|
return queue
|
||||||
|
}()
|
||||||
|
private let cloudSyncStateLock = NSLock()
|
||||||
|
private var suppressCloudSyncPush = false
|
||||||
|
private var cloudSyncOperationGeneration = 0
|
||||||
|
|
||||||
func applicationDidFinishLaunching(_ notification: Notification) {
|
func applicationDidFinishLaunching(_ notification: Notification) {
|
||||||
settings = SettingsModel()
|
settings = SettingsModel()
|
||||||
cacheService = ClipboardCacheService()
|
cacheService = ClipboardCacheService()
|
||||||
|
cloudSyncService = ClipboardCloudSyncService()
|
||||||
store = ClipboardStore(settings: settings, cacheService: cacheService)
|
store = ClipboardStore(settings: settings, cacheService: cacheService)
|
||||||
monitor = ClipboardMonitorService(store: store, cacheService: cacheService, settings: settings)
|
monitor = ClipboardMonitorService(store: store, cacheService: cacheService, settings: settings)
|
||||||
panelController = ClipboardPanelController(
|
panelController = ClipboardPanelController(
|
||||||
store: store,
|
store: store,
|
||||||
settings: settings,
|
settings: settings,
|
||||||
cacheService: cacheService,
|
cacheService: cacheService,
|
||||||
preferredScreen: { [weak self] in
|
|
||||||
self?.statusItem?.button?.window?.screen
|
|
||||||
},
|
|
||||||
pollClipboardNow: { [weak monitor] in
|
pollClipboardNow: { [weak monitor] in
|
||||||
monitor?.pollNowAndWait()
|
monitor?.pollNow()
|
||||||
},
|
},
|
||||||
openSettings: { [weak self] in
|
openSettings: { [weak self] in
|
||||||
self?.openSettings()
|
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()
|
lifecycleService = AppLifecycleService()
|
||||||
shortcutManager = ShortcutManager(
|
shortcutManager = ShortcutManager(
|
||||||
onOpenClipboardPanel: { [weak self] in
|
onOpenClipboardPanel: { [weak self] in
|
||||||
@@ -57,10 +88,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
self?.panelController.toggle()
|
self?.panelController.toggle()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onOpenSettings: { [weak self] in
|
onToggleStackCapture: { [weak self] in
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
self?.refreshAccessibilityPermissionMessage()
|
self?.panelController.toggleStackCaptureMode()
|
||||||
self?.settingsController.show()
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onStatusChange: { [weak self] status in
|
onStatusChange: { [weak self] status in
|
||||||
@@ -68,12 +98,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
self?.settings.setShortcutStatus(message: status.message)
|
self?.settings.setShortcutStatus(message: status.message)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
openShortcut: settings.openShortcut,
|
openShortcut: settings.openShortcut
|
||||||
settingsShortcut: settings.settingsShortcut
|
|
||||||
)
|
)
|
||||||
bindSettings()
|
bindSettings()
|
||||||
|
bindCloudSync()
|
||||||
applyPresentation(changedSurface: nil)
|
applyPresentation(changedSurface: nil)
|
||||||
monitor.setPaused(settings.pauseCapture)
|
applyCapturePauseSetting()
|
||||||
monitor.start()
|
monitor.start()
|
||||||
shortcutManager.start()
|
shortcutManager.start()
|
||||||
|
|
||||||
@@ -81,14 +111,18 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
|
|
||||||
refreshStatusItem()
|
refreshStatusItem()
|
||||||
configureMainMenu()
|
configureMainMenu()
|
||||||
requestInitialAccessibilityPermissionIfNeeded()
|
presentInitialSetupIfNeeded()
|
||||||
}
|
}
|
||||||
|
|
||||||
func applicationDidBecomeActive(_ notification: Notification) {
|
func applicationDidBecomeActive(_ notification: Notification) {
|
||||||
refreshAccessibilityPermissionMessage()
|
refreshAccessibilityPermissionMessage()
|
||||||
|
onboardingController?.refreshPermissionStatus()
|
||||||
}
|
}
|
||||||
|
|
||||||
func applicationWillTerminate(_ notification: Notification) {
|
func applicationWillTerminate(_ notification: Notification) {
|
||||||
|
pauseResumeTimer?.invalidate()
|
||||||
|
cloudSyncPushWorkItem?.cancel()
|
||||||
|
cloudSyncOperationQueue.cancelAllOperations()
|
||||||
monitor.stop()
|
monitor.stop()
|
||||||
shortcutManager.stop()
|
shortcutManager.stop()
|
||||||
cacheService.clearTemporaryPreviews(wait: true)
|
cacheService.clearTemporaryPreviews(wait: true)
|
||||||
@@ -108,6 +142,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
panelController.toggle()
|
panelController.toggle()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@objc private func createCollection() {
|
||||||
|
panelController.createCollection()
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func toggleStackCaptureMode() {
|
||||||
|
panelController.toggleStackCaptureMode()
|
||||||
|
}
|
||||||
|
|
||||||
@objc private func statusItemClicked(_ sender: NSStatusBarButton) {
|
@objc private func statusItemClicked(_ sender: NSStatusBarButton) {
|
||||||
let event = NSApp.currentEvent
|
let event = NSApp.currentEvent
|
||||||
if shouldOpenStatusMenu(for: event) {
|
if shouldOpenStatusMenu(for: event) {
|
||||||
@@ -115,7 +157,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
showClipboardPanel()
|
showClipboardPanelFromStatusButton(sender)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func showClipboardPanelFromStatusButton(_ button: NSStatusBarButton) {
|
||||||
|
panelController.toggle(preferredScreen: button.window?.screen)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func shouldOpenStatusMenu(for event: NSEvent?) -> Bool {
|
private func shouldOpenStatusMenu(for event: NSEvent?) -> Bool {
|
||||||
@@ -143,6 +189,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
presentation: Self.statusMenuPresentation(
|
presentation: Self.statusMenuPresentation(
|
||||||
historyCount: store.items.count,
|
historyCount: store.items.count,
|
||||||
isCapturePaused: settings.pauseCapture,
|
isCapturePaused: settings.pauseCapture,
|
||||||
|
pauseCaptureUntil: settings.pauseCaptureUntil,
|
||||||
captureStatus: settings.captureStatusMessage,
|
captureStatus: settings.captureStatusMessage,
|
||||||
pasteStatus: settings.pasteStatusMessage,
|
pasteStatus: settings.pasteStatusMessage,
|
||||||
shortcutStatus: settings.shortcutStatusMessage,
|
shortcutStatus: settings.shortcutStatusMessage,
|
||||||
@@ -163,6 +210,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
static func statusMenuPresentation(
|
static func statusMenuPresentation(
|
||||||
historyCount: Int,
|
historyCount: Int,
|
||||||
isCapturePaused: Bool,
|
isCapturePaused: Bool,
|
||||||
|
pauseCaptureUntil: Date? = nil,
|
||||||
|
now: Date = Date(),
|
||||||
captureStatus: String,
|
captureStatus: String,
|
||||||
pasteStatus: String,
|
pasteStatus: String,
|
||||||
shortcutStatus: String,
|
shortcutStatus: String,
|
||||||
@@ -172,7 +221,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
let captureState = isCapturePaused ? "Capture Paused" : "Capture Running"
|
let captureState = isCapturePaused ? "Capture Paused" : "Capture Running"
|
||||||
let summary = "\(captureState) - \(clipCountText(historyCount))"
|
let summary = "\(captureState) - \(clipCountText(historyCount))"
|
||||||
let status = firstPresentStatus([
|
let status = firstPresentStatus([
|
||||||
isCapturePaused ? "Capture is paused." : nil,
|
capturePauseStatusText(isCapturePaused: isCapturePaused, pauseCaptureUntil: pauseCaptureUntil, now: now),
|
||||||
captureStatus,
|
captureStatus,
|
||||||
pasteStatus,
|
pasteStatus,
|
||||||
shortcutStatus,
|
shortcutStatus,
|
||||||
@@ -210,6 +259,24 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
symbolName: "rectangle.bottomthird.inset.filled",
|
symbolName: "rectangle.bottomthird.inset.filled",
|
||||||
to: menu
|
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(
|
addActionMenuItem(
|
||||||
"Settings\u{2026}",
|
"Settings\u{2026}",
|
||||||
action: #selector(openSettings),
|
action: #selector(openSettings),
|
||||||
@@ -225,10 +292,24 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
isCapturePaused ? "Resume Capture" : "Pause Capture",
|
isCapturePaused ? "Resume Capture" : "Pause Capture",
|
||||||
action: #selector(togglePauseCapture),
|
action: #selector(togglePauseCapture),
|
||||||
target: target,
|
target: target,
|
||||||
|
keyEquivalent: "t",
|
||||||
|
keyEquivalentModifierMask: .command,
|
||||||
symbolName: isCapturePaused ? "play.fill" : "pause.fill",
|
symbolName: isCapturePaused ? "play.fill" : "pause.fill",
|
||||||
to: menu
|
to: menu
|
||||||
)
|
)
|
||||||
pause.state = isCapturePaused ? .on : .off
|
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())
|
menu.addItem(NSMenuItem.separator())
|
||||||
addActionMenuItem(
|
addActionMenuItem(
|
||||||
@@ -249,7 +330,20 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@objc private func togglePauseCapture() {
|
@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() {
|
@objc private func quitApp() {
|
||||||
@@ -317,6 +411,35 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
return "\(count) clips"
|
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 {
|
private static func boundedStatusText(_ value: String) -> String {
|
||||||
let collapsed = value
|
let collapsed = value
|
||||||
.split { $0.isWhitespace || $0.isNewline }
|
.split { $0.isWhitespace || $0.isNewline }
|
||||||
@@ -385,6 +508,27 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
appSubMenu.addItem(quit)
|
appSubMenu.addItem(quit)
|
||||||
appMenu.submenu = appSubMenu
|
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 editMenu = NSMenuItem()
|
||||||
let editSubMenu = NSMenu(title: "Edit")
|
let editSubMenu = NSMenu(title: "Edit")
|
||||||
let openShortcut = self.settings.openShortcut
|
let openShortcut = self.settings.openShortcut
|
||||||
@@ -396,10 +540,19 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
showClipboard.keyEquivalentModifierMask = menuModifierFlags(openShortcut)
|
showClipboard.keyEquivalentModifierMask = menuModifierFlags(openShortcut)
|
||||||
showClipboard.target = self
|
showClipboard.target = self
|
||||||
editSubMenu.addItem(showClipboard)
|
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
|
editMenu.submenu = editSubMenu
|
||||||
|
|
||||||
let mainMenu = NSMenu()
|
let mainMenu = NSMenu()
|
||||||
mainMenu.addItem(appMenu)
|
mainMenu.addItem(appMenu)
|
||||||
|
mainMenu.addItem(fileMenu)
|
||||||
mainMenu.addItem(editMenu)
|
mainMenu.addItem(editMenu)
|
||||||
NSApp.mainMenu = mainMenu
|
NSApp.mainMenu = mainMenu
|
||||||
}
|
}
|
||||||
@@ -413,37 +566,202 @@ 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.isCloudSyncPushSuppressed else { return }
|
||||||
|
self.scheduleCloudSyncPush()
|
||||||
|
}
|
||||||
|
|
||||||
|
if settings.iCloudSyncEnabled {
|
||||||
|
applyCloudSyncSetting()
|
||||||
|
} else {
|
||||||
|
settings.setCloudSyncStatus(message: "iCloud Sync is off.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func handleSettingsChange(_ change: SettingsModel.Change) {
|
private func handleSettingsChange(_ change: SettingsModel.Change) {
|
||||||
switch change {
|
switch change {
|
||||||
case .maxHistoryItems:
|
case .maxHistoryItems:
|
||||||
store.updateHistoryLimit(settings.maxHistoryItems)
|
store.updateHistoryLimit(settings.maxHistoryItems)
|
||||||
|
case .historyRetention:
|
||||||
|
store.normalizeHistoryLength()
|
||||||
case .imageCacheMaxBytes:
|
case .imageCacheMaxBytes:
|
||||||
cacheService.purgeIfNeeded(maxBytes: settings.imageCacheMaxBytes)
|
cacheService.purgeIfNeeded(maxBytes: settings.imageCacheMaxBytes)
|
||||||
case .openShortcut, .settingsShortcut:
|
case .openShortcut:
|
||||||
let status = shortcutManager.reconfigure(openShortcut: settings.openShortcut, settingsShortcut: settings.settingsShortcut)
|
let status = shortcutManager.reconfigure(openShortcut: settings.openShortcut)
|
||||||
settings.setShortcutStatus(message: status.message)
|
settings.setShortcutStatus(message: status.message)
|
||||||
refreshStatusItem()
|
refreshStatusItem()
|
||||||
configureMainMenu()
|
configureMainMenu()
|
||||||
|
case .settingsShortcut:
|
||||||
|
refreshStatusItem()
|
||||||
|
configureMainMenu()
|
||||||
case .launchAtLogin:
|
case .launchAtLogin:
|
||||||
applyLaunchAtLoginSetting(settings.launchAtLogin)
|
applyLaunchAtLoginSetting(settings.launchAtLogin)
|
||||||
case .showMenuBarIcon:
|
case .showMenuBarIcon:
|
||||||
applyPresentation(changedSurface: .menuBar)
|
applyPresentation(changedSurface: .menuBar)
|
||||||
case .showDockIcon:
|
case .showDockIcon:
|
||||||
applyPresentation(changedSurface: .dock)
|
applyPresentation(changedSurface: .dock)
|
||||||
|
case .panelSide:
|
||||||
|
break
|
||||||
|
case .cloudSync:
|
||||||
|
applyCloudSyncSetting()
|
||||||
case .pauseCapture:
|
case .pauseCapture:
|
||||||
monitor.setPaused(settings.pauseCapture)
|
applyCapturePauseSetting()
|
||||||
if settings.showMenuBarIcon {
|
|
||||||
refreshStatusMenu()
|
|
||||||
}
|
|
||||||
case .pollProfile:
|
case .pollProfile:
|
||||||
monitor.setPaused(settings.pauseCapture)
|
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
|
break
|
||||||
case .captureStatus:
|
case .captureStatus:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func applyCloudSyncSetting() {
|
||||||
|
cloudSyncPushWorkItem?.cancel()
|
||||||
|
cloudSyncPushWorkItem = nil
|
||||||
|
cloudSyncOperationGeneration += 1
|
||||||
|
let generation = cloudSyncOperationGeneration
|
||||||
|
|
||||||
|
guard settings.iCloudSyncEnabled else {
|
||||||
|
setCloudSyncPushSuppressed(false)
|
||||||
|
settings.setCloudSyncStatus(message: "iCloud Sync is off.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
settings.setCloudSyncStatus(message: "Checking iCloud Sync…")
|
||||||
|
setCloudSyncPushSuppressed(true)
|
||||||
|
cloudSyncOperationQueue.addOperation { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
let status = self.cloudSyncService.status()
|
||||||
|
let result: (message: String, schedulesInitialPush: Bool)
|
||||||
|
if !status.isAvailable {
|
||||||
|
result = (status.message, false)
|
||||||
|
} else {
|
||||||
|
do {
|
||||||
|
let summary = try self.cloudSyncService.pull(store: self.store)
|
||||||
|
result = ("Restored \(summary.itemCount) clips from iCloud.", false)
|
||||||
|
} catch ClipboardCloudSyncError.noRemoteArchive(_) {
|
||||||
|
result = ("iCloud Sync is ready. No remote archive yet.", true)
|
||||||
|
} catch {
|
||||||
|
result = ("iCloud Sync failed: \(error.localizedDescription)", false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DispatchQueue.main.async { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
self.setCloudSyncPushSuppressed(false)
|
||||||
|
guard generation == self.cloudSyncOperationGeneration,
|
||||||
|
self.settings.iCloudSyncEnabled else { return }
|
||||||
|
self.settings.setCloudSyncStatus(message: result.message)
|
||||||
|
if result.schedulesInitialPush {
|
||||||
|
self.scheduleCloudSyncPush(after: 1.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 }
|
||||||
|
let generation = cloudSyncOperationGeneration
|
||||||
|
cloudSyncOperationQueue.addOperation { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
let message: String
|
||||||
|
do {
|
||||||
|
let summary = try self.cloudSyncService.push(store: self.store)
|
||||||
|
message = "Synced \(summary.itemCount) clips to iCloud."
|
||||||
|
} catch {
|
||||||
|
message = "iCloud Sync failed: \(error.localizedDescription)"
|
||||||
|
}
|
||||||
|
DispatchQueue.main.async { [weak self] in
|
||||||
|
guard let self,
|
||||||
|
generation == self.cloudSyncOperationGeneration,
|
||||||
|
self.settings.iCloudSyncEnabled else { return }
|
||||||
|
self.settings.setCloudSyncStatus(message: message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var isCloudSyncPushSuppressed: Bool {
|
||||||
|
cloudSyncStateLock.lock()
|
||||||
|
defer { cloudSyncStateLock.unlock() }
|
||||||
|
return suppressCloudSyncPush
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setCloudSyncPushSuppressed(_ suppressed: Bool) {
|
||||||
|
cloudSyncStateLock.lock()
|
||||||
|
suppressCloudSyncPush = suppressed
|
||||||
|
cloudSyncStateLock.unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
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(
|
static func presentationPlan(
|
||||||
showMenuBarIcon: Bool,
|
showMenuBarIcon: Bool,
|
||||||
showDockIcon: Bool,
|
showDockIcon: Bool,
|
||||||
@@ -536,7 +854,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
|
|
||||||
let alert = NSAlert()
|
let alert = NSAlert()
|
||||||
alert.messageText = "Allow automatic paste?"
|
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: "Open Accessibility Settings")
|
||||||
alert.addButton(withTitle: "Later")
|
alert.addButton(withTitle: "Later")
|
||||||
alert.alertStyle = .warning
|
alert.alertStyle = .warning
|
||||||
@@ -550,6 +868,39 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
refreshAccessibilityPermissionMessage()
|
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 {
|
private func menuModifierFlags(_ binding: ShortcutBinding) -> NSEvent.ModifierFlags {
|
||||||
NSEvent.ModifierFlags(rawValue: binding.modifierFlags)
|
NSEvent.ModifierFlags(rawValue: binding.modifierFlags)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ enum AppConfiguration {
|
|||||||
static let defaultHistoryLength = 300
|
static let defaultHistoryLength = 300
|
||||||
static let minHistoryLength = 50
|
static let minHistoryLength = 50
|
||||||
static let maxHistoryLength = 2000
|
static let maxHistoryLength = 2000
|
||||||
|
static let minCacheMaxBytes: Int64 = 2 * 1024 * 1024
|
||||||
static let defaultCacheMaxBytes: Int64 = 120 * 1024 * 1024
|
static let defaultCacheMaxBytes: Int64 = 120 * 1024 * 1024
|
||||||
|
static let maxCacheMaxBytes: Int64 = 512 * 1024 * 1024
|
||||||
static let maxPinnedItems = 250
|
static let maxPinnedItems = 250
|
||||||
static let maxFullImagePixelSize: CGFloat = 1600
|
static let maxFullImagePixelSize: CGFloat = 1600
|
||||||
static let maxRecognizedImageTextLength = 4096
|
static let maxRecognizedImageTextLength = 4096
|
||||||
|
|||||||
@@ -25,10 +25,40 @@ extension NSImage {
|
|||||||
let rep = NSBitmapImageRep(cgImage: cgImage)
|
let rep = NSBitmapImageRep(cgImage: cgImage)
|
||||||
return rep.representation(using: .png, properties: [:])
|
return rep.representation(using: .png, properties: [:])
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
extension NSView {
|
func rotatedClockwise() -> NSImage? {
|
||||||
var isInAnyViewHierarchy: Bool {
|
guard let cgImage = cgImage(forProposedRect: nil, context: nil, hints: nil),
|
||||||
return window != 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)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ enum ClipboardItemKind: Int {
|
|||||||
case unknown
|
case unknown
|
||||||
case pdf
|
case pdf
|
||||||
case audio
|
case audio
|
||||||
|
case color
|
||||||
|
case code
|
||||||
|
case video
|
||||||
|
|
||||||
var displayName: String {
|
var displayName: String {
|
||||||
switch self {
|
switch self {
|
||||||
@@ -20,6 +23,9 @@ enum ClipboardItemKind: Int {
|
|||||||
case .unknown: return "item"
|
case .unknown: return "item"
|
||||||
case .pdf: return "PDF"
|
case .pdf: return "PDF"
|
||||||
case .audio: return "audio"
|
case .audio: return "audio"
|
||||||
|
case .color: return "color"
|
||||||
|
case .code: return "code"
|
||||||
|
case .video: return "video"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -27,27 +33,27 @@ enum ClipboardItemKind: Int {
|
|||||||
extension ClipboardItemKind {
|
extension ClipboardItemKind {
|
||||||
var canOpen: Bool {
|
var canOpen: Bool {
|
||||||
switch self {
|
switch self {
|
||||||
case .url, .file, .image, .pdf, .audio:
|
case .url, .file, .image, .pdf, .audio, .video:
|
||||||
return true
|
return true
|
||||||
case .text, .richText, .unknown:
|
case .text, .richText, .unknown, .color, .code:
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var canReveal: Bool {
|
var canReveal: Bool {
|
||||||
switch self {
|
switch self {
|
||||||
case .file, .image, .pdf, .audio:
|
case .file, .image, .pdf, .audio, .video:
|
||||||
return true
|
return true
|
||||||
case .text, .richText, .unknown, .url:
|
case .text, .richText, .unknown, .url, .color, .code:
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var hasManagedCacheReference: Bool {
|
var hasManagedCacheReference: Bool {
|
||||||
switch self {
|
switch self {
|
||||||
case .url, .image, .pdf, .audio, .richText:
|
case .url, .image, .pdf, .audio, .richText, .video:
|
||||||
return true
|
return true
|
||||||
case .text, .file, .unknown:
|
case .text, .file, .unknown, .color, .code:
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -62,8 +68,11 @@ enum ClipboardSortMode: Int {
|
|||||||
case pinned
|
case pinned
|
||||||
case files
|
case files
|
||||||
case audio
|
case audio
|
||||||
|
case colors
|
||||||
|
case code
|
||||||
|
case videos
|
||||||
|
|
||||||
static let allCases: [ClipboardSortMode] = [.mostRecent, .mostUsed, .text, .links, .images, .audio, .files, .pinned]
|
static let allCases: [ClipboardSortMode] = [.mostRecent, .mostUsed, .text, .links, .images, .colors, .audio, .videos, .files, .pinned, .code]
|
||||||
|
|
||||||
var title: String {
|
var title: String {
|
||||||
switch self {
|
switch self {
|
||||||
@@ -75,6 +84,34 @@ enum ClipboardSortMode: Int {
|
|||||||
case .pinned: return "Pinned"
|
case .pinned: return "Pinned"
|
||||||
case .files: return "Files"
|
case .files: return "Files"
|
||||||
case .audio: return "Audio"
|
case .audio: return "Audio"
|
||||||
|
case .colors: return "Colors"
|
||||||
|
case .code: return "Code"
|
||||||
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -115,6 +152,7 @@ struct ClipboardItem {
|
|||||||
var ocrText: String?
|
var ocrText: String?
|
||||||
var collectionName: String?
|
var collectionName: String?
|
||||||
var customTitle: String?
|
var customTitle: String?
|
||||||
|
var sourceDeviceName: String?
|
||||||
|
|
||||||
var searchableText: String {
|
var searchableText: String {
|
||||||
var text = kindLabel + " " + displayText.lowercased() + " " + payload.lowercased()
|
var text = kindLabel + " " + displayText.lowercased() + " " + payload.lowercased()
|
||||||
@@ -124,18 +162,22 @@ struct ClipboardItem {
|
|||||||
if let sourceApp {
|
if let sourceApp {
|
||||||
text += " " + sourceApp.lowercased()
|
text += " " + sourceApp.lowercased()
|
||||||
}
|
}
|
||||||
if let ocrText {
|
|
||||||
text += " " + ocrText.lowercased()
|
|
||||||
}
|
|
||||||
if let sourceAppBundleId {
|
if let sourceAppBundleId {
|
||||||
text += " " + sourceAppBundleId.lowercased()
|
text += " " + sourceAppBundleId.lowercased()
|
||||||
}
|
}
|
||||||
if let collectionName {
|
if let collectionName {
|
||||||
text += " " + collectionName.lowercased()
|
text += " " + collectionName.lowercased()
|
||||||
}
|
}
|
||||||
|
if let sourceDeviceName {
|
||||||
|
text += " " + sourceDeviceName.lowercased()
|
||||||
|
}
|
||||||
return text
|
return text
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var effectiveSourceDeviceName: String {
|
||||||
|
ClipboardItem.normalizedDeviceName(sourceDeviceName) ?? Self.localDeviceName
|
||||||
|
}
|
||||||
|
|
||||||
private var kindLabel: String {
|
private var kindLabel: String {
|
||||||
switch kind {
|
switch kind {
|
||||||
case .text: return "text"
|
case .text: return "text"
|
||||||
@@ -146,6 +188,9 @@ struct ClipboardItem {
|
|||||||
case .unknown: return "unknown"
|
case .unknown: return "unknown"
|
||||||
case .pdf: return "pdf document"
|
case .pdf: return "pdf document"
|
||||||
case .audio: return "audio sound"
|
case .audio: return "audio sound"
|
||||||
|
case .color: return "color swatch hex"
|
||||||
|
case .code: return "code snippet source programming"
|
||||||
|
case .video: return "video movie mp4 quicktime"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,7 +210,8 @@ struct ClipboardItem {
|
|||||||
sourceAppBundleId: String? = nil,
|
sourceAppBundleId: String? = nil,
|
||||||
ocrText: String? = nil,
|
ocrText: String? = nil,
|
||||||
collectionName: String? = nil,
|
collectionName: String? = nil,
|
||||||
customTitle: String? = nil
|
customTitle: String? = nil,
|
||||||
|
sourceDeviceName: String? = ClipboardItem.localDeviceName
|
||||||
) {
|
) {
|
||||||
self.id = id
|
self.id = id
|
||||||
self.kind = kind
|
self.kind = kind
|
||||||
@@ -183,6 +229,7 @@ struct ClipboardItem {
|
|||||||
self.ocrText = ocrText
|
self.ocrText = ocrText
|
||||||
self.collectionName = collectionName
|
self.collectionName = collectionName
|
||||||
self.customTitle = ClipboardItem.normalizedCustomTitle(customTitle)
|
self.customTitle = ClipboardItem.normalizedCustomTitle(customTitle)
|
||||||
|
self.sourceDeviceName = ClipboardItem.normalizedDeviceName(sourceDeviceName)
|
||||||
}
|
}
|
||||||
|
|
||||||
static func normalizedCustomTitle(_ value: String?) -> String? {
|
static func normalizedCustomTitle(_ value: String?) -> String? {
|
||||||
@@ -194,4 +241,21 @@ struct ClipboardItem {
|
|||||||
guard !title.isEmpty else { return nil }
|
guard !title.isEmpty else { return nil }
|
||||||
return String(title.prefix(80))
|
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))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
106
sources/clipbored/models/CodeSnippetPayload.swift
Normal file
106
sources/clipbored/models/CodeSnippetPayload.swift
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum CodeSnippetPayload {
|
||||||
|
static func isLikelyCode(_ value: String) -> Bool {
|
||||||
|
let text = value.clipboardTrimmed
|
||||||
|
guard text.count >= 12 else { return false }
|
||||||
|
if text.hasPrefix("```") { return true }
|
||||||
|
if languageLabel(from: text) != "Code" { return true }
|
||||||
|
|
||||||
|
let lines = text.components(separatedBy: .newlines)
|
||||||
|
let nonEmptyLines = lines.map(\.clipboardTrimmed).filter { !$0.isEmpty }
|
||||||
|
guard !nonEmptyLines.isEmpty else { return false }
|
||||||
|
|
||||||
|
var score = 0
|
||||||
|
if nonEmptyLines.count >= 2 && lines.contains(where: { $0.hasPrefix(" ") || $0.hasPrefix("\t") }) {
|
||||||
|
score += 2
|
||||||
|
}
|
||||||
|
if text.contains("{") && text.contains("}") { score += 2 }
|
||||||
|
if text.contains(";") { score += 1 }
|
||||||
|
if text.contains("=>") || text.contains("->") || text.contains("==") || text.contains("!=") || text.contains("&&") || text.contains("||") {
|
||||||
|
score += 1
|
||||||
|
}
|
||||||
|
if nonEmptyLines.filter({ $0.hasSuffix("{") || $0.hasSuffix("}") || $0.hasSuffix(";") }).count >= 2 {
|
||||||
|
score += 2
|
||||||
|
}
|
||||||
|
if containsCodeKeyword(text) { score += 2 }
|
||||||
|
if containsAssignment(text) { score += 1 }
|
||||||
|
|
||||||
|
return score >= 4
|
||||||
|
}
|
||||||
|
|
||||||
|
static func languageLabel(from value: String) -> String {
|
||||||
|
let text = value.clipboardTrimmed
|
||||||
|
let lower = text.lowercased()
|
||||||
|
if isJSON(text) { return "JSON" }
|
||||||
|
if lower.contains("<html") || lower.contains("</") && lower.contains(">") {
|
||||||
|
return "HTML"
|
||||||
|
}
|
||||||
|
if lower.contains("#include") { return "C/C++" }
|
||||||
|
if lower.contains("func ") || lower.contains("let ") || lower.contains("var ") && lower.contains("->") {
|
||||||
|
return "Swift"
|
||||||
|
}
|
||||||
|
if lower.contains("function ") || lower.contains("const ") || lower.contains("let ") && lower.contains("=>") {
|
||||||
|
return "JavaScript"
|
||||||
|
}
|
||||||
|
if lower.contains("def ") || lower.contains("import ") && lower.contains(":") {
|
||||||
|
return "Python"
|
||||||
|
}
|
||||||
|
if lower.range(of: #"\b(select|insert|update|delete|create)\b[\s\S]+\b(from|into|table|set)\b"#, options: .regularExpression) != nil {
|
||||||
|
return "SQL"
|
||||||
|
}
|
||||||
|
if lower.range(of: #"^\s*(git|npm|yarn|pnpm|curl|ssh|docker|kubectl|brew|swift|python|node)\b"#, options: .regularExpression) != nil {
|
||||||
|
return "Shell"
|
||||||
|
}
|
||||||
|
if lower.contains("{") && lower.contains(":") && lower.contains(";") {
|
||||||
|
return "CSS"
|
||||||
|
}
|
||||||
|
return "Code"
|
||||||
|
}
|
||||||
|
|
||||||
|
static func title(from value: String) -> String {
|
||||||
|
let language = languageLabel(from: value)
|
||||||
|
guard language != "Code" else { return "Code Snippet" }
|
||||||
|
return "\(language) Snippet"
|
||||||
|
}
|
||||||
|
|
||||||
|
static func previewText(from value: String, maxLines: Int = 4) -> String {
|
||||||
|
let lines = value
|
||||||
|
.components(separatedBy: .newlines)
|
||||||
|
.map { $0.clipboardTrimmed }
|
||||||
|
.filter { !$0.isEmpty && $0 != "```" }
|
||||||
|
let preview = lines.prefix(maxLines).joined(separator: " ")
|
||||||
|
return preview.isEmpty ? "Code snippet" : String(preview.prefix(180))
|
||||||
|
}
|
||||||
|
|
||||||
|
static func previewLines(from value: String, maxLines: Int = 5) -> [String] {
|
||||||
|
let lines = value
|
||||||
|
.components(separatedBy: .newlines)
|
||||||
|
.map { line in
|
||||||
|
line.replacingOccurrences(of: "\t", with: " ")
|
||||||
|
}
|
||||||
|
.filter { !$0.clipboardTrimmed.isEmpty && $0.clipboardTrimmed != "```" }
|
||||||
|
return Array(lines.prefix(maxLines))
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func containsCodeKeyword(_ value: String) -> Bool {
|
||||||
|
value.range(
|
||||||
|
of: #"\b(import|func|function|class|struct|enum|interface|return|guard|if|else|for|while|switch|case|try|catch|throw|async|await|public|private|static|const|let|var|def)\b"#,
|
||||||
|
options: [.regularExpression, .caseInsensitive]
|
||||||
|
) != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func containsAssignment(_ value: String) -> Bool {
|
||||||
|
value.range(
|
||||||
|
of: #"\b[A-Za-z_][A-Za-z0-9_]*\s*(=|:=)\s*[^=\n]"#,
|
||||||
|
options: .regularExpression
|
||||||
|
) != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func isJSON(_ value: String) -> Bool {
|
||||||
|
guard let data = value.data(using: .utf8) else { return false }
|
||||||
|
let trimmed = value.clipboardTrimmed
|
||||||
|
guard trimmed.hasPrefix("{") || trimmed.hasPrefix("[") else { return false }
|
||||||
|
return (try? JSONSerialization.jsonObject(with: data)) != nil
|
||||||
|
}
|
||||||
|
}
|
||||||
67
sources/clipbored/models/ColorPayload.swift
Normal file
67
sources/clipbored/models/ColorPayload.swift
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import AppKit
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum ColorPayload {
|
||||||
|
static func hexString(from color: NSColor) -> String? {
|
||||||
|
guard let rgb = color.usingColorSpace(.sRGB) ?? color.usingColorSpace(.deviceRGB) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
let red = clampedByte(rgb.redComponent)
|
||||||
|
let green = clampedByte(rgb.greenComponent)
|
||||||
|
let blue = clampedByte(rgb.blueComponent)
|
||||||
|
let alpha = clampedByte(rgb.alphaComponent)
|
||||||
|
if alpha >= 255 {
|
||||||
|
return String(format: "#%02X%02X%02X", red, green, blue)
|
||||||
|
}
|
||||||
|
return String(format: "#%02X%02X%02X%02X", red, green, blue, alpha)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func color(from payload: String) -> NSColor? {
|
||||||
|
var value = payload.clipboardTrimmed
|
||||||
|
if value.hasPrefix("#") {
|
||||||
|
value.removeFirst()
|
||||||
|
}
|
||||||
|
guard value.count == 6 || value.count == 8,
|
||||||
|
let raw = UInt32(value, radix: 16) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let hasAlpha = value.count == 8
|
||||||
|
let red = CGFloat((raw >> (hasAlpha ? 24 : 16)) & 0xFF) / 255
|
||||||
|
let green = CGFloat((raw >> (hasAlpha ? 16 : 8)) & 0xFF) / 255
|
||||||
|
let blue = CGFloat((raw >> (hasAlpha ? 8 : 0)) & 0xFF) / 255
|
||||||
|
let alpha = hasAlpha ? CGFloat(raw & 0xFF) / 255 : 1
|
||||||
|
return NSColor(deviceRed: red, green: green, blue: blue, alpha: alpha)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func displayHex(from payload: String) -> String {
|
||||||
|
if let color = color(from: payload), let hex = hexString(from: color) {
|
||||||
|
return hex
|
||||||
|
}
|
||||||
|
let normalized = payload.clipboardTrimmed
|
||||||
|
return normalized.hasPrefix("#") ? normalized.uppercased() : "#\(normalized.uppercased())"
|
||||||
|
}
|
||||||
|
|
||||||
|
static func componentSummary(from payload: String) -> String {
|
||||||
|
guard let color = color(from: payload),
|
||||||
|
let rgb = color.usingColorSpace(.sRGB) ?? color.usingColorSpace(.deviceRGB) else {
|
||||||
|
return "Color"
|
||||||
|
}
|
||||||
|
let red = clampedByte(rgb.redComponent)
|
||||||
|
let green = clampedByte(rgb.greenComponent)
|
||||||
|
let blue = clampedByte(rgb.blueComponent)
|
||||||
|
let alpha = clampedByte(rgb.alphaComponent)
|
||||||
|
if alpha >= 255 {
|
||||||
|
return "RGB \(red) \(green) \(blue)"
|
||||||
|
}
|
||||||
|
return "RGBA \(red) \(green) \(blue) \(alpha)"
|
||||||
|
}
|
||||||
|
|
||||||
|
static func previewText(from payload: String) -> String {
|
||||||
|
"\(displayHex(from: payload))\n\(componentSummary(from: payload))"
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func clampedByte(_ value: CGFloat) -> Int {
|
||||||
|
Int((min(1, max(0, value)) * 255).rounded())
|
||||||
|
}
|
||||||
|
}
|
||||||
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,64 @@
|
|||||||
import Foundation
|
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 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 {
|
final class SettingsModel {
|
||||||
enum Change: Equatable {
|
enum Change: Equatable {
|
||||||
case maxHistoryItems
|
case maxHistoryItems
|
||||||
|
case historyRetention
|
||||||
|
case defaultSortMode
|
||||||
case imageCacheMaxBytes
|
case imageCacheMaxBytes
|
||||||
|
case includeImageTextInSearch
|
||||||
|
case pruneDuplicates
|
||||||
case openShortcut
|
case openShortcut
|
||||||
case settingsShortcut
|
case settingsShortcut
|
||||||
case launchAtLogin
|
case launchAtLogin
|
||||||
case showMenuBarIcon
|
case showMenuBarIcon
|
||||||
case showDockIcon
|
case showDockIcon
|
||||||
|
case panelSide
|
||||||
|
case cloudSync
|
||||||
case pauseCapture
|
case pauseCapture
|
||||||
|
case ignoredApps
|
||||||
|
case ignoredItemKinds
|
||||||
|
case keepFirstImage
|
||||||
|
case excludeSensitive
|
||||||
|
case hideFromScreenCapture
|
||||||
|
case clearHistoryOnQuit
|
||||||
case pollProfile
|
case pollProfile
|
||||||
case captureStatus
|
case captureStatus
|
||||||
case collections
|
case collections
|
||||||
@@ -19,6 +68,7 @@ final class SettingsModel {
|
|||||||
|
|
||||||
enum Keys {
|
enum Keys {
|
||||||
static let maxHistoryItems = "maxHistoryItems"
|
static let maxHistoryItems = "maxHistoryItems"
|
||||||
|
static let historyRetention = "historyRetentionDays"
|
||||||
static let defaultSortMode = "defaultSortMode"
|
static let defaultSortMode = "defaultSortMode"
|
||||||
static let imageCacheMaxBytes = "imageCacheMaxBytes"
|
static let imageCacheMaxBytes = "imageCacheMaxBytes"
|
||||||
static let includeImageTextInSearch = "includeImageTextInSearch"
|
static let includeImageTextInSearch = "includeImageTextInSearch"
|
||||||
@@ -26,6 +76,8 @@ final class SettingsModel {
|
|||||||
static let launchAtLogin = "launchAtLogin"
|
static let launchAtLogin = "launchAtLogin"
|
||||||
static let showMenuBarIcon = "showMenuBarIcon"
|
static let showMenuBarIcon = "showMenuBarIcon"
|
||||||
static let showDockIcon = "showDockIcon"
|
static let showDockIcon = "showDockIcon"
|
||||||
|
static let panelSide = "panelSide"
|
||||||
|
static let iCloudSyncEnabled = "iCloudSyncEnabled"
|
||||||
static let openShortcut = "openShortcut"
|
static let openShortcut = "openShortcut"
|
||||||
static let settingsShortcut = "settingsShortcut"
|
static let settingsShortcut = "settingsShortcut"
|
||||||
static let ignoredApps = "ignoredApps"
|
static let ignoredApps = "ignoredApps"
|
||||||
@@ -34,26 +86,38 @@ final class SettingsModel {
|
|||||||
static let keepFirstImage = "keepFirstImage"
|
static let keepFirstImage = "keepFirstImage"
|
||||||
static let excludeSensitive = "excludeSensitive"
|
static let excludeSensitive = "excludeSensitive"
|
||||||
static let pauseCapture = "pauseCapture"
|
static let pauseCapture = "pauseCapture"
|
||||||
|
static let pauseCaptureUntil = "pauseCaptureUntil"
|
||||||
|
static let hideFromScreenCapture = "hideFromScreenCapture"
|
||||||
static let clearHistoryOnQuit = "clearHistoryOnQuit"
|
static let clearHistoryOnQuit = "clearHistoryOnQuit"
|
||||||
|
static let onboardingCompleted = "onboardingCompleted"
|
||||||
static let accessibilityNoticeShown = "accessibilityNoticeShown"
|
static let accessibilityNoticeShown = "accessibilityNoticeShown"
|
||||||
static let customCollectionNames = "customCollectionNames"
|
static let customCollectionNames = "customCollectionNames"
|
||||||
static let collectionColorHexes = "collectionColorHexes"
|
static let collectionColorHexes = "collectionColorHexes"
|
||||||
}
|
}
|
||||||
|
|
||||||
var maxHistoryItems: Int {
|
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 {
|
var defaultSortMode: ClipboardSortMode {
|
||||||
didSet { if oldValue != defaultSortMode { storeAndNotify(.other) } }
|
didSet { if oldValue != defaultSortMode { storeAndNotify(.defaultSortMode) } }
|
||||||
}
|
}
|
||||||
var imageCacheMaxBytes: Int64 {
|
var imageCacheMaxBytes: Int64 {
|
||||||
didSet { if oldValue != imageCacheMaxBytes { storeAndNotify(.imageCacheMaxBytes) } }
|
didSet {
|
||||||
|
imageCacheMaxBytes = Self.clampedImageCacheMaxBytes(imageCacheMaxBytes)
|
||||||
|
if oldValue != imageCacheMaxBytes { storeAndNotify(.imageCacheMaxBytes) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
var includeImageTextInSearch: Bool {
|
var includeImageTextInSearch: Bool {
|
||||||
didSet { if oldValue != includeImageTextInSearch { storeAndNotify(.other) } }
|
didSet { if oldValue != includeImageTextInSearch { storeAndNotify(.includeImageTextInSearch) } }
|
||||||
}
|
}
|
||||||
var pruneDuplicates: Bool {
|
var pruneDuplicates: Bool {
|
||||||
didSet { if oldValue != pruneDuplicates { storeAndNotify(.other) } }
|
didSet { if oldValue != pruneDuplicates { storeAndNotify(.pruneDuplicates) } }
|
||||||
}
|
}
|
||||||
var launchAtLogin: Bool {
|
var launchAtLogin: Bool {
|
||||||
didSet { if oldValue != launchAtLogin { storeAndNotify(.launchAtLogin) } }
|
didSet { if oldValue != launchAtLogin { storeAndNotify(.launchAtLogin) } }
|
||||||
@@ -64,6 +128,16 @@ final class SettingsModel {
|
|||||||
var showDockIcon: Bool {
|
var showDockIcon: Bool {
|
||||||
didSet { if oldValue != showDockIcon { storeAndNotify(.showDockIcon) } }
|
didSet { if oldValue != showDockIcon { storeAndNotify(.showDockIcon) } }
|
||||||
}
|
}
|
||||||
|
var panelSide: ClipboardPanelSide {
|
||||||
|
didSet { if oldValue != panelSide { storeAndNotify(.panelSide) } }
|
||||||
|
}
|
||||||
|
var iCloudSyncEnabled: Bool {
|
||||||
|
didSet {
|
||||||
|
guard oldValue != iCloudSyncEnabled else { return }
|
||||||
|
cloudSyncStatusMessage = ""
|
||||||
|
storeAndNotify(.cloudSync)
|
||||||
|
}
|
||||||
|
}
|
||||||
var openShortcut: ShortcutBinding {
|
var openShortcut: ShortcutBinding {
|
||||||
didSet { if oldValue != openShortcut { storeAndNotify(.openShortcut) } }
|
didSet { if oldValue != openShortcut { storeAndNotify(.openShortcut) } }
|
||||||
}
|
}
|
||||||
@@ -71,25 +145,37 @@ final class SettingsModel {
|
|||||||
didSet { if oldValue != settingsShortcut { storeAndNotify(.settingsShortcut) } }
|
didSet { if oldValue != settingsShortcut { storeAndNotify(.settingsShortcut) } }
|
||||||
}
|
}
|
||||||
var ignoredApps: [String] {
|
var ignoredApps: [String] {
|
||||||
didSet { if oldValue != ignoredApps { storeAndNotify(.other) } }
|
didSet { if oldValue != ignoredApps { storeAndNotify(.ignoredApps) } }
|
||||||
}
|
}
|
||||||
var ignoredItemKindsRaw: [Int] {
|
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 {
|
var pollProfileRaw: AppConfiguration.PollProfile {
|
||||||
didSet { if oldValue != pollProfileRaw { storeAndNotify(.pollProfile) } }
|
didSet { if oldValue != pollProfileRaw { storeAndNotify(.pollProfile) } }
|
||||||
}
|
}
|
||||||
var keepFirstImage: Bool {
|
var keepFirstImage: Bool {
|
||||||
didSet { if oldValue != keepFirstImage { storeAndNotify(.other) } }
|
didSet { if oldValue != keepFirstImage { storeAndNotify(.keepFirstImage) } }
|
||||||
}
|
}
|
||||||
var excludeSensitive: Bool {
|
var excludeSensitive: Bool {
|
||||||
didSet { if oldValue != excludeSensitive { storeAndNotify(.other) } }
|
didSet { if oldValue != excludeSensitive { storeAndNotify(.excludeSensitive) } }
|
||||||
}
|
}
|
||||||
var pauseCapture: Bool {
|
var pauseCapture: Bool {
|
||||||
didSet { if oldValue != pauseCapture { storeAndNotify(.pauseCapture) } }
|
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 {
|
var clearHistoryOnQuit: Bool {
|
||||||
didSet { if oldValue != clearHistoryOnQuit { storeAndNotify(.other) } }
|
didSet { if oldValue != clearHistoryOnQuit { storeAndNotify(.clearHistoryOnQuit) } }
|
||||||
}
|
}
|
||||||
private(set) var customCollectionNames: [String]
|
private(set) var customCollectionNames: [String]
|
||||||
private(set) var collectionColorHexes: [String: String]
|
private(set) var collectionColorHexes: [String: String]
|
||||||
@@ -98,6 +184,8 @@ final class SettingsModel {
|
|||||||
private(set) var captureStatusMessage: String = ""
|
private(set) var captureStatusMessage: String = ""
|
||||||
private(set) var shortcutStatusMessage: String = ""
|
private(set) var shortcutStatusMessage: String = ""
|
||||||
private(set) var pasteStatusMessage: String = ""
|
private(set) var pasteStatusMessage: String = ""
|
||||||
|
private(set) var cloudSyncStatusMessage: String = ""
|
||||||
|
private(set) var onboardingCompleted: Bool
|
||||||
private(set) var accessibilityNoticeShown: Bool
|
private(set) var accessibilityNoticeShown: Bool
|
||||||
|
|
||||||
private let defaults: UserDefaults
|
private let defaults: UserDefaults
|
||||||
@@ -107,10 +195,17 @@ final class SettingsModel {
|
|||||||
self.defaults = defaults
|
self.defaults = defaults
|
||||||
|
|
||||||
let savedHistory = defaults.integer(forKey: Keys.maxHistoryItems)
|
let savedHistory = defaults.integer(forKey: Keys.maxHistoryItems)
|
||||||
|
let savedRetention = defaults.object(forKey: Keys.historyRetention) as? Int
|
||||||
let savedSort = defaults.integer(forKey: Keys.defaultSortMode)
|
let savedSort = defaults.integer(forKey: Keys.defaultSortMode)
|
||||||
|
let savedCacheObject = defaults.object(forKey: Keys.imageCacheMaxBytes)
|
||||||
let savedCache = defaults.integer(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
|
maxHistoryItems = savedHistory > 0 ? savedHistory : AppConfiguration.defaultHistoryLength
|
||||||
|
historyRetention = savedRetention.flatMap(HistoryRetention.init(rawValue:)) ?? .oneMonth
|
||||||
defaultSortMode = ClipboardSortMode(rawValue: savedSort) ?? .mostRecent
|
defaultSortMode = ClipboardSortMode(rawValue: savedSort) ?? .mostRecent
|
||||||
imageCacheMaxBytes = savedCache > 0 ? Int64(savedCache) : AppConfiguration.defaultCacheMaxBytes
|
imageCacheMaxBytes = savedCache > 0 ? Int64(savedCache) : AppConfiguration.defaultCacheMaxBytes
|
||||||
includeImageTextInSearch = defaults.object(forKey: Keys.includeImageTextInSearch) as? Bool ?? false
|
includeImageTextInSearch = defaults.object(forKey: Keys.includeImageTextInSearch) as? Bool ?? false
|
||||||
@@ -118,30 +213,49 @@ final class SettingsModel {
|
|||||||
launchAtLogin = defaults.object(forKey: Keys.launchAtLogin) as? Bool ?? false
|
launchAtLogin = defaults.object(forKey: Keys.launchAtLogin) as? Bool ?? false
|
||||||
showMenuBarIcon = defaults.object(forKey: Keys.showMenuBarIcon) as? Bool ?? true
|
showMenuBarIcon = defaults.object(forKey: Keys.showMenuBarIcon) as? Bool ?? true
|
||||||
showDockIcon = defaults.object(forKey: Keys.showDockIcon) as? Bool ?? false
|
showDockIcon = defaults.object(forKey: Keys.showDockIcon) as? Bool ?? false
|
||||||
|
panelSide = savedPanelSide.flatMap(ClipboardPanelSide.init(rawValue:)) ?? .right
|
||||||
|
iCloudSyncEnabled = defaults.object(forKey: Keys.iCloudSyncEnabled) as? Bool ?? false
|
||||||
openShortcut = Self.readShortcut(from: defaults.string(forKey: Keys.openShortcut)) ?? AppConfiguration.defaultOpenShortcut
|
openShortcut = Self.readShortcut(from: defaults.string(forKey: Keys.openShortcut)) ?? AppConfiguration.defaultOpenShortcut
|
||||||
settingsShortcut = Self.readShortcut(from: defaults.string(forKey: Keys.settingsShortcut)) ?? AppConfiguration.defaultSettingsShortcut
|
settingsShortcut = Self.readShortcut(from: defaults.string(forKey: Keys.settingsShortcut)) ?? AppConfiguration.defaultSettingsShortcut
|
||||||
ignoredApps = defaults.stringArray(forKey: Keys.ignoredApps) ?? AppConfiguration.defaultIgnoredApps
|
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)
|
let profileValue = defaults.integer(forKey: Keys.pollProfile)
|
||||||
pollProfileRaw = AppConfiguration.PollProfile(rawValue: profileValue) ?? AppConfiguration.defaultPollProfile
|
pollProfileRaw = AppConfiguration.PollProfile(rawValue: profileValue) ?? AppConfiguration.defaultPollProfile
|
||||||
keepFirstImage = defaults.object(forKey: Keys.keepFirstImage) as? Bool ?? true
|
keepFirstImage = defaults.object(forKey: Keys.keepFirstImage) as? Bool ?? true
|
||||||
excludeSensitive = defaults.object(forKey: Keys.excludeSensitive) as? Bool ?? false
|
excludeSensitive = defaults.object(forKey: Keys.excludeSensitive) as? Bool ?? false
|
||||||
pauseCapture = defaults.object(forKey: Keys.pauseCapture) 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
|
clearHistoryOnQuit = defaults.object(forKey: Keys.clearHistoryOnQuit) as? Bool ?? false
|
||||||
customCollectionNames = Self.normalizedCollectionNames(defaults.stringArray(forKey: Keys.customCollectionNames) ?? [])
|
customCollectionNames = Self.normalizedCollectionNames(defaults.stringArray(forKey: Keys.customCollectionNames) ?? [])
|
||||||
collectionColorHexes = Self.normalizedCollectionColorHexes(defaults.dictionary(forKey: Keys.collectionColorHexes))
|
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
|
accessibilityNoticeShown = defaults.object(forKey: Keys.accessibilityNoticeShown) as? Bool ?? false
|
||||||
|
|
||||||
maxHistoryItems = max(AppConfiguration.minHistoryLength, min(AppConfiguration.maxHistoryLength, maxHistoryItems))
|
maxHistoryItems = Self.clampedMaxHistoryItems(maxHistoryItems)
|
||||||
imageCacheMaxBytes = max(4 * 1024 * 1024, imageCacheMaxBytes)
|
imageCacheMaxBytes = Self.clampedImageCacheMaxBytes(imageCacheMaxBytes)
|
||||||
|
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
|
||||||
|
|| savedPanelSide == nil
|
||||||
|
|| ClipboardPanelSide(rawValue: savedPanelSide ?? -1) == nil {
|
||||||
store()
|
store()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func store() {
|
private func store() {
|
||||||
defaults.set(maxHistoryItems, forKey: Keys.maxHistoryItems)
|
defaults.set(maxHistoryItems, forKey: Keys.maxHistoryItems)
|
||||||
|
defaults.set(historyRetention.rawValue, forKey: Keys.historyRetention)
|
||||||
defaults.set(defaultSortMode.rawValue, forKey: Keys.defaultSortMode)
|
defaults.set(defaultSortMode.rawValue, forKey: Keys.defaultSortMode)
|
||||||
defaults.set(imageCacheMaxBytes, forKey: Keys.imageCacheMaxBytes)
|
defaults.set(imageCacheMaxBytes, forKey: Keys.imageCacheMaxBytes)
|
||||||
defaults.set(includeImageTextInSearch, forKey: Keys.includeImageTextInSearch)
|
defaults.set(includeImageTextInSearch, forKey: Keys.includeImageTextInSearch)
|
||||||
@@ -149,6 +263,8 @@ final class SettingsModel {
|
|||||||
defaults.set(launchAtLogin, forKey: Keys.launchAtLogin)
|
defaults.set(launchAtLogin, forKey: Keys.launchAtLogin)
|
||||||
defaults.set(showMenuBarIcon, forKey: Keys.showMenuBarIcon)
|
defaults.set(showMenuBarIcon, forKey: Keys.showMenuBarIcon)
|
||||||
defaults.set(showDockIcon, forKey: Keys.showDockIcon)
|
defaults.set(showDockIcon, forKey: Keys.showDockIcon)
|
||||||
|
defaults.set(panelSide.rawValue, forKey: Keys.panelSide)
|
||||||
|
defaults.set(iCloudSyncEnabled, forKey: Keys.iCloudSyncEnabled)
|
||||||
defaults.set(openShortcut.encoded(), forKey: Keys.openShortcut)
|
defaults.set(openShortcut.encoded(), forKey: Keys.openShortcut)
|
||||||
defaults.set(settingsShortcut.encoded(), forKey: Keys.settingsShortcut)
|
defaults.set(settingsShortcut.encoded(), forKey: Keys.settingsShortcut)
|
||||||
defaults.set(ignoredApps, forKey: Keys.ignoredApps)
|
defaults.set(ignoredApps, forKey: Keys.ignoredApps)
|
||||||
@@ -157,7 +273,14 @@ final class SettingsModel {
|
|||||||
defaults.set(keepFirstImage, forKey: Keys.keepFirstImage)
|
defaults.set(keepFirstImage, forKey: Keys.keepFirstImage)
|
||||||
defaults.set(excludeSensitive, forKey: Keys.excludeSensitive)
|
defaults.set(excludeSensitive, forKey: Keys.excludeSensitive)
|
||||||
defaults.set(pauseCapture, forKey: Keys.pauseCapture)
|
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(clearHistoryOnQuit, forKey: Keys.clearHistoryOnQuit)
|
||||||
|
defaults.set(onboardingCompleted, forKey: Keys.onboardingCompleted)
|
||||||
defaults.set(customCollectionNames, forKey: Keys.customCollectionNames)
|
defaults.set(customCollectionNames, forKey: Keys.customCollectionNames)
|
||||||
defaults.set(collectionColorHexes, forKey: Keys.collectionColorHexes)
|
defaults.set(collectionColorHexes, forKey: Keys.collectionColorHexes)
|
||||||
}
|
}
|
||||||
@@ -201,6 +324,13 @@ final class SettingsModel {
|
|||||||
defaults.set(true, forKey: Keys.accessibilityNoticeShown)
|
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) {
|
func setShortcutStatus(message: String) {
|
||||||
guard shortcutStatusMessage != message else { return }
|
guard shortcutStatusMessage != message else { return }
|
||||||
shortcutStatusMessage = message
|
shortcutStatusMessage = message
|
||||||
@@ -213,6 +343,12 @@ final class SettingsModel {
|
|||||||
notify(.status)
|
notify(.status)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func setCloudSyncStatus(message: String) {
|
||||||
|
guard cloudSyncStatusMessage != message else { return }
|
||||||
|
cloudSyncStatusMessage = message
|
||||||
|
notify(.status)
|
||||||
|
}
|
||||||
|
|
||||||
@discardableResult
|
@discardableResult
|
||||||
func ensureCollection(named name: String, colorHex: String? = nil) -> String? {
|
func ensureCollection(named name: String, colorHex: String? = nil) -> String? {
|
||||||
guard let normalizedName = ClipboardCollectionDefaults.normalizedName(name) else { return nil }
|
guard let normalizedName = ClipboardCollectionDefaults.normalizedName(name) else { return nil }
|
||||||
@@ -334,8 +470,16 @@ final class SettingsModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func sanitizeLimits() {
|
func sanitizeLimits() {
|
||||||
maxHistoryItems = max(AppConfiguration.minHistoryLength, min(AppConfiguration.maxHistoryLength, maxHistoryItems))
|
maxHistoryItems = Self.clampedMaxHistoryItems(maxHistoryItems)
|
||||||
imageCacheMaxBytes = max(4 * 1024 * 1024, imageCacheMaxBytes)
|
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] {
|
private static func normalizedCollectionNames(_ names: [String]) -> [String] {
|
||||||
@@ -348,6 +492,25 @@ final class SettingsModel {
|
|||||||
return normalized
|
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] {
|
private static func normalizedCollectionColorHexes(_ rawValue: [String: Any]?) -> [String: String] {
|
||||||
guard let rawValue else { return [:] }
|
guard let rawValue else { return [:] }
|
||||||
var normalized: [String: String] = [:]
|
var normalized: [String: String] = [:]
|
||||||
|
|||||||
68
sources/clipbored/models/VideoPayload.swift
Normal file
68
sources/clipbored/models/VideoPayload.swift
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import AppKit
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
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: TypeIdentifier.mpeg4Movie),
|
||||||
|
NSPasteboard.PasteboardType(rawValue: TypeIdentifier.quickTimeMovie),
|
||||||
|
NSPasteboard.PasteboardType(rawValue: TypeIdentifier.movie),
|
||||||
|
NSPasteboard.PasteboardType(rawValue: TypeIdentifier.video),
|
||||||
|
NSPasteboard.PasteboardType(rawValue: "com.apple.m4v-video")
|
||||||
|
]
|
||||||
|
|
||||||
|
static func data(from pasteboard: NSPasteboard) -> (data: Data, type: NSPasteboard.PasteboardType)? {
|
||||||
|
for type in pasteboardTypes {
|
||||||
|
if let data = pasteboard.data(forType: type), !data.isEmpty {
|
||||||
|
return (data, type)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
static func fileExtension(for type: NSPasteboard.PasteboardType) -> String {
|
||||||
|
switch type.rawValue {
|
||||||
|
case TypeIdentifier.mpeg4Movie:
|
||||||
|
return "mp4"
|
||||||
|
case TypeIdentifier.quickTimeMovie, TypeIdentifier.movie, TypeIdentifier.video:
|
||||||
|
return "mov"
|
||||||
|
case "com.apple.m4v-video":
|
||||||
|
return "m4v"
|
||||||
|
default:
|
||||||
|
return "mov"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func pasteboardType(forPath path: String) -> NSPasteboard.PasteboardType {
|
||||||
|
switch URL(fileURLWithPath: path).pathExtension.lowercased() {
|
||||||
|
case "mp4":
|
||||||
|
return NSPasteboard.PasteboardType(rawValue: TypeIdentifier.mpeg4Movie)
|
||||||
|
case "m4v":
|
||||||
|
return NSPasteboard.PasteboardType(rawValue: "com.apple.m4v-video")
|
||||||
|
case "mov", "qt":
|
||||||
|
return NSPasteboard.PasteboardType(rawValue: TypeIdentifier.quickTimeMovie)
|
||||||
|
default:
|
||||||
|
return NSPasteboard.PasteboardType(rawValue: TypeIdentifier.movie)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func displayTitle(byteCount: Int) -> String {
|
||||||
|
"Video (\(ByteCountFormatter.string(fromByteCount: Int64(byteCount), countStyle: .file)))"
|
||||||
|
}
|
||||||
|
|
||||||
|
static func fileExtension(from path: String) -> String {
|
||||||
|
let value = URL(fileURLWithPath: path).pathExtension.clipboardTrimmed
|
||||||
|
return value.isEmpty ? "mov" : value.lowercased()
|
||||||
|
}
|
||||||
|
|
||||||
|
static func kindText(from path: String) -> String {
|
||||||
|
let value = fileExtension(from: path)
|
||||||
|
return value.isEmpty ? "Video" : value.uppercased()
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 6.6 KiB |
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
import AppKit
|
import AppKit
|
||||||
|
import AVFoundation
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
final class ClipboardCacheService {
|
final class ClipboardCacheService {
|
||||||
|
typealias VideoThumbnailProvider = (URL) -> NSImage?
|
||||||
|
|
||||||
private let thumbnailCache = NSCache<NSString, NSImage>()
|
private let thumbnailCache = NSCache<NSString, NSImage>()
|
||||||
private let fileManager = FileManager.default
|
private let fileManager = FileManager.default
|
||||||
private let queue = DispatchQueue(label: "clipboard.cache.service", qos: .utility)
|
private let queue = DispatchQueue(label: "clipboard.cache.service", qos: .utility)
|
||||||
@@ -9,8 +12,13 @@ final class ClipboardCacheService {
|
|||||||
private let attachmentDirectory: URL
|
private let attachmentDirectory: URL
|
||||||
private let temporaryPreviewDirectory: URL
|
private let temporaryPreviewDirectory: URL
|
||||||
private let encryptionService: ClipboardEncryptionService
|
private let encryptionService: ClipboardEncryptionService
|
||||||
|
private let videoThumbnailProvider: VideoThumbnailProvider
|
||||||
|
|
||||||
init(baseURL: URL? = nil, encryptionService: ClipboardEncryptionService = ClipboardEncryptionService()) {
|
init(
|
||||||
|
baseURL: URL? = nil,
|
||||||
|
encryptionService: ClipboardEncryptionService = ClipboardEncryptionService(),
|
||||||
|
videoThumbnailProvider: VideoThumbnailProvider? = nil
|
||||||
|
) {
|
||||||
let base = baseURL ?? ClipboardStore.storageDirectory()
|
let base = baseURL ?? ClipboardStore.storageDirectory()
|
||||||
imageDirectory = base.appendingPathComponent("images", isDirectory: true)
|
imageDirectory = base.appendingPathComponent("images", isDirectory: true)
|
||||||
attachmentDirectory = base.appendingPathComponent("attachments", isDirectory: true)
|
attachmentDirectory = base.appendingPathComponent("attachments", isDirectory: true)
|
||||||
@@ -18,12 +26,13 @@ final class ClipboardCacheService {
|
|||||||
.appendingPathComponent(AppConfiguration.appName, isDirectory: true)
|
.appendingPathComponent(AppConfiguration.appName, isDirectory: true)
|
||||||
.appendingPathComponent("Previews", isDirectory: true)
|
.appendingPathComponent("Previews", isDirectory: true)
|
||||||
self.encryptionService = encryptionService
|
self.encryptionService = encryptionService
|
||||||
|
self.videoThumbnailProvider = videoThumbnailProvider ?? Self.makeVideoThumbnail
|
||||||
thumbnailCache.countLimit = 128
|
thumbnailCache.countLimit = 128
|
||||||
try? fileManager.createDirectory(at: imageDirectory, withIntermediateDirectories: true)
|
try? fileManager.createDirectory(at: imageDirectory, withIntermediateDirectories: true)
|
||||||
try? fileManager.createDirectory(at: attachmentDirectory, withIntermediateDirectories: true)
|
try? fileManager.createDirectory(at: attachmentDirectory, withIntermediateDirectories: true)
|
||||||
hardenDirectory(imageDirectory)
|
hardenDirectory(imageDirectory)
|
||||||
hardenDirectory(attachmentDirectory)
|
hardenDirectory(attachmentDirectory)
|
||||||
clearTemporaryPreviews()
|
clearTemporaryPreviews(wait: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
func cacheImage(_ image: NSImage, id: UUID) -> (full: String, thumb: String)? {
|
func cacheImage(_ image: NSImage, id: UUID) -> (full: String, thumb: String)? {
|
||||||
@@ -48,15 +57,46 @@ final class ClipboardCacheService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func cachePDF(_ data: Data, id: UUID) -> String? {
|
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? {
|
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? {
|
||||||
|
cacheAttachmentData(data, id: id, fileExtension: fileExtension)
|
||||||
}
|
}
|
||||||
|
|
||||||
func cacheRichText(_ data: Data, id: UUID) -> String? {
|
func cacheRichText(_ data: Data, id: UUID) -> String? {
|
||||||
cacheAttachment(data, id: id, fileExtension: "rtf")
|
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? {
|
private func cacheAttachment(_ data: Data, id: UUID, fileExtension: String) -> String? {
|
||||||
@@ -105,7 +145,10 @@ final class ClipboardCacheService {
|
|||||||
case .file:
|
case .file:
|
||||||
return filePreviewThumbnail(for: item.payload)
|
return filePreviewThumbnail(for: item.payload)
|
||||||
|
|
||||||
case .text, .unknown, .audio, .richText:
|
case .video:
|
||||||
|
return videoPreviewThumbnail(for: item)
|
||||||
|
|
||||||
|
case .text, .unknown, .audio, .richText, .color, .code:
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -150,6 +193,51 @@ final class ClipboardCacheService {
|
|||||||
return image
|
return image
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func videoPreviewThumbnail(for item: ClipboardItem) -> NSImage? {
|
||||||
|
let key = NSString(string: "video-preview:\(item.id.uuidString):\(item.payload)")
|
||||||
|
if let cached = thumbnailCache.object(forKey: key) {
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
|
||||||
|
guard
|
||||||
|
let data = data(for: item.payload),
|
||||||
|
let temporaryURL = writeTemporaryCopy(
|
||||||
|
data: data,
|
||||||
|
id: item.id,
|
||||||
|
fileExtension: VideoPayload.fileExtension(from: item.payload)
|
||||||
|
)
|
||||||
|
else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
defer { removeTemporaryCopyIfPossible(temporaryURL) }
|
||||||
|
|
||||||
|
guard let image = videoThumbnailProvider(temporaryURL), hasDrawableSize(image) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
let thumbnail = image.resized(to: CGSize(width: 260, height: 132))
|
||||||
|
thumbnailCache.setObject(thumbnail, forKey: key)
|
||||||
|
return thumbnail
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func makeVideoThumbnail(from url: URL) -> NSImage? {
|
||||||
|
let asset = AVURLAsset(url: url)
|
||||||
|
let generator = AVAssetImageGenerator(asset: asset)
|
||||||
|
generator.appliesPreferredTrackTransform = true
|
||||||
|
generator.maximumSize = CGSize(width: 520, height: 264)
|
||||||
|
generator.requestedTimeToleranceBefore = .zero
|
||||||
|
generator.requestedTimeToleranceAfter = CMTime(value: 1, timescale: 30)
|
||||||
|
|
||||||
|
for time in [CMTime(seconds: 0.08, preferredTimescale: 600), .zero] {
|
||||||
|
if let cgImage = try? generator.copyCGImage(at: time, actualTime: nil) {
|
||||||
|
return NSImage(
|
||||||
|
cgImage: cgImage,
|
||||||
|
size: NSSize(width: CGFloat(cgImage.width), height: CGFloat(cgImage.height))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
private func hasDrawableSize(_ image: NSImage) -> Bool {
|
private func hasDrawableSize(_ image: NSImage) -> Bool {
|
||||||
image.size.width > 0 && image.size.height > 0
|
image.size.width > 0 && image.size.height > 0
|
||||||
}
|
}
|
||||||
@@ -174,6 +262,9 @@ final class ClipboardCacheService {
|
|||||||
case .audio:
|
case .audio:
|
||||||
guard let data = data(for: item.payload) else { return nil }
|
guard let data = data(for: item.payload) else { return nil }
|
||||||
return writeTemporaryCopy(data: data, id: item.id, fileExtension: "sound")
|
return writeTemporaryCopy(data: data, id: item.id, fileExtension: "sound")
|
||||||
|
case .video:
|
||||||
|
guard let data = data(for: item.payload) else { return nil }
|
||||||
|
return writeTemporaryCopy(data: data, id: item.id, fileExtension: VideoPayload.fileExtension(from: item.payload))
|
||||||
case .richText:
|
case .richText:
|
||||||
guard let data = data(for: item.payload) else { return nil }
|
guard let data = data(for: item.payload) else { return nil }
|
||||||
return writeTemporaryCopy(data: data, id: item.id, fileExtension: "rtf")
|
return writeTemporaryCopy(data: data, id: item.id, fileExtension: "rtf")
|
||||||
@@ -187,14 +278,17 @@ final class ClipboardCacheService {
|
|||||||
case .file:
|
case .file:
|
||||||
let urls = FilePayload.urls(from: item.payload)
|
let urls = FilePayload.urls(from: item.payload)
|
||||||
return urls.first { fileManager.fileExists(atPath: $0.path) }
|
return urls.first { fileManager.fileExists(atPath: $0.path) }
|
||||||
case .text, .unknown:
|
case .text, .code, .unknown:
|
||||||
let text = item.payload.clipboardTrimmed.isEmpty ? item.displayText : item.payload
|
let text = item.payload.clipboardTrimmed.isEmpty ? item.displayText : item.payload
|
||||||
guard !text.clipboardTrimmed.isEmpty else { return nil }
|
guard !text.clipboardTrimmed.isEmpty else { return nil }
|
||||||
return writeTemporaryCopy(data: Data(text.utf8), id: item.id, fileExtension: "txt")
|
return writeTemporaryCopy(data: Data(text.utf8), id: item.id, fileExtension: "txt")
|
||||||
|
case .color:
|
||||||
|
let text = ColorPayload.previewText(from: item.payload)
|
||||||
|
return writeTemporaryCopy(data: Data(text.utf8), id: item.id, fileExtension: "txt")
|
||||||
case .url:
|
case .url:
|
||||||
guard let data = webLocationData(for: item.payload) else { return nil }
|
guard let data = webLocationData(for: item.payload) else { return nil }
|
||||||
return writeTemporaryCopy(data: data, id: item.id, fileExtension: "webloc")
|
return writeTemporaryCopy(data: data, id: item.id, fileExtension: "webloc")
|
||||||
case .image, .pdf, .audio, .richText:
|
case .image, .pdf, .audio, .richText, .video:
|
||||||
return temporaryReadableURL(for: item)
|
return temporaryReadableURL(for: item)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -209,7 +303,7 @@ final class ClipboardCacheService {
|
|||||||
if let thumbnailPath = item.thumbnailPath {
|
if let thumbnailPath = item.thumbnailPath {
|
||||||
_ = self.data(for: thumbnailPath)
|
_ = self.data(for: thumbnailPath)
|
||||||
}
|
}
|
||||||
if (item.kind == .pdf || item.kind == .audio || item.kind == .richText), self.isManagedAttachment(path: item.payload) {
|
if (item.kind == .pdf || item.kind == .audio || item.kind == .richText || item.kind == .video), self.isManagedAttachment(path: item.payload) {
|
||||||
_ = self.data(for: item.payload)
|
_ = self.data(for: item.payload)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -227,7 +321,7 @@ final class ClipboardCacheService {
|
|||||||
try? self.fileManager.removeItem(atPath: path)
|
try? self.fileManager.removeItem(atPath: path)
|
||||||
self.thumbnailCache.removeObject(forKey: NSString(string: path))
|
self.thumbnailCache.removeObject(forKey: NSString(string: path))
|
||||||
}
|
}
|
||||||
if (item.kind == .pdf || item.kind == .audio || item.kind == .richText), self.isManagedAttachment(path: item.payload) {
|
if (item.kind == .pdf || item.kind == .audio || item.kind == .richText || item.kind == .video), self.isManagedAttachment(path: item.payload) {
|
||||||
try? self.fileManager.removeItem(atPath: item.payload)
|
try? self.fileManager.removeItem(atPath: item.payload)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -235,7 +329,6 @@ final class ClipboardCacheService {
|
|||||||
|
|
||||||
func purgeIfNeeded(maxBytes: Int64) {
|
func purgeIfNeeded(maxBytes: Int64) {
|
||||||
queue.async {
|
queue.async {
|
||||||
DiagnosticsService.shared.incrementCachePurge()
|
|
||||||
let urls = (try? self.fileManager.contentsOfDirectory(at: self.imageDirectory, includingPropertiesForKeys: nil, options: [])) ?? []
|
let urls = (try? self.fileManager.contentsOfDirectory(at: self.imageDirectory, includingPropertiesForKeys: nil, options: [])) ?? []
|
||||||
var items: [(url: URL, size: Int64, date: Date)] = []
|
var items: [(url: URL, size: Int64, date: Date)] = []
|
||||||
var totalSize: Int64 = 0
|
var totalSize: Int64 = 0
|
||||||
@@ -337,6 +430,13 @@ final class ClipboardCacheService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func removeTemporaryCopyIfPossible(_ url: URL) {
|
||||||
|
try? fileManager.removeItem(at: url)
|
||||||
|
if ((try? fileManager.contentsOfDirectory(at: temporaryPreviewDirectory, includingPropertiesForKeys: nil)) ?? []).isEmpty {
|
||||||
|
try? fileManager.removeItem(at: temporaryPreviewDirectory)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func webLocationData(for value: String) -> Data? {
|
private func webLocationData(for value: String) -> Data? {
|
||||||
let trimmed = value.clipboardTrimmed
|
let trimmed = value.clipboardTrimmed
|
||||||
guard !trimmed.isEmpty else { return nil }
|
guard !trimmed.isEmpty else { return nil }
|
||||||
|
|||||||
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,9 +20,14 @@ final class ClipboardEncryptionService {
|
|||||||
private let resetProvider: () -> Void
|
private let resetProvider: () -> Void
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
|
if Self.shouldBypassSystemKeychain() {
|
||||||
|
keyProvider = { nil }
|
||||||
|
resetProvider = {}
|
||||||
|
} else {
|
||||||
keyProvider = { ClipboardEncryptionKeychain.shared.symmetricKey() }
|
keyProvider = { ClipboardEncryptionKeychain.shared.symmetricKey() }
|
||||||
resetProvider = { ClipboardEncryptionKeychain.shared.resetStoredKey() }
|
resetProvider = { ClipboardEncryptionKeychain.shared.resetStoredKey() }
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
init(keyProvider: @escaping () -> SymmetricKey?, resetProvider: @escaping () -> Void = {}) {
|
init(keyProvider: @escaping () -> SymmetricKey?, resetProvider: @escaping () -> Void = {}) {
|
||||||
self.keyProvider = keyProvider
|
self.keyProvider = keyProvider
|
||||||
@@ -102,6 +107,20 @@ final class ClipboardEncryptionService {
|
|||||||
func resetStoredKey() {
|
func resetStoredKey() {
|
||||||
resetProvider()
|
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 {
|
private enum ClipboardEncryptionKeychain {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ final class ClipboardMonitorService {
|
|||||||
private var scheduledInterval: TimeInterval = 0
|
private var scheduledInterval: TimeInterval = 0
|
||||||
private var didReportReadFailure = false
|
private var didReportReadFailure = false
|
||||||
private(set) var isPaused = false
|
private(set) var isPaused = false
|
||||||
|
var onCapturedItem: (ClipboardItem) -> Void = { _ in }
|
||||||
|
|
||||||
init(
|
init(
|
||||||
store: ClipboardStore,
|
store: ClipboardStore,
|
||||||
@@ -107,7 +108,6 @@ final class ClipboardMonitorService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func tick() {
|
private func tick() {
|
||||||
DiagnosticsService.shared.incrementMonitorTick()
|
|
||||||
pollPasteboard(rescheduleAfterCapture: true)
|
pollPasteboard(rescheduleAfterCapture: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,13 +137,14 @@ final class ClipboardMonitorService {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
DiagnosticsService.shared.incrementPasteboardChange()
|
|
||||||
|
|
||||||
didReportReadFailure = false
|
didReportReadFailure = false
|
||||||
if let item = readCurrentItem(from: pasteboard) {
|
if let item = readCurrentItem(from: pasteboard) {
|
||||||
reportCaptured(item)
|
reportCaptured(item)
|
||||||
DispatchQueue.main.async { [weak self] in
|
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 {
|
} else if !didReportReadFailure {
|
||||||
reportCaptureStatus("Clipboard changed, but ClipBored could not read a supported item.")
|
reportCaptureStatus("Clipboard changed, but ClipBored could not read a supported item.")
|
||||||
@@ -159,7 +160,6 @@ final class ClipboardMonitorService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func readCurrentItem(from pasteboard: NSPasteboard) -> ClipboardItem? {
|
private func readCurrentItem(from pasteboard: NSPasteboard) -> ClipboardItem? {
|
||||||
DiagnosticsService.shared.incrementExtractionAttempt()
|
|
||||||
let source = frontmostApp()
|
let source = frontmostApp()
|
||||||
|
|
||||||
func isIgnored(_ kind: ClipboardItemKind) -> Bool {
|
func isIgnored(_ kind: ClipboardItemKind) -> Bool {
|
||||||
@@ -195,6 +195,15 @@ final class ClipboardMonitorService {
|
|||||||
return itemFromURL(url.url, title: url.title, sourceApp: source.name, sourceBundleId: source.bundleId, previewPasteboard: pasteboard)
|
return itemFromURL(url.url, title: url.title, sourceApp: source.name, sourceBundleId: source.bundleId, previewPasteboard: pasteboard)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if isIgnored(.color), hasColor(on: pasteboard) {
|
||||||
|
reportReadFailureStatus(ignoredKindMessage(.color))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if let colorItem = itemFromColor(pasteboard, sourceApp: source.name, sourceBundleId: source.bundleId) {
|
||||||
|
return colorItem
|
||||||
|
}
|
||||||
|
|
||||||
if isIgnored(.image), hasImage(on: pasteboard) {
|
if isIgnored(.image), hasImage(on: pasteboard) {
|
||||||
reportReadFailureStatus(ignoredKindMessage(.image))
|
reportReadFailureStatus(ignoredKindMessage(.image))
|
||||||
return nil
|
return nil
|
||||||
@@ -213,6 +222,15 @@ final class ClipboardMonitorService {
|
|||||||
return pdfItem
|
return pdfItem
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if isIgnored(.video), hasVideo(on: pasteboard) {
|
||||||
|
reportReadFailureStatus(ignoredKindMessage(.video))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if let videoItem = itemFromVideo(pasteboard, sourceApp: source.name, sourceBundleId: source.bundleId) {
|
||||||
|
return videoItem
|
||||||
|
}
|
||||||
|
|
||||||
if isIgnored(.audio), hasAudio(on: pasteboard) {
|
if isIgnored(.audio), hasAudio(on: pasteboard) {
|
||||||
reportReadFailureStatus(ignoredKindMessage(.audio))
|
reportReadFailureStatus(ignoredKindMessage(.audio))
|
||||||
return nil
|
return nil
|
||||||
@@ -245,17 +263,23 @@ final class ClipboardMonitorService {
|
|||||||
return htmlPayload
|
return htmlPayload
|
||||||
}
|
}
|
||||||
|
|
||||||
if isIgnored(.text), let string = pasteboard.string(forType: .string) {
|
if let string = pasteboard.string(forType: .string) {
|
||||||
let trimmed = string.clipboardTrimmed
|
let trimmed = string.clipboardTrimmed
|
||||||
if !trimmed.isEmpty {
|
if !trimmed.isEmpty {
|
||||||
|
if CodeSnippetPayload.isLikelyCode(trimmed), isIgnored(.code) {
|
||||||
|
reportReadFailureStatus(ignoredKindMessage(.code))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !CodeSnippetPayload.isLikelyCode(trimmed), isIgnored(.text) {
|
||||||
reportReadFailureStatus(ignoredKindMessage(.text))
|
reportReadFailureStatus(ignoredKindMessage(.text))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if let string = pasteboard.string(forType: .string),
|
if let string = pasteboard.string(forType: .string),
|
||||||
let item = itemFromString(string, sourceApp: source.name, sourceBundleId: source.bundleId) {
|
let item = itemFromString(string, sourceApp: source.name, sourceBundleId: source.bundleId) {
|
||||||
if item.kind == .text, item.payload.isEmpty {
|
if (item.kind == .text || item.kind == .code), item.payload.isEmpty {
|
||||||
reportReadFailureStatus("Clipboard contains no readable text.")
|
reportReadFailureStatus("Clipboard contains no readable text.")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -296,6 +320,24 @@ final class ClipboardMonitorService {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if CodeSnippetPayload.isLikelyCode(trimmed) {
|
||||||
|
return ClipboardItem(
|
||||||
|
id: UUID(),
|
||||||
|
kind: .code,
|
||||||
|
displayText: CodeSnippetPayload.title(from: trimmed),
|
||||||
|
payload: trimmed,
|
||||||
|
payloadHash: store.hashString(trimmed),
|
||||||
|
createdAt: Date(),
|
||||||
|
lastUsedAt: Date(),
|
||||||
|
useCount: 1,
|
||||||
|
sourceApp: sourceApp,
|
||||||
|
imagePath: nil,
|
||||||
|
thumbnailPath: nil,
|
||||||
|
isPinned: false,
|
||||||
|
sourceAppBundleId: sourceBundleId
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return ClipboardItem(
|
return ClipboardItem(
|
||||||
id: UUID(),
|
id: UUID(),
|
||||||
kind: .text,
|
kind: .text,
|
||||||
@@ -389,10 +431,7 @@ final class ClipboardMonitorService {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
let normalized = text
|
return ImageTextExtractor.normalizedRecognizedText(text)
|
||||||
.split(whereSeparator: \.isWhitespace)
|
|
||||||
.joined(separator: " ")
|
|
||||||
return String(normalized.prefix(AppConfiguration.maxRecognizedImageTextLength))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func hasImage(on pasteboard: NSPasteboard) -> Bool {
|
private func hasImage(on pasteboard: NSPasteboard) -> Bool {
|
||||||
@@ -407,6 +446,14 @@ final class ClipboardMonitorService {
|
|||||||
pasteboard.data(forType: .sound) != nil
|
pasteboard.data(forType: .sound) != nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func hasVideo(on pasteboard: NSPasteboard) -> Bool {
|
||||||
|
VideoPayload.data(from: pasteboard) != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func hasColor(on pasteboard: NSPasteboard) -> Bool {
|
||||||
|
NSColor(from: pasteboard) != nil
|
||||||
|
}
|
||||||
|
|
||||||
private func hasFileItems(on pasteboard: NSPasteboard) -> Bool {
|
private func hasFileItems(on pasteboard: NSPasteboard) -> Bool {
|
||||||
guard let urls = pasteboard.readObjects(forClasses: [NSURL.self], options: nil) as? [URL], !urls.isEmpty else {
|
guard let urls = pasteboard.readObjects(forClasses: [NSURL.self], options: nil) as? [URL], !urls.isEmpty else {
|
||||||
return false
|
return false
|
||||||
@@ -474,6 +521,56 @@ final class ClipboardMonitorService {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func itemFromVideo(_ pasteboard: NSPasteboard, sourceApp: String?, sourceBundleId: String?) -> ClipboardItem? {
|
||||||
|
guard let video = VideoPayload.data(from: pasteboard) else { return nil }
|
||||||
|
let id = UUID()
|
||||||
|
let hash = store.hashString(video.data.base64EncodedString())
|
||||||
|
guard let path = cacheService.cacheVideo(video.data, id: id, fileExtension: VideoPayload.fileExtension(for: video.type)) else {
|
||||||
|
reportReadFailureStatus("Failed to cache video for clipboard history.")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return ClipboardItem(
|
||||||
|
id: id,
|
||||||
|
kind: .video,
|
||||||
|
displayText: VideoPayload.displayTitle(byteCount: video.data.count),
|
||||||
|
payload: path,
|
||||||
|
payloadHash: hash,
|
||||||
|
createdAt: Date(),
|
||||||
|
lastUsedAt: Date(),
|
||||||
|
useCount: 1,
|
||||||
|
sourceApp: sourceApp,
|
||||||
|
imagePath: nil,
|
||||||
|
thumbnailPath: nil,
|
||||||
|
isPinned: false,
|
||||||
|
sourceAppBundleId: sourceBundleId
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func itemFromColor(_ pasteboard: NSPasteboard, sourceApp: String?, sourceBundleId: String?) -> ClipboardItem? {
|
||||||
|
guard let color = NSColor(from: pasteboard) else { return nil }
|
||||||
|
guard let hex = ColorPayload.hexString(from: color) else {
|
||||||
|
reportReadFailureStatus("Clipboard color is present but could not be decoded.")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return ClipboardItem(
|
||||||
|
id: UUID(),
|
||||||
|
kind: .color,
|
||||||
|
displayText: hex,
|
||||||
|
payload: hex,
|
||||||
|
payloadHash: store.hashString(hex),
|
||||||
|
createdAt: Date(),
|
||||||
|
lastUsedAt: Date(),
|
||||||
|
useCount: 1,
|
||||||
|
sourceApp: sourceApp,
|
||||||
|
imagePath: nil,
|
||||||
|
thumbnailPath: nil,
|
||||||
|
isPinned: false,
|
||||||
|
sourceAppBundleId: sourceBundleId
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private func itemFromRichText(_ pasteboard: NSPasteboard, sourceApp: String?, sourceBundleId: String?) -> ClipboardItem? {
|
private func itemFromRichText(_ pasteboard: NSPasteboard, sourceApp: String?, sourceBundleId: String?) -> ClipboardItem? {
|
||||||
guard let data = pasteboard.data(forType: .rtf),
|
guard let data = pasteboard.data(forType: .rtf),
|
||||||
let attributed = NSAttributedString(rtf: data, documentAttributes: nil)
|
let attributed = NSAttributedString(rtf: data, documentAttributes: nil)
|
||||||
|
|||||||
@@ -4,6 +4,22 @@ import Foundation
|
|||||||
import CommonCrypto
|
import CommonCrypto
|
||||||
import SQLite3
|
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 {
|
final class ClipboardStore {
|
||||||
private(set) var items: [ClipboardItem] = [] {
|
private(set) var items: [ClipboardItem] = [] {
|
||||||
didSet { notifyItemsChanged() }
|
didSet { notifyItemsChanged() }
|
||||||
@@ -62,18 +78,17 @@ final class ClipboardStore {
|
|||||||
return base
|
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 {
|
guard let index = items.firstIndex(where: { settings.pruneDuplicates ? $0.payloadHash == incoming.payloadHash : false }) else {
|
||||||
insertNewItem(incoming)
|
return insertNewItem(incoming)
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if settings.keepFirstImage, incoming.kind == .image {
|
if settings.keepFirstImage, incoming.kind == .image {
|
||||||
updateExistingKeepImage(incoming, at: index)
|
return updateExistingKeepImage(incoming, at: index)
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
updateExistingItem(incoming, at: index)
|
return updateExistingItem(incoming, at: index)
|
||||||
}
|
}
|
||||||
|
|
||||||
func markUsed(_ id: UUID) {
|
func markUsed(_ id: UUID) {
|
||||||
@@ -98,7 +113,11 @@ final class ClipboardStore {
|
|||||||
func setCollection(_ id: UUID, name: String?) {
|
func setCollection(_ id: UUID, name: String?) {
|
||||||
guard let index = items.firstIndex(where: { $0.id == id }) else { return }
|
guard let index = items.firstIndex(where: { $0.id == id }) else { return }
|
||||||
items[index].collectionName = ClipboardCollectionDefaults.normalizedName(name)
|
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?) {
|
func setCustomTitle(_ id: UUID, title: String?) {
|
||||||
@@ -111,7 +130,7 @@ final class ClipboardStore {
|
|||||||
func updateText(_ id: UUID, text: String) -> Bool {
|
func updateText(_ id: UUID, text: String) -> Bool {
|
||||||
guard !text.isEmpty,
|
guard !text.isEmpty,
|
||||||
let index = items.firstIndex(where: { $0.id == id }),
|
let index = items.firstIndex(where: { $0.id == id }),
|
||||||
items[index].kind == .text else {
|
items[index].kind == .text || items[index].kind == .code else {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,21 +142,74 @@ final class ClipboardStore {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
func remove(_ id: UUID) {
|
@discardableResult
|
||||||
guard let index = items.firstIndex(where: { $0.id == id }) else { return }
|
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)
|
let removed = items.remove(at: index)
|
||||||
if removed.kind.hasManagedCacheReference {
|
if purgeManagedCache {
|
||||||
cacheService.removeCachedReferences(removed)
|
purgeManagedCacheReferences(for: [removed])
|
||||||
}
|
}
|
||||||
persistAsync(.delete(id))
|
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() {
|
func removeAll() {
|
||||||
for item in items {
|
purgeManagedCacheReferences(for: items)
|
||||||
if item.kind.hasManagedCacheReference {
|
|
||||||
cacheService.removeCachedReferences(item)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
items.removeAll()
|
items.removeAll()
|
||||||
persistAsync(.deleteAll)
|
persistAsync(.deleteAll)
|
||||||
}
|
}
|
||||||
@@ -155,27 +227,9 @@ final class ClipboardStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func normalizeHistoryLength() {
|
func normalizeHistoryLength() {
|
||||||
var pinnedCount = 0
|
let plan = retentionPlan(for: items)
|
||||||
var unpinnedCount = 0
|
let kept = plan.kept
|
||||||
var kept: [ClipboardItem] = []
|
let overflow = plan.overflow
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
guard !overflow.isEmpty else { return }
|
guard !overflow.isEmpty else { return }
|
||||||
|
|
||||||
@@ -199,15 +253,80 @@ final class ClipboardStore {
|
|||||||
dataQueue.sync {}
|
dataQueue.sync {}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func insertNewItem(_ incoming: ClipboardItem) {
|
@discardableResult
|
||||||
items.insert(incoming, at: 0)
|
func exportArchive(to url: URL) throws -> ClipboardArchiveSummary {
|
||||||
normalizeHistoryLength()
|
dataQueue.sync {}
|
||||||
persistAsync(.upsert(incoming), purgeCache: incoming.imagePath != nil)
|
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)
|
cacheService.removeCachedReferences(incoming)
|
||||||
var existing = items.remove(at: index)
|
var existing = items.remove(at: index)
|
||||||
|
existing.createdAt = Date()
|
||||||
existing.lastUsedAt = Date()
|
existing.lastUsedAt = Date()
|
||||||
existing.useCount += 1
|
existing.useCount += 1
|
||||||
if !incoming.displayText.isEmpty {
|
if !incoming.displayText.isEmpty {
|
||||||
@@ -215,15 +334,22 @@ final class ClipboardStore {
|
|||||||
}
|
}
|
||||||
existing.sourceApp = incoming.sourceApp
|
existing.sourceApp = incoming.sourceApp
|
||||||
existing.sourceAppBundleId = incoming.sourceAppBundleId
|
existing.sourceAppBundleId = incoming.sourceAppBundleId
|
||||||
|
existing.sourceDeviceName = incoming.sourceDeviceName
|
||||||
|
existing.collectionName = incoming.collectionName ?? existing.collectionName
|
||||||
existing.customTitle = incoming.customTitle ?? existing.customTitle
|
existing.customTitle = incoming.customTitle ?? existing.customTitle
|
||||||
items.insert(existing, at: 0)
|
items.insert(existing, at: 0)
|
||||||
normalizeHistoryLength()
|
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)
|
var existing = items.remove(at: index)
|
||||||
let previousCachedItem = existing
|
let previousCachedItem = existing
|
||||||
|
existing.createdAt = Date()
|
||||||
existing.lastUsedAt = Date()
|
existing.lastUsedAt = Date()
|
||||||
existing.useCount += 1
|
existing.useCount += 1
|
||||||
if !incoming.displayText.isEmpty {
|
if !incoming.displayText.isEmpty {
|
||||||
@@ -234,6 +360,8 @@ final class ClipboardStore {
|
|||||||
existing.kind = incoming.kind
|
existing.kind = incoming.kind
|
||||||
existing.sourceApp = incoming.sourceApp
|
existing.sourceApp = incoming.sourceApp
|
||||||
existing.sourceAppBundleId = incoming.sourceAppBundleId
|
existing.sourceAppBundleId = incoming.sourceAppBundleId
|
||||||
|
existing.sourceDeviceName = incoming.sourceDeviceName
|
||||||
|
existing.collectionName = incoming.collectionName ?? existing.collectionName
|
||||||
existing.customTitle = incoming.customTitle ?? existing.customTitle
|
existing.customTitle = incoming.customTitle ?? existing.customTitle
|
||||||
|
|
||||||
if incoming.kind == .image || incoming.kind == .url {
|
if incoming.kind == .image || incoming.kind == .url {
|
||||||
@@ -252,7 +380,94 @@ final class ClipboardStore {
|
|||||||
|
|
||||||
items.insert(existing, at: 0)
|
items.insert(existing, at: 0)
|
||||||
normalizeHistoryLength()
|
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) {
|
private func persistAsync(_ mutation: PersistenceMutation, purgeCache: Bool = false) {
|
||||||
@@ -341,7 +556,8 @@ final class ClipboardStore {
|
|||||||
is_pinned INTEGER NOT NULL DEFAULT 0,
|
is_pinned INTEGER NOT NULL DEFAULT 0,
|
||||||
ocr_text TEXT,
|
ocr_text TEXT,
|
||||||
collection_name TEXT,
|
collection_name TEXT,
|
||||||
custom_title TEXT
|
custom_title TEXT,
|
||||||
|
source_device_name TEXT
|
||||||
);
|
);
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -357,6 +573,7 @@ final class ClipboardStore {
|
|||||||
_ = execute(createTable)
|
_ = execute(createTable)
|
||||||
_ = execute("ALTER TABLE clipboard_items ADD COLUMN collection_name TEXT;")
|
_ = 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 custom_title TEXT;")
|
||||||
|
_ = execute("ALTER TABLE clipboard_items ADD COLUMN source_device_name TEXT;")
|
||||||
_ = execute(createIndexes)
|
_ = execute(createIndexes)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -422,7 +639,8 @@ final class ClipboardStore {
|
|||||||
sourceAppBundleId: row["sourceAppBundleId"] as? String,
|
sourceAppBundleId: row["sourceAppBundleId"] as? String,
|
||||||
ocrText: row["ocrText"] as? String,
|
ocrText: row["ocrText"] as? String,
|
||||||
collectionName: row["collectionName"] as? String,
|
collectionName: row["collectionName"] as? String,
|
||||||
customTitle: row["customTitle"] as? String
|
customTitle: row["customTitle"] as? String,
|
||||||
|
sourceDeviceName: row["sourceDeviceName"] as? String ?? ClipboardItem.localDeviceName
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -441,78 +659,22 @@ final class ClipboardStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func legacyISO8601Date(_ string: String) -> Date? {
|
private func legacyISO8601Date(_ string: String) -> Date? {
|
||||||
string.withCString { pointer -> Date? in
|
let value = string.replacingOccurrences(of: " ", with: "T")
|
||||||
let byteCount = strlen(pointer)
|
return Self.legacyDateFormatters.lazy.compactMap { $0.date(from: value) }.first
|
||||||
guard byteCount >= 20,
|
|
||||||
byte(pointer, 4) == 45,
|
|
||||||
byte(pointer, 7) == 45,
|
|
||||||
byte(pointer, 10) == 84 || byte(pointer, 10) == 32,
|
|
||||||
byte(pointer, 13) == 58,
|
|
||||||
byte(pointer, 16) == 58,
|
|
||||||
let year = decimal(pointer, byteCount, 0, 4),
|
|
||||||
let month = decimal(pointer, byteCount, 5, 2),
|
|
||||||
let day = decimal(pointer, byteCount, 8, 2),
|
|
||||||
let hour = decimal(pointer, byteCount, 11, 2),
|
|
||||||
let minute = decimal(pointer, byteCount, 14, 2),
|
|
||||||
let second = decimal(pointer, byteCount, 17, 2)
|
|
||||||
else {
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var cursor = 19
|
private static let legacyDateFormatters: [ISO8601DateFormatter] = {
|
||||||
var fraction = 0.0
|
let formats: [ISO8601DateFormatter.Options] = [
|
||||||
if cursor < byteCount, byte(pointer, cursor) == 46 {
|
[.withInternetDateTime, .withFractionalSeconds],
|
||||||
cursor += 1
|
[.withInternetDateTime]
|
||||||
var scale = 0.1
|
]
|
||||||
while cursor < byteCount {
|
return formats.map { options in
|
||||||
let digit = byte(pointer, cursor)
|
let formatter = ISO8601DateFormatter()
|
||||||
guard digit >= 48, digit <= 57 else { break }
|
formatter.formatOptions = options
|
||||||
fraction += Double(digit - 48) * scale
|
return formatter
|
||||||
scale /= 10
|
|
||||||
cursor += 1
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
var offset = 0
|
|
||||||
if cursor < byteCount, byte(pointer, cursor) == 90 {
|
|
||||||
offset = 0
|
|
||||||
} else if cursor + 5 < byteCount, byte(pointer, cursor) == 43 || byte(pointer, cursor) == 45 {
|
|
||||||
let sign = byte(pointer, cursor) == 43 ? 1 : -1
|
|
||||||
guard let offsetHour = decimal(pointer, byteCount, cursor + 1, 2),
|
|
||||||
let offsetMinute = decimal(pointer, byteCount, cursor + 4, 2)
|
|
||||||
else { return nil }
|
|
||||||
offset = sign * ((offsetHour * 3600) + (offsetMinute * 60))
|
|
||||||
}
|
|
||||||
|
|
||||||
var components = tm()
|
|
||||||
components.tm_year = Int32(year - 1900)
|
|
||||||
components.tm_mon = Int32(month - 1)
|
|
||||||
components.tm_mday = Int32(day)
|
|
||||||
components.tm_hour = Int32(hour)
|
|
||||||
components.tm_min = Int32(minute)
|
|
||||||
components.tm_sec = Int32(second)
|
|
||||||
components.tm_isdst = 0
|
|
||||||
|
|
||||||
let epoch = timegm(&components)
|
|
||||||
guard epoch >= 0 else { return nil }
|
|
||||||
return Date(timeIntervalSince1970: TimeInterval(epoch - time_t(offset)) + fraction)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func decimal(_ pointer: UnsafePointer<CChar>, _ byteCount: Int, _ start: Int, _ length: Int) -> Int? {
|
|
||||||
guard start + length <= byteCount else { return nil }
|
|
||||||
var result = 0
|
|
||||||
for index in start..<(start + length) {
|
|
||||||
let digit = byte(pointer, index)
|
|
||||||
guard digit >= 48, digit <= 57 else { return nil }
|
|
||||||
result = (result * 10) + Int(digit - 48)
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
private func byte(_ pointer: UnsafePointer<CChar>, _ index: Int) -> UInt8 {
|
|
||||||
UInt8(bitPattern: pointer[index])
|
|
||||||
}
|
|
||||||
|
|
||||||
private func isDatabaseEmpty() -> Bool {
|
private func isDatabaseEmpty() -> Bool {
|
||||||
guard let db else { return true }
|
guard let db else { return true }
|
||||||
@@ -535,7 +697,7 @@ final class ClipboardStore {
|
|||||||
id, kind, display_text, payload, payload_hash, created_at,
|
id, kind, display_text, payload, payload_hash, created_at,
|
||||||
last_used_at, use_count, source_app, source_app_bundle_id,
|
last_used_at, use_count, source_app, source_app_bundle_id,
|
||||||
image_path, thumbnail_path, is_pinned, ocr_text, collection_name,
|
image_path, thumbnail_path, is_pinned, ocr_text, collection_name,
|
||||||
custom_title
|
custom_title, source_device_name
|
||||||
FROM clipboard_items
|
FROM clipboard_items
|
||||||
ORDER BY created_at DESC, last_used_at DESC
|
ORDER BY created_at DESC, last_used_at DESC
|
||||||
"""
|
"""
|
||||||
@@ -617,6 +779,7 @@ final class ClipboardStore {
|
|||||||
let ocrTextValue = stringValue(13)
|
let ocrTextValue = stringValue(13)
|
||||||
let collectionNameValue = stringValue(14)
|
let collectionNameValue = stringValue(14)
|
||||||
let customTitleValue = stringValue(15)
|
let customTitleValue = stringValue(15)
|
||||||
|
let sourceDeviceNameValue = stringValue(16)
|
||||||
|
|
||||||
needsEncryptionMigration = needsEncryptionMigration
|
needsEncryptionMigration = needsEncryptionMigration
|
||||||
|| sourceAppValue.migrationNeeded
|
|| sourceAppValue.migrationNeeded
|
||||||
@@ -626,6 +789,7 @@ final class ClipboardStore {
|
|||||||
|| ocrTextValue.migrationNeeded
|
|| ocrTextValue.migrationNeeded
|
||||||
|| collectionNameValue.migrationNeeded
|
|| collectionNameValue.migrationNeeded
|
||||||
|| customTitleValue.migrationNeeded
|
|| customTitleValue.migrationNeeded
|
||||||
|
|| sourceDeviceNameValue.migrationNeeded
|
||||||
hadDecodeFailure = hadDecodeFailure
|
hadDecodeFailure = hadDecodeFailure
|
||||||
|| sourceAppValue.decodeFailed
|
|| sourceAppValue.decodeFailed
|
||||||
|| sourceAppBundleIdValue.decodeFailed
|
|| sourceAppBundleIdValue.decodeFailed
|
||||||
@@ -634,6 +798,7 @@ final class ClipboardStore {
|
|||||||
|| ocrTextValue.decodeFailed
|
|| ocrTextValue.decodeFailed
|
||||||
|| collectionNameValue.decodeFailed
|
|| collectionNameValue.decodeFailed
|
||||||
|| customTitleValue.decodeFailed
|
|| customTitleValue.decodeFailed
|
||||||
|
|| sourceDeviceNameValue.decodeFailed
|
||||||
|
|
||||||
loaded.append(
|
loaded.append(
|
||||||
ClipboardItem(
|
ClipboardItem(
|
||||||
@@ -652,7 +817,8 @@ final class ClipboardStore {
|
|||||||
sourceAppBundleId: sourceAppBundleIdValue.value,
|
sourceAppBundleId: sourceAppBundleIdValue.value,
|
||||||
ocrText: ocrTextValue.value,
|
ocrText: ocrTextValue.value,
|
||||||
collectionName: collectionNameValue.value,
|
collectionName: collectionNameValue.value,
|
||||||
customTitle: customTitleValue.value
|
customTitle: customTitleValue.value,
|
||||||
|
sourceDeviceName: sourceDeviceNameValue.value ?? ClipboardItem.localDeviceName
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -671,6 +837,7 @@ final class ClipboardStore {
|
|||||||
|
|
||||||
private enum PersistenceMutation {
|
private enum PersistenceMutation {
|
||||||
case upsert(ClipboardItem)
|
case upsert(ClipboardItem)
|
||||||
|
case upsertMany([ClipboardItem])
|
||||||
case delete(UUID)
|
case delete(UUID)
|
||||||
case deleteMany([UUID])
|
case deleteMany([UUID])
|
||||||
case deleteAll
|
case deleteAll
|
||||||
@@ -678,14 +845,13 @@ final class ClipboardStore {
|
|||||||
|
|
||||||
private func applyPersistence(_ mutation: PersistenceMutation) {
|
private func applyPersistence(_ mutation: PersistenceMutation) {
|
||||||
guard let db else { return }
|
guard let db else { return }
|
||||||
DiagnosticsService.shared.incrementDatabaseMutation()
|
|
||||||
let insertSQL = """
|
let insertSQL = """
|
||||||
INSERT OR REPLACE INTO clipboard_items (
|
INSERT OR REPLACE INTO clipboard_items (
|
||||||
id, kind, display_text, payload, payload_hash,
|
id, kind, display_text, payload, payload_hash,
|
||||||
created_at, last_used_at, use_count, source_app,
|
created_at, last_used_at, use_count, source_app,
|
||||||
source_app_bundle_id, image_path, thumbnail_path, is_pinned, ocr_text,
|
source_app_bundle_id, image_path, thumbnail_path, is_pinned, ocr_text,
|
||||||
collection_name, custom_title
|
collection_name, custom_title, source_device_name
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);
|
||||||
"""
|
"""
|
||||||
|
|
||||||
switch mutation {
|
switch mutation {
|
||||||
@@ -719,6 +885,40 @@ final class ClipboardStore {
|
|||||||
shouldRollback = true
|
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):
|
case .delete(let id):
|
||||||
guard execute("BEGIN IMMEDIATE TRANSACTION;") else { return }
|
guard execute("BEGIN IMMEDIATE TRANSACTION;") else { return }
|
||||||
let query = "DELETE FROM clipboard_items WHERE id = ?;"
|
let query = "DELETE FROM clipboard_items WHERE id = ?;"
|
||||||
@@ -784,8 +984,8 @@ final class ClipboardStore {
|
|||||||
id, kind, display_text, payload, payload_hash,
|
id, kind, display_text, payload, payload_hash,
|
||||||
created_at, last_used_at, use_count, source_app,
|
created_at, last_used_at, use_count, source_app,
|
||||||
source_app_bundle_id, image_path, thumbnail_path, is_pinned, ocr_text,
|
source_app_bundle_id, image_path, thumbnail_path, is_pinned, ocr_text,
|
||||||
collection_name, custom_title
|
collection_name, custom_title, source_device_name
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);
|
||||||
"""
|
"""
|
||||||
|
|
||||||
guard execute("BEGIN IMMEDIATE TRANSACTION;") else {
|
guard execute("BEGIN IMMEDIATE TRANSACTION;") else {
|
||||||
@@ -870,5 +1070,6 @@ final class ClipboardStore {
|
|||||||
bindText(statement, 14, encryptionService.protect(item.ocrText))
|
bindText(statement, 14, encryptionService.protect(item.ocrText))
|
||||||
bindText(statement, 15, encryptionService.protect(item.collectionName))
|
bindText(statement, 15, encryptionService.protect(item.collectionName))
|
||||||
bindText(statement, 16, encryptionService.protect(item.customTitle))
|
bindText(statement, 16, encryptionService.protect(item.customTitle))
|
||||||
|
bindText(statement, 17, encryptionService.protect(item.sourceDeviceName))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,61 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
final class DiagnosticsService {
|
|
||||||
static let shared = DiagnosticsService()
|
|
||||||
|
|
||||||
struct Snapshot: Equatable {
|
|
||||||
var monitorTicks: Int
|
|
||||||
var pasteboardChanges: Int
|
|
||||||
var extractionAttempts: Int
|
|
||||||
var databaseMutations: Int
|
|
||||||
var cachePurges: Int
|
|
||||||
}
|
|
||||||
|
|
||||||
private let queue = DispatchQueue(label: "clipboard.diagnostics", qos: .utility)
|
|
||||||
private var snapshot = Snapshot(
|
|
||||||
monitorTicks: 0,
|
|
||||||
pasteboardChanges: 0,
|
|
||||||
extractionAttempts: 0,
|
|
||||||
databaseMutations: 0,
|
|
||||||
cachePurges: 0
|
|
||||||
)
|
|
||||||
|
|
||||||
private init() {}
|
|
||||||
|
|
||||||
func incrementMonitorTick() {
|
|
||||||
queue.async { self.snapshot.monitorTicks += 1 }
|
|
||||||
}
|
|
||||||
|
|
||||||
func incrementPasteboardChange() {
|
|
||||||
queue.async { self.snapshot.pasteboardChanges += 1 }
|
|
||||||
}
|
|
||||||
|
|
||||||
func incrementExtractionAttempt() {
|
|
||||||
queue.async { self.snapshot.extractionAttempts += 1 }
|
|
||||||
}
|
|
||||||
|
|
||||||
func incrementDatabaseMutation() {
|
|
||||||
queue.async { self.snapshot.databaseMutations += 1 }
|
|
||||||
}
|
|
||||||
|
|
||||||
func incrementCachePurge() {
|
|
||||||
queue.async { self.snapshot.cachePurges += 1 }
|
|
||||||
}
|
|
||||||
|
|
||||||
func currentSnapshot() -> Snapshot {
|
|
||||||
queue.sync { snapshot }
|
|
||||||
}
|
|
||||||
|
|
||||||
func reset() {
|
|
||||||
queue.sync {
|
|
||||||
snapshot = Snapshot(
|
|
||||||
monitorTicks: 0,
|
|
||||||
pasteboardChanges: 0,
|
|
||||||
extractionAttempts: 0,
|
|
||||||
databaseMutations: 0,
|
|
||||||
cachePurges: 0
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -29,12 +29,20 @@ enum ImageTextExtractor {
|
|||||||
return normalized(lines)
|
return normalized(lines)
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func normalized(_ lines: [String]) -> String? {
|
static func normalizedRecognizedText(_ text: String?) -> String? {
|
||||||
let text = lines
|
guard let text else { return nil }
|
||||||
.filter { !$0.isEmpty }
|
let normalized = text
|
||||||
.joined(separator: " ")
|
|
||||||
.split(whereSeparator: \.isWhitespace)
|
.split(whereSeparator: \.isWhitespace)
|
||||||
.joined(separator: " ")
|
.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 .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,
|
guard let targetApp,
|
||||||
!targetApp.isTerminated else {
|
!targetApp.isTerminated else {
|
||||||
return .copied
|
return .copied
|
||||||
@@ -77,27 +89,19 @@ final class PasteActionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func pastePlainText(_ item: ClipboardItem, targetApp: NSRunningApplication?) -> PasteActionResult {
|
func pastePlainText(_ item: ClipboardItem, targetApp: NSRunningApplication?) -> PasteActionResult {
|
||||||
guard writePlainTextToPasteboard(item) else {
|
guard let text = plainText(for: item), writePlainTextToPasteboard(text) else {
|
||||||
return .failed("Could not write plain text to clipboard.")
|
return .failed("Could not write plain text to clipboard.")
|
||||||
}
|
}
|
||||||
|
|
||||||
guard let targetApp,
|
return completePlainTextPaste(targetApp: targetApp)
|
||||||
!targetApp.isTerminated else {
|
|
||||||
return .copiedPlainText
|
|
||||||
}
|
}
|
||||||
|
|
||||||
guard accessibilityPermissionProvider() else {
|
func pastePlainText(_ value: String, targetApp: NSRunningApplication?) -> PasteActionResult {
|
||||||
return .copiedPlainTextNeedsPermission
|
guard writePlainTextToPasteboard(value) else {
|
||||||
|
return .failed("Could not write plain text to clipboard.")
|
||||||
}
|
}
|
||||||
|
|
||||||
guard targetActivator(targetApp) else {
|
return completePlainTextPaste(targetApp: targetApp)
|
||||||
return .copiedPlainText
|
|
||||||
}
|
|
||||||
|
|
||||||
keyboardPasteScheduler { [weak self] in
|
|
||||||
self?.pasteViaKeyboard()
|
|
||||||
}
|
|
||||||
return .pastedPlainText
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@discardableResult
|
@discardableResult
|
||||||
@@ -105,11 +109,21 @@ final class PasteActionService {
|
|||||||
writeToPasteboard(item) ? .copied : .failed("Could not write item to clipboard.")
|
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
|
@discardableResult
|
||||||
func copyPlainText(_ item: ClipboardItem) -> PasteActionResult {
|
func copyPlainText(_ item: ClipboardItem) -> PasteActionResult {
|
||||||
writePlainTextToPasteboard(item) ? .copiedPlainText : .failed("Could not write plain text to clipboard.")
|
writePlainTextToPasteboard(item) ? .copiedPlainText : .failed("Could not write plain text to clipboard.")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
func copyPlainText(_ value: String) -> PasteActionResult {
|
||||||
|
writePlainTextToPasteboard(value) ? .copiedPlainText : .failed("Could not write plain text to clipboard.")
|
||||||
|
}
|
||||||
|
|
||||||
func pasteboardWriters(for item: ClipboardItem) -> [NSPasteboardWriting] {
|
func pasteboardWriters(for item: ClipboardItem) -> [NSPasteboardWriting] {
|
||||||
switch item.kind {
|
switch item.kind {
|
||||||
case .image:
|
case .image:
|
||||||
@@ -130,6 +144,17 @@ final class PasteActionService {
|
|||||||
pasteboardItem.setString(dragLabel(for: item), forType: .string)
|
pasteboardItem.setString(dragLabel(for: item), forType: .string)
|
||||||
return [pasteboardItem]
|
return [pasteboardItem]
|
||||||
|
|
||||||
|
case .video:
|
||||||
|
guard let data = cacheService.data(for: item.payload) else { return [] }
|
||||||
|
let pasteboardItem = NSPasteboardItem()
|
||||||
|
pasteboardItem.setData(data, forType: VideoPayload.pasteboardType(forPath: item.payload))
|
||||||
|
pasteboardItem.setString(dragLabel(for: item), forType: .string)
|
||||||
|
return [pasteboardItem]
|
||||||
|
|
||||||
|
case .color:
|
||||||
|
guard let color = ColorPayload.color(from: item.payload) else { return [] }
|
||||||
|
return [color]
|
||||||
|
|
||||||
case .richText:
|
case .richText:
|
||||||
if let data = cacheService.data(for: item.payload) {
|
if let data = cacheService.data(for: item.payload) {
|
||||||
let pasteboardItem = NSPasteboardItem()
|
let pasteboardItem = NSPasteboardItem()
|
||||||
@@ -161,7 +186,7 @@ final class PasteActionService {
|
|||||||
}
|
}
|
||||||
return [pasteboardItem]
|
return [pasteboardItem]
|
||||||
|
|
||||||
case .text, .unknown:
|
case .text, .code, .unknown:
|
||||||
guard !item.payload.isEmpty else { return [] }
|
guard !item.payload.isEmpty else { return [] }
|
||||||
return [stringPasteboardItem(item.payload)]
|
return [stringPasteboardItem(item.payload)]
|
||||||
}
|
}
|
||||||
@@ -184,6 +209,17 @@ final class PasteActionService {
|
|||||||
guard let data = cacheService.data(for: item.payload) else { return false }
|
guard let data = cacheService.data(for: item.payload) else { return false }
|
||||||
board.clearContents()
|
board.clearContents()
|
||||||
didWrite = board.setData(data, forType: .sound)
|
didWrite = board.setData(data, forType: .sound)
|
||||||
|
case .video:
|
||||||
|
guard let data = cacheService.data(for: item.payload) else { return false }
|
||||||
|
board.clearContents()
|
||||||
|
didWrite = board.setData(data, forType: VideoPayload.pasteboardType(forPath: item.payload))
|
||||||
|
case .color:
|
||||||
|
guard let color = ColorPayload.color(from: item.payload) else { return false }
|
||||||
|
board.clearContents()
|
||||||
|
didWrite = board.writeObjects([color])
|
||||||
|
if didWrite {
|
||||||
|
board.setString(ColorPayload.displayHex(from: item.payload), forType: .string)
|
||||||
|
}
|
||||||
case .richText:
|
case .richText:
|
||||||
if let data = cacheService.data(for: item.payload) {
|
if let data = cacheService.data(for: item.payload) {
|
||||||
board.clearContents()
|
board.clearContents()
|
||||||
@@ -211,7 +247,7 @@ final class PasteActionService {
|
|||||||
guard !item.payload.isEmpty else { return false }
|
guard !item.payload.isEmpty else { return false }
|
||||||
board.clearContents()
|
board.clearContents()
|
||||||
didWrite = writeURL(item.payload, title: item.displayText, to: board)
|
didWrite = writeURL(item.payload, title: item.displayText, to: board)
|
||||||
case .text, .unknown:
|
case .text, .code, .unknown:
|
||||||
guard !item.payload.isEmpty else { return false }
|
guard !item.payload.isEmpty else { return false }
|
||||||
board.clearContents()
|
board.clearContents()
|
||||||
didWrite = board.setString(item.payload, forType: .string)
|
didWrite = board.setString(item.payload, forType: .string)
|
||||||
@@ -223,9 +259,29 @@ final class PasteActionService {
|
|||||||
return didWrite
|
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? {
|
func plainText(for item: ClipboardItem) -> String? {
|
||||||
switch item.kind {
|
switch item.kind {
|
||||||
case .text, .unknown:
|
case .text, .code, .unknown:
|
||||||
return nonEmptyPlainText(item.payload) ?? nonEmptyPlainText(item.displayText)
|
return nonEmptyPlainText(item.payload) ?? nonEmptyPlainText(item.displayText)
|
||||||
case .url, .file:
|
case .url, .file:
|
||||||
return nonEmptyPlainText(item.payload) ?? nonEmptyPlainText(item.displayText)
|
return nonEmptyPlainText(item.payload) ?? nonEmptyPlainText(item.displayText)
|
||||||
@@ -237,14 +293,22 @@ final class PasteActionService {
|
|||||||
return nonEmptyPlainText(richTextFallbackPlainString(for: item))
|
return nonEmptyPlainText(richTextFallbackPlainString(for: item))
|
||||||
case .image:
|
case .image:
|
||||||
return nonEmptyPlainText(item.ocrText) ?? nonEmptyPlainText(item.displayText)
|
return nonEmptyPlainText(item.ocrText) ?? nonEmptyPlainText(item.displayText)
|
||||||
case .pdf, .audio:
|
case .pdf, .audio, .video:
|
||||||
return nonEmptyPlainText(item.displayText)
|
return nonEmptyPlainText(item.displayText)
|
||||||
|
case .color:
|
||||||
|
return nonEmptyPlainText(ColorPayload.displayHex(from: item.payload)) ?? nonEmptyPlainText(item.displayText)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@discardableResult
|
@discardableResult
|
||||||
func writePlainTextToPasteboard(_ item: ClipboardItem) -> Bool {
|
func writePlainTextToPasteboard(_ item: ClipboardItem) -> Bool {
|
||||||
guard let text = plainText(for: item) else { return false }
|
guard let text = plainText(for: item) else { return false }
|
||||||
|
return writePlainTextToPasteboard(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
private func writePlainTextToPasteboard(_ text: String) -> Bool {
|
||||||
|
guard !text.clipboardTrimmed.isEmpty else { return false }
|
||||||
let board = NSPasteboard.general
|
let board = NSPasteboard.general
|
||||||
board.clearContents()
|
board.clearContents()
|
||||||
let didWrite = board.setString(text, forType: .string)
|
let didWrite = board.setString(text, forType: .string)
|
||||||
@@ -254,6 +318,26 @@ final class PasteActionService {
|
|||||||
return didWrite
|
return didWrite
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func completePlainTextPaste(targetApp: NSRunningApplication?) -> PasteActionResult {
|
||||||
|
guard let targetApp,
|
||||||
|
!targetApp.isTerminated else {
|
||||||
|
return .copiedPlainText
|
||||||
|
}
|
||||||
|
|
||||||
|
guard accessibilityPermissionProvider() else {
|
||||||
|
return .copiedPlainTextNeedsPermission
|
||||||
|
}
|
||||||
|
|
||||||
|
guard targetActivator(targetApp) else {
|
||||||
|
return .copiedPlainText
|
||||||
|
}
|
||||||
|
|
||||||
|
keyboardPasteScheduler { [weak self] in
|
||||||
|
self?.pasteViaKeyboard()
|
||||||
|
}
|
||||||
|
return .pastedPlainText
|
||||||
|
}
|
||||||
|
|
||||||
private func stringPasteboardItem(_ value: String) -> NSPasteboardItem {
|
private func stringPasteboardItem(_ value: String) -> NSPasteboardItem {
|
||||||
let pasteboardItem = NSPasteboardItem()
|
let pasteboardItem = NSPasteboardItem()
|
||||||
pasteboardItem.setString(value, forType: .string)
|
pasteboardItem.setString(value, forType: .string)
|
||||||
|
|||||||
@@ -17,369 +17,143 @@ enum SensitiveContentDetector {
|
|||||||
case keyword
|
case keyword
|
||||||
}
|
}
|
||||||
|
|
||||||
static func detect(_ text: String, sourceBundleId: String? = nil, sourceApp: String? = nil) -> Reason? {
|
private static let tokenPatterns: [(Reason, NSRegularExpression)] = [
|
||||||
let trimmed = text.clipboardTrimmed
|
(.bearerToken, regex(#"(?i)\bbearer\s+[A-Za-z0-9._+/=-]{20,}(?![A-Za-z0-9_])"#)),
|
||||||
guard !trimmed.isEmpty else { return nil }
|
(.githubToken, regex(#"\bgh[porus]_[A-Za-z0-9_]{30,}(?![A-Za-z0-9_])"#)),
|
||||||
let bytes = Array(trimmed.utf8)
|
(.slackToken, regex(#"\bxox[baprs]-[A-Za-z0-9-]{20,}(?![A-Za-z0-9_])"#)),
|
||||||
|
(.awsAccessKey, regex(#"\bAKIA[A-Z0-9]{16}(?![A-Za-z0-9_])"#)),
|
||||||
|
(.stripeKey, regex(#"\b[srp]k_(?:live|test)_[A-Za-z0-9]{16,}(?![A-Za-z0-9_])"#)),
|
||||||
|
(.openAIToken, regex(#"\bsk-(?:proj-)?[A-Za-z0-9_-]{20,}(?![A-Za-z0-9_])"#)),
|
||||||
|
(.googleAPIKey, regex(#"\bAIza[A-Za-z0-9_-]{35}(?![A-Za-z0-9_])"#)),
|
||||||
|
(.jsonWebToken, regex(#"\beyJ[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}(?![A-Za-z0-9_])"#))
|
||||||
|
]
|
||||||
|
|
||||||
if containsPrivateKey(trimmed) { return .privateKey }
|
static func detect(
|
||||||
if containsBearerToken(bytes) { return .bearerToken }
|
_ text: String,
|
||||||
if containsGitHubToken(bytes) { return .githubToken }
|
sourceBundleId: String? = nil,
|
||||||
if containsSlackToken(bytes) { return .slackToken }
|
sourceApp: String? = nil
|
||||||
if containsAWSAccessKey(bytes) { return .awsAccessKey }
|
) -> Reason? {
|
||||||
if containsStripeKey(bytes) { return .stripeKey }
|
let value = text.clipboardTrimmed
|
||||||
if containsOpenAIToken(bytes) { return .openAIToken }
|
guard !value.isEmpty else { return nil }
|
||||||
if containsGoogleAPIKey(bytes) { return .googleAPIKey }
|
|
||||||
if containsJSONWebToken(bytes) { return .jsonWebToken }
|
|
||||||
if containsCreditCard(trimmed) { return .creditCard }
|
|
||||||
if looksLikeOneTimeCode(trimmed, sourceBundleId: sourceBundleId, sourceApp: sourceApp) { return .oneTimeCode }
|
|
||||||
if looksHighEntropy(trimmed) { return .highEntropyToken }
|
|
||||||
|
|
||||||
let lowered = trimmed.lowercased()
|
if value.contains("-----BEGIN "), value.contains("PRIVATE KEY-----") {
|
||||||
if lowered.contains("password") || lowered.contains("secret") || lowered.contains("api_key") || looksLikeSecretAssignment(lowered) {
|
return .privateKey
|
||||||
|
}
|
||||||
|
if let match = tokenPatterns.first(where: { matches($0.1, in: value) }) {
|
||||||
|
return match.0
|
||||||
|
}
|
||||||
|
if containsCreditCard(value) { return .creditCard }
|
||||||
|
if looksLikeOneTimeCode(value, sourceBundleId: sourceBundleId, sourceApp: sourceApp) {
|
||||||
|
return .oneTimeCode
|
||||||
|
}
|
||||||
|
if looksHighEntropy(value) { return .highEntropyToken }
|
||||||
|
|
||||||
|
let lowered = value.lowercased()
|
||||||
|
if lowered.contains("password")
|
||||||
|
|| lowered.contains("secret")
|
||||||
|
|| lowered.contains("api_key")
|
||||||
|
|| looksLikeSecretAssignment(lowered) {
|
||||||
return .keyword
|
return .keyword
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
static func isLikelySensitive(_ text: String, sourceBundleId: String? = nil, sourceApp: String? = nil) -> Bool {
|
static func isLikelySensitive(
|
||||||
|
_ text: String,
|
||||||
|
sourceBundleId: String? = nil,
|
||||||
|
sourceApp: String? = nil
|
||||||
|
) -> Bool {
|
||||||
detect(text, sourceBundleId: sourceBundleId, sourceApp: sourceApp) != nil
|
detect(text, sourceBundleId: sourceBundleId, sourceApp: sourceApp) != nil
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func containsPrivateKey(_ text: String) -> Bool {
|
private static func regex(_ pattern: String) -> NSRegularExpression {
|
||||||
text.contains("-----BEGIN ") && text.contains("PRIVATE KEY-----")
|
try! NSRegularExpression(pattern: pattern)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func matches(_ regex: NSRegularExpression, in text: String) -> Bool {
|
||||||
|
regex.firstMatch(in: text, range: NSRange(text.startIndex..., in: text)) != nil
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func looksHighEntropy(_ text: String) -> Bool {
|
private static func looksHighEntropy(_ text: String) -> Bool {
|
||||||
let candidate = text.clipboardTrimmed
|
guard (32...256).contains(text.count),
|
||||||
guard candidate.count >= 32, candidate.count <= 256 else { return false }
|
!text.contains(where: \.isWhitespace) else {
|
||||||
guard !candidate.contains(where: { $0.isWhitespace }) else { return false }
|
|
||||||
|
|
||||||
var hasLower = false
|
|
||||||
var hasUpper = false
|
|
||||||
var hasDigit = false
|
|
||||||
var symbolCount = 0
|
|
||||||
|
|
||||||
for scalar in candidate.unicodeScalars {
|
|
||||||
let value = scalar.value
|
|
||||||
if value >= 48, value <= 57 {
|
|
||||||
hasDigit = true
|
|
||||||
} else if value >= 65, value <= 90 {
|
|
||||||
hasUpper = true
|
|
||||||
} else if value >= 97, value <= 122 {
|
|
||||||
hasLower = true
|
|
||||||
} else if value == 95 || value == 45 || value == 46 || value == 43 || value == 47 || value == 61 {
|
|
||||||
symbolCount += 1
|
|
||||||
} else {
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var characterClasses = 0
|
||||||
|
var hasSymbol = false
|
||||||
|
for scalar in text.unicodeScalars {
|
||||||
|
switch scalar.value {
|
||||||
|
case 48...57: characterClasses |= 1
|
||||||
|
case 65...90: characterClasses |= 2
|
||||||
|
case 97...122: characterClasses |= 4
|
||||||
|
case 43, 45, 46, 47, 61, 95: hasSymbol = true
|
||||||
|
default: return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return characterClasses.nonzeroBitCount >= 2 && hasSymbol
|
||||||
}
|
}
|
||||||
|
|
||||||
let classCount = (hasLower ? 1 : 0) + (hasUpper ? 1 : 0) + (hasDigit ? 1 : 0)
|
private static func looksLikeOneTimeCode(
|
||||||
return classCount >= 2 && symbolCount > 0
|
_ text: String,
|
||||||
|
sourceBundleId: String?,
|
||||||
|
sourceApp: String?
|
||||||
|
) -> Bool {
|
||||||
|
guard (6...8).contains(text.count), text.allSatisfy(\.isNumber) else {
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
let source = "\(sourceBundleId ?? "") \(sourceApp ?? "")".lowercased()
|
||||||
private static func looksLikeOneTimeCode(_ text: String, sourceBundleId: String?, sourceApp: String?) -> Bool {
|
return ["auth", "1password", "bitwarden", "lastpass", "keeper", "dashlane"]
|
||||||
let value = text.clipboardTrimmed
|
.contains(where: source.contains)
|
||||||
guard value.count >= 6, value.count <= 8, value.allSatisfy({ $0.isNumber }) else { return false }
|
|
||||||
|
|
||||||
let source = ((sourceBundleId ?? "") + " " + (sourceApp ?? "")).lowercased()
|
|
||||||
guard !source.isEmpty else { return false }
|
|
||||||
return source.contains("auth") ||
|
|
||||||
source.contains("1password") ||
|
|
||||||
source.contains("bitwarden") ||
|
|
||||||
source.contains("lastpass") ||
|
|
||||||
source.contains("keeper") ||
|
|
||||||
source.contains("dashlane")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func containsCreditCard(_ text: String) -> Bool {
|
private static func containsCreditCard(_ text: String) -> Bool {
|
||||||
var digits: [Int] = []
|
var digits: [Int] = []
|
||||||
|
func isCard(_ digits: [Int]) -> Bool {
|
||||||
for char in text {
|
guard (13...19).contains(digits.count),
|
||||||
if char.isNumber, let digit = char.wholeNumberValue {
|
let first = digits.first,
|
||||||
digits.append(digit)
|
digits.contains(where: { $0 != first }) else {
|
||||||
} else {
|
return false
|
||||||
if isCreditCardGroup(digits) {
|
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
|
var sum = 0
|
||||||
|
for (index, digit) in digits.reversed().enumerated() {
|
||||||
|
let doubled = index.isMultiple(of: 2) ? digit : digit * 2
|
||||||
|
sum += doubled > 9 ? doubled - 9 : doubled
|
||||||
|
}
|
||||||
|
return sum.isMultiple(of: 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
for character in text {
|
||||||
|
if let digit = character.wholeNumberValue {
|
||||||
|
digits.append(digit)
|
||||||
|
} else if (character == " " || character == "-"), !digits.isEmpty {
|
||||||
|
continue
|
||||||
|
} else {
|
||||||
|
if isCard(digits) { return true }
|
||||||
digits.removeAll(keepingCapacity: true)
|
digits.removeAll(keepingCapacity: true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return isCard(digits)
|
||||||
return isCreditCardGroup(digits)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func isCreditCardGroup(_ digits: [Int]) -> Bool {
|
private static func looksLikeSecretAssignment(_ text: String) -> Bool {
|
||||||
guard digits.count >= 13, digits.count <= 19, let first = digits.first else {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
guard digits.contains(where: { $0 != first }) else {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return passesLuhn(digits)
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func passesLuhn(_ digits: [Int]) -> Bool {
|
|
||||||
var sum = 0
|
|
||||||
var shouldDouble = false
|
|
||||||
|
|
||||||
for digit in digits.reversed() {
|
|
||||||
var value = digit
|
|
||||||
if shouldDouble {
|
|
||||||
value *= 2
|
|
||||||
if value > 9 {
|
|
||||||
value -= 9
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sum += value
|
|
||||||
shouldDouble.toggle()
|
|
||||||
}
|
|
||||||
|
|
||||||
return sum % 10 == 0
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func containsBearerToken(_ bytes: [UInt8]) -> Bool {
|
|
||||||
guard bytes.count >= 27 else { return false }
|
|
||||||
for index in 0...(bytes.count - 6) where isWordBoundaryBefore(bytes, index) {
|
|
||||||
guard matchesBearer(bytes, index) else { continue }
|
|
||||||
var cursor = index + 6
|
|
||||||
guard cursor < bytes.count, isWhitespace(bytes[cursor]) else { continue }
|
|
||||||
while cursor < bytes.count, isWhitespace(bytes[cursor]) {
|
|
||||||
cursor += 1
|
|
||||||
}
|
|
||||||
let start = cursor
|
|
||||||
while cursor < bytes.count, isBearerByte(bytes[cursor]) {
|
|
||||||
cursor += 1
|
|
||||||
}
|
|
||||||
if cursor - start >= 20, isWordBoundaryAfter(bytes, cursor) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func containsGitHubToken(_ bytes: [UInt8]) -> Bool {
|
|
||||||
guard bytes.count >= 34 else { return false }
|
|
||||||
for index in 0..<(bytes.count - 3) where isWordBoundaryBefore(bytes, index) {
|
|
||||||
let marker = bytes[index + 2]
|
|
||||||
guard bytes[index] == 103, bytes[index + 1] == 104, (marker == 112 || marker == 111 || marker == 117 || marker == 115 || marker == 114), bytes[index + 3] == 95 else {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
var cursor = index + 4
|
|
||||||
while cursor < bytes.count, isAlphaNumeric(bytes[cursor]) || bytes[cursor] == 95 {
|
|
||||||
cursor += 1
|
|
||||||
}
|
|
||||||
if cursor - (index + 4) >= 30, isWordBoundaryAfter(bytes, cursor) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func containsSlackToken(_ bytes: [UInt8]) -> Bool {
|
|
||||||
guard bytes.count >= 25 else { return false }
|
|
||||||
for index in 0..<(bytes.count - 4) where isWordBoundaryBefore(bytes, index) {
|
|
||||||
let marker = bytes[index + 3]
|
|
||||||
guard bytes[index] == 120, bytes[index + 1] == 111, bytes[index + 2] == 120, (marker == 98 || marker == 97 || marker == 112 || marker == 114 || marker == 115), bytes[index + 4] == 45 else {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
var cursor = index + 5
|
|
||||||
while cursor < bytes.count, isAlphaNumeric(bytes[cursor]) || bytes[cursor] == 45 {
|
|
||||||
cursor += 1
|
|
||||||
}
|
|
||||||
if cursor - (index + 5) >= 20, isWordBoundaryAfter(bytes, cursor) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func containsAWSAccessKey(_ bytes: [UInt8]) -> Bool {
|
|
||||||
guard bytes.count >= 20 else { return false }
|
|
||||||
for index in 0...(bytes.count - 20) where isWordBoundaryBefore(bytes, index) {
|
|
||||||
guard bytes[index] == 65, bytes[index + 1] == 75, bytes[index + 2] == 73, bytes[index + 3] == 65 else { continue }
|
|
||||||
var cursor = index + 4
|
|
||||||
while cursor < index + 20, isUpperAlphaNumeric(bytes[cursor]) {
|
|
||||||
cursor += 1
|
|
||||||
}
|
|
||||||
if cursor == index + 20, isWordBoundaryAfter(bytes, cursor) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func containsStripeKey(_ bytes: [UInt8]) -> Bool {
|
|
||||||
guard bytes.count >= 24 else { return false }
|
|
||||||
for index in 0..<(bytes.count - 8) where isWordBoundaryBefore(bytes, index) {
|
|
||||||
let prefix = bytes[index]
|
|
||||||
guard (prefix == 115 || prefix == 114 || prefix == 112), bytes[index + 1] == 107, bytes[index + 2] == 95 else { continue }
|
|
||||||
let live = bytes[index + 3] == 108 && bytes[index + 4] == 105 && bytes[index + 5] == 118 && bytes[index + 6] == 101 && bytes[index + 7] == 95
|
|
||||||
let test = bytes[index + 3] == 116 && bytes[index + 4] == 101 && bytes[index + 5] == 115 && bytes[index + 6] == 116 && bytes[index + 7] == 95
|
|
||||||
guard live || test else { continue }
|
|
||||||
var cursor = index + 8
|
|
||||||
while cursor < bytes.count, isAlphaNumeric(bytes[cursor]) {
|
|
||||||
cursor += 1
|
|
||||||
}
|
|
||||||
if cursor - (index + 8) >= 16, isWordBoundaryAfter(bytes, cursor) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func containsOpenAIToken(_ bytes: [UInt8]) -> Bool {
|
|
||||||
guard bytes.count >= 24 else { return false }
|
|
||||||
for index in 0..<(bytes.count - 3) where isWordBoundaryBefore(bytes, index) {
|
|
||||||
guard bytes[index] == 115, bytes[index + 1] == 107, bytes[index + 2] == 45 else { continue }
|
|
||||||
var cursor = index + 3
|
|
||||||
if cursor + 5 <= bytes.count,
|
|
||||||
bytes[cursor] == 112,
|
|
||||||
bytes[cursor + 1] == 114,
|
|
||||||
bytes[cursor + 2] == 111,
|
|
||||||
bytes[cursor + 3] == 106,
|
|
||||||
bytes[cursor + 4] == 45 {
|
|
||||||
cursor += 5
|
|
||||||
}
|
|
||||||
let tokenStart = cursor
|
|
||||||
while cursor < bytes.count, isTokenByte(bytes[cursor]) {
|
|
||||||
cursor += 1
|
|
||||||
}
|
|
||||||
if cursor - tokenStart >= 20, isWordBoundaryAfter(bytes, cursor) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func containsGoogleAPIKey(_ bytes: [UInt8]) -> Bool {
|
|
||||||
guard bytes.count >= 39 else { return false }
|
|
||||||
for index in 0...(bytes.count - 39) where isWordBoundaryBefore(bytes, index) {
|
|
||||||
guard bytes[index] == 65, bytes[index + 1] == 73, bytes[index + 2] == 122, bytes[index + 3] == 97 else { continue }
|
|
||||||
var cursor = index + 4
|
|
||||||
while cursor < index + 39, isTokenByte(bytes[cursor]) {
|
|
||||||
cursor += 1
|
|
||||||
}
|
|
||||||
if cursor == index + 39, isWordBoundaryAfter(bytes, cursor) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func containsJSONWebToken(_ bytes: [UInt8]) -> Bool {
|
|
||||||
guard bytes.count >= 32 else { return false }
|
|
||||||
var index = 0
|
|
||||||
while index + 3 < bytes.count {
|
|
||||||
guard isWordBoundaryBefore(bytes, index), bytes[index] == 101, bytes[index + 1] == 121, bytes[index + 2] == 74 else {
|
|
||||||
index += 1
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
var cursor = index
|
|
||||||
let firstStart = cursor
|
|
||||||
while cursor < bytes.count, isBase64URLByte(bytes[cursor]) {
|
|
||||||
cursor += 1
|
|
||||||
}
|
|
||||||
guard cursor - firstStart >= 8, cursor < bytes.count, bytes[cursor] == 46 else {
|
|
||||||
index += 1
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
cursor += 1
|
|
||||||
let secondStart = cursor
|
|
||||||
while cursor < bytes.count, isBase64URLByte(bytes[cursor]) {
|
|
||||||
cursor += 1
|
|
||||||
}
|
|
||||||
guard cursor - secondStart >= 8, cursor < bytes.count, bytes[cursor] == 46 else {
|
|
||||||
index += 1
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
cursor += 1
|
|
||||||
let thirdStart = cursor
|
|
||||||
while cursor < bytes.count, isBase64URLByte(bytes[cursor]) {
|
|
||||||
cursor += 1
|
|
||||||
}
|
|
||||||
if cursor - thirdStart >= 8, isWordBoundaryAfter(bytes, cursor) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
index += 1
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func looksLikeSecretAssignment(_ lowered: String) -> Bool {
|
|
||||||
let keys = [
|
let keys = [
|
||||||
"api_key",
|
"api_key", "apikey", "access_token", "auth_token", "client_secret",
|
||||||
"apikey",
|
"private_token", "refresh_token", "secret_key", "passwd"
|
||||||
"access_token",
|
|
||||||
"auth_token",
|
|
||||||
"client_secret",
|
|
||||||
"private_token",
|
|
||||||
"refresh_token",
|
|
||||||
"secret_key",
|
|
||||||
"passwd"
|
|
||||||
]
|
]
|
||||||
|
|
||||||
for key in keys {
|
for key in keys {
|
||||||
guard let range = lowered.range(of: key) else { continue }
|
guard let range = text.range(of: key) else { continue }
|
||||||
let suffix = lowered[range.upperBound...].drop(while: { $0.isWhitespace })
|
let suffix = text[range.upperBound...].drop(while: \.isWhitespace)
|
||||||
guard let separator = suffix.first, separator == "=" || separator == ":" else { continue }
|
guard suffix.first == "=" || suffix.first == ":" else { continue }
|
||||||
let value = suffix.dropFirst().drop(while: { $0.isWhitespace || $0 == "\"" || $0 == "'" })
|
let value = suffix.dropFirst().drop {
|
||||||
let valueLength = value.prefix { !$0.isWhitespace && $0 != "\"" && $0 != "'" && $0 != "," }.count
|
$0.isWhitespace || $0 == "\"" || $0 == "'"
|
||||||
if valueLength >= 8 {
|
}
|
||||||
|
if value.prefix(while: {
|
||||||
|
!$0.isWhitespace && $0 != "\"" && $0 != "'" && $0 != ","
|
||||||
|
}).count >= 8 {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func matchesBearer(_ bytes: [UInt8], _ index: Int) -> Bool {
|
|
||||||
(bytes[index] == 98 || bytes[index] == 66) &&
|
|
||||||
(bytes[index + 1] == 101 || bytes[index + 1] == 69) &&
|
|
||||||
(bytes[index + 2] == 97 || bytes[index + 2] == 65) &&
|
|
||||||
(bytes[index + 3] == 114 || bytes[index + 3] == 82) &&
|
|
||||||
(bytes[index + 4] == 101 || bytes[index + 4] == 69) &&
|
|
||||||
(bytes[index + 5] == 114 || bytes[index + 5] == 82)
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func isWordBoundaryBefore(_ bytes: [UInt8], _ index: Int) -> Bool {
|
|
||||||
index == 0 || !isWordByte(bytes[index - 1])
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func isWordBoundaryAfter(_ bytes: [UInt8], _ index: Int) -> Bool {
|
|
||||||
index >= bytes.count || !isWordByte(bytes[index])
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func isWordByte(_ byte: UInt8) -> Bool {
|
|
||||||
isAlphaNumeric(byte) || byte == 95
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func isAlphaNumeric(_ byte: UInt8) -> Bool {
|
|
||||||
(byte >= 48 && byte <= 57) || (byte >= 65 && byte <= 90) || (byte >= 97 && byte <= 122)
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func isUpperAlphaNumeric(_ byte: UInt8) -> Bool {
|
|
||||||
(byte >= 48 && byte <= 57) || (byte >= 65 && byte <= 90)
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func isBearerByte(_ byte: UInt8) -> Bool {
|
|
||||||
isAlphaNumeric(byte) || byte == 46 || byte == 95 || byte == 45 || byte == 43 || byte == 47 || byte == 61
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func isTokenByte(_ byte: UInt8) -> Bool {
|
|
||||||
isAlphaNumeric(byte) || byte == 95 || byte == 45
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func isBase64URLByte(_ byte: UInt8) -> Bool {
|
|
||||||
isAlphaNumeric(byte) || byte == 95 || byte == 45
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func isWhitespace(_ byte: UInt8) -> Bool {
|
|
||||||
byte == 32 || byte == 9 || byte == 10 || byte == 13
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,31 +24,28 @@ final class ShortcutManager {
|
|||||||
|
|
||||||
private enum HotKeyID: UInt32 {
|
private enum HotKeyID: UInt32 {
|
||||||
case openPanel = 1
|
case openPanel = 1
|
||||||
case openSettings = 2
|
case stackCapture = 3
|
||||||
}
|
}
|
||||||
|
|
||||||
private let onOpenClipboardPanel: () -> Void
|
private let onOpenClipboardPanel: () -> Void
|
||||||
private let onOpenSettings: () -> Void
|
private let onToggleStackCapture: () -> Void
|
||||||
private let onStatusChange: (RegistrationStatus) -> Void
|
private let onStatusChange: (RegistrationStatus) -> Void
|
||||||
|
|
||||||
private var openBinding: ShortcutBinding
|
private var openBinding: ShortcutBinding
|
||||||
private var settingsBinding: ShortcutBinding
|
|
||||||
private var openHotKey: EventHotKeyRef?
|
private var openHotKey: EventHotKeyRef?
|
||||||
private var settingsHotKey: EventHotKeyRef?
|
private var stackCaptureHotKey: EventHotKeyRef?
|
||||||
private var eventHandler: EventHandlerRef?
|
private var eventHandler: EventHandlerRef?
|
||||||
|
|
||||||
init(
|
init(
|
||||||
onOpenClipboardPanel: @escaping () -> Void,
|
onOpenClipboardPanel: @escaping () -> Void,
|
||||||
onOpenSettings: @escaping () -> Void,
|
onToggleStackCapture: @escaping () -> Void = {},
|
||||||
onStatusChange: @escaping (RegistrationStatus) -> Void = { _ in },
|
onStatusChange: @escaping (RegistrationStatus) -> Void = { _ in },
|
||||||
openShortcut: ShortcutBinding,
|
openShortcut: ShortcutBinding
|
||||||
settingsShortcut: ShortcutBinding
|
|
||||||
) {
|
) {
|
||||||
self.onOpenClipboardPanel = onOpenClipboardPanel
|
self.onOpenClipboardPanel = onOpenClipboardPanel
|
||||||
self.onOpenSettings = onOpenSettings
|
self.onToggleStackCapture = onToggleStackCapture
|
||||||
self.onStatusChange = onStatusChange
|
self.onStatusChange = onStatusChange
|
||||||
self.openBinding = openShortcut
|
self.openBinding = openShortcut
|
||||||
self.settingsBinding = settingsShortcut
|
|
||||||
}
|
}
|
||||||
|
|
||||||
deinit {
|
deinit {
|
||||||
@@ -59,12 +56,12 @@ final class ShortcutManager {
|
|||||||
func start() -> RegistrationStatus {
|
func start() -> RegistrationStatus {
|
||||||
stop()
|
stop()
|
||||||
|
|
||||||
if let status = validationFailure(for: openBinding) ?? validationFailure(for: settingsBinding) {
|
if let status = Self.validationFailure(for: openBinding) {
|
||||||
onStatusChange(status)
|
onStatusChange(status)
|
||||||
return status
|
return status
|
||||||
}
|
}
|
||||||
if openBinding == settingsBinding {
|
if openBinding == Self.stackCaptureShortcut {
|
||||||
let status = RegistrationStatus.conflict(openBinding.displayText)
|
let status = RegistrationStatus.conflict(Self.stackCaptureShortcut.displayText)
|
||||||
onStatusChange(status)
|
onStatusChange(status)
|
||||||
return status
|
return status
|
||||||
}
|
}
|
||||||
@@ -97,11 +94,11 @@ final class ShortcutManager {
|
|||||||
return openStatus
|
return openStatus
|
||||||
}
|
}
|
||||||
|
|
||||||
let settingsStatus = register(binding: settingsBinding, id: .openSettings, target: &settingsHotKey)
|
let stackCaptureStatus = register(binding: Self.stackCaptureShortcut, id: .stackCapture, target: &stackCaptureHotKey)
|
||||||
guard settingsStatus == .registered else {
|
guard stackCaptureStatus == .registered else {
|
||||||
stop()
|
stop()
|
||||||
onStatusChange(settingsStatus)
|
onStatusChange(stackCaptureStatus)
|
||||||
return settingsStatus
|
return stackCaptureStatus
|
||||||
}
|
}
|
||||||
|
|
||||||
onStatusChange(.registered)
|
onStatusChange(.registered)
|
||||||
@@ -109,9 +106,8 @@ final class ShortcutManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@discardableResult
|
@discardableResult
|
||||||
func reconfigure(openShortcut: ShortcutBinding, settingsShortcut: ShortcutBinding) -> RegistrationStatus {
|
func reconfigure(openShortcut: ShortcutBinding) -> RegistrationStatus {
|
||||||
openBinding = openShortcut
|
openBinding = openShortcut
|
||||||
settingsBinding = settingsShortcut
|
|
||||||
return start()
|
return start()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,15 +115,15 @@ final class ShortcutManager {
|
|||||||
if let openHotKey {
|
if let openHotKey {
|
||||||
UnregisterEventHotKey(openHotKey)
|
UnregisterEventHotKey(openHotKey)
|
||||||
}
|
}
|
||||||
if let settingsHotKey {
|
if let stackCaptureHotKey {
|
||||||
UnregisterEventHotKey(settingsHotKey)
|
UnregisterEventHotKey(stackCaptureHotKey)
|
||||||
}
|
}
|
||||||
if let eventHandler {
|
if let eventHandler {
|
||||||
RemoveEventHandler(eventHandler)
|
RemoveEventHandler(eventHandler)
|
||||||
}
|
}
|
||||||
|
|
||||||
openHotKey = nil
|
openHotKey = nil
|
||||||
settingsHotKey = nil
|
stackCaptureHotKey = nil
|
||||||
eventHandler = nil
|
eventHandler = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,7 +153,7 @@ final class ShortcutManager {
|
|||||||
return .registrationFailed(osStatusMessage(status))
|
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 {
|
guard Self.virtualKeyCode(for: binding.key) != nil else {
|
||||||
return .unsupportedShortcut(binding.displayText)
|
return .unsupportedShortcut(binding.displayText)
|
||||||
}
|
}
|
||||||
@@ -185,8 +181,8 @@ final class ShortcutManager {
|
|||||||
switch HotKeyID(rawValue: hotKeyID.id) {
|
switch HotKeyID(rawValue: hotKeyID.id) {
|
||||||
case .openPanel:
|
case .openPanel:
|
||||||
onOpenClipboardPanel()
|
onOpenClipboardPanel()
|
||||||
case .openSettings:
|
case .stackCapture:
|
||||||
onOpenSettings()
|
onToggleStackCapture()
|
||||||
case nil:
|
case nil:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -260,6 +256,11 @@ final class ShortcutManager {
|
|||||||
|
|
||||||
private static let hotKeySignature: OSType = 0x436C7042
|
private static let hotKeySignature: OSType = 0x436C7042
|
||||||
|
|
||||||
|
static let stackCaptureShortcut = ShortcutBinding(
|
||||||
|
key: "c",
|
||||||
|
modifierFlags: NSEvent.ModifierFlags([.command, .shift]).rawValue
|
||||||
|
)
|
||||||
|
|
||||||
private func osStatusMessage(_ status: OSStatus) -> String {
|
private func osStatusMessage(_ status: OSStatus) -> String {
|
||||||
"OSStatus \(status)"
|
"OSStatus \(status)"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,6 @@
|
|||||||
import AppKit
|
import AppKit
|
||||||
import QuickLookUI
|
import QuickLookUI
|
||||||
|
|
||||||
struct ClipboardPanelAnimationProfile {
|
|
||||||
let showDuration: TimeInterval
|
|
||||||
let hideDuration: TimeInterval
|
|
||||||
let reflowDuration: TimeInterval
|
|
||||||
let easing: CAMediaTimingFunctionName
|
|
||||||
}
|
|
||||||
|
|
||||||
struct ClipboardPanelReflowPlan {
|
struct ClipboardPanelReflowPlan {
|
||||||
let frame: NSRect
|
let frame: NSRect
|
||||||
let bottomSafeInset: CGFloat
|
let bottomSafeInset: CGFloat
|
||||||
@@ -16,14 +9,22 @@ struct ClipboardPanelReflowPlan {
|
|||||||
enum ClipboardPanelShortcutAction: Equatable {
|
enum ClipboardPanelShortcutAction: Equatable {
|
||||||
case copy
|
case copy
|
||||||
case copyPlainText
|
case copyPlainText
|
||||||
|
case edit
|
||||||
|
case focusSearch
|
||||||
case newCollection
|
case newCollection
|
||||||
|
case nextCollection
|
||||||
case open
|
case open
|
||||||
case pastePlainText
|
case pastePlainText
|
||||||
case pasteStackNext
|
case pasteStackNext
|
||||||
case preview
|
case preview
|
||||||
|
case previousCollection
|
||||||
|
case rename
|
||||||
case reveal
|
case reveal
|
||||||
case showInClipboard
|
case showInClipboard
|
||||||
|
case toggleCapturePause
|
||||||
case toggleStack
|
case toggleStack
|
||||||
|
case toggleStackCapture
|
||||||
|
case undoDelete
|
||||||
}
|
}
|
||||||
|
|
||||||
enum ClipboardPanelNavigationAction: Equatable {
|
enum ClipboardPanelNavigationAction: Equatable {
|
||||||
@@ -35,23 +36,39 @@ enum ClipboardPanelNavigationAction: Equatable {
|
|||||||
case previous
|
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 {
|
final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanelDataSource, QLPreviewPanelDelegate {
|
||||||
private enum Animation {
|
private enum Animation {
|
||||||
static let showDuration: TimeInterval = 0.16
|
static let showDuration: TimeInterval = 0.22
|
||||||
static let hideDuration: TimeInterval = 0.12
|
static let hideDuration: TimeInterval = 0.16
|
||||||
static let reflowDuration: TimeInterval = 0.10
|
static let reflowDuration: TimeInterval = 0.18
|
||||||
static let easing: CAMediaTimingFunctionName = .easeInEaseOut
|
static let easing: CAMediaTimingFunctionName = .easeInEaseOut
|
||||||
|
|
||||||
|
static func duration(_ preferredDuration: TimeInterval) -> TimeInterval {
|
||||||
|
NSWorkspace.shared.accessibilityDisplayShouldReduceMotion ? 0 : preferredDuration
|
||||||
|
}
|
||||||
}
|
}
|
||||||
private enum Metrics {
|
private enum Metrics {
|
||||||
static let shelfHeightRatio: CGFloat = 0.42
|
static let preferredVerticalShelfWidth: CGFloat = 336
|
||||||
static let minimumShelfHeight: CGFloat = 408
|
static let minimumVerticalShelfWidth: CGFloat = 320
|
||||||
static let maximumShelfHeight: CGFloat = 430
|
static let maximumVerticalShelfWidthRatio: CGFloat = 0.30
|
||||||
static let minimumBottomInset: CGFloat = 18
|
static let minimumBottomInset: CGFloat = 18
|
||||||
static let maximumBottomInset: CGFloat = 20
|
static let maximumBottomInset: CGFloat = 20
|
||||||
|
static let hiddenDockRevealInsetLimit: CGFloat = 8
|
||||||
}
|
}
|
||||||
|
|
||||||
private var panel: NSPanel!
|
private var panel: NSPanel!
|
||||||
private var panelView: ClipboardPanelView!
|
private var panelView: ClipboardPanelView!
|
||||||
|
private let settings: SettingsModel
|
||||||
private(set) var isVisible = false
|
private(set) var isVisible = false
|
||||||
private var clickMonitor: Any?
|
private var clickMonitor: Any?
|
||||||
private var keyMonitor: Any?
|
private var keyMonitor: Any?
|
||||||
@@ -62,6 +79,7 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel
|
|||||||
private let openSettings: () -> Void
|
private let openSettings: () -> Void
|
||||||
private var isAnimating = false
|
private var isAnimating = false
|
||||||
private var quickLookURL: URL?
|
private var quickLookURL: URL?
|
||||||
|
private var linkPreviewController: LinkPreviewWindowController?
|
||||||
private var screenParametersObserver: NSObjectProtocol?
|
private var screenParametersObserver: NSObjectProtocol?
|
||||||
private static let quickPasteKeyCodes: [UInt16: Int] = [
|
private static let quickPasteKeyCodes: [UInt16: Int] = [
|
||||||
18: 0,
|
18: 0,
|
||||||
@@ -82,7 +100,9 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel
|
|||||||
23: .images,
|
23: .images,
|
||||||
22: .files,
|
22: .files,
|
||||||
26: .pinned,
|
26: .pinned,
|
||||||
28: .audio
|
28: .audio,
|
||||||
|
25: .colors,
|
||||||
|
29: .code
|
||||||
]
|
]
|
||||||
|
|
||||||
private let viewModel: ClipboardPanelViewModel
|
private let viewModel: ClipboardPanelViewModel
|
||||||
@@ -95,6 +115,7 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel
|
|||||||
pollClipboardNow: @escaping () -> Void = {},
|
pollClipboardNow: @escaping () -> Void = {},
|
||||||
openSettings: @escaping () -> Void = {}
|
openSettings: @escaping () -> Void = {}
|
||||||
) {
|
) {
|
||||||
|
self.settings = settings
|
||||||
self.viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: cacheService)
|
self.viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: cacheService)
|
||||||
self.pollClipboardNow = pollClipboardNow
|
self.pollClipboardNow = pollClipboardNow
|
||||||
self.openSettings = openSettings
|
self.openSettings = openSettings
|
||||||
@@ -115,7 +136,7 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel
|
|||||||
onPreview: { [weak self] in self?.previewSelected() }
|
onPreview: { [weak self] in self?.previewSelected() }
|
||||||
)
|
)
|
||||||
|
|
||||||
let contentSize = NSSize(width: 1200, height: 420)
|
let contentSize = NSSize(width: 336, height: 760)
|
||||||
panel = KeyablePanel(
|
panel = KeyablePanel(
|
||||||
contentRect: NSRect(x: 0, y: 0, width: contentSize.width, height: contentSize.height),
|
contentRect: NSRect(x: 0, y: 0, width: contentSize.width, height: contentSize.height),
|
||||||
styleMask: [.nonactivatingPanel, .fullSizeContentView],
|
styleMask: [.nonactivatingPanel, .fullSizeContentView],
|
||||||
@@ -130,13 +151,25 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel
|
|||||||
panel.delegate = self
|
panel.delegate = self
|
||||||
panel.isOpaque = false
|
panel.isOpaque = false
|
||||||
panel.backgroundColor = NSColor.clear
|
panel.backgroundColor = NSColor.clear
|
||||||
panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
|
panel.collectionBehavior = Self.panelCollectionBehavior
|
||||||
panel.becomesKeyOnlyIfNeeded = false
|
panel.becomesKeyOnlyIfNeeded = false
|
||||||
panel.titlebarAppearsTransparent = true
|
panel.titlebarAppearsTransparent = true
|
||||||
panel.titleVisibility = .hidden
|
panel.titleVisibility = .hidden
|
||||||
panel.standardWindowButton(.miniaturizeButton)?.isHidden = true
|
panel.standardWindowButton(.miniaturizeButton)?.isHidden = true
|
||||||
panel.standardWindowButton(.zoomButton)?.isHidden = true
|
panel.standardWindowButton(.zoomButton)?.isHidden = true
|
||||||
panel.standardWindowButton(.closeButton)?.isHidden = true
|
panel.standardWindowButton(.closeButton)?.isHidden = true
|
||||||
|
applyPanelSharingSetting()
|
||||||
|
|
||||||
|
settings.observe { [weak self] change in
|
||||||
|
guard change == .hideFromScreenCapture || change == .panelSide else { return }
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
if change == .hideFromScreenCapture {
|
||||||
|
self?.applyPanelSharingSetting()
|
||||||
|
} else {
|
||||||
|
self?.reflowPanelForScreenChange()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
screenParametersObserver = NotificationCenter.default.addObserver(
|
screenParametersObserver = NotificationCenter.default.addObserver(
|
||||||
forName: NSApplication.didChangeScreenParametersNotification,
|
forName: NSApplication.didChangeScreenParametersNotification,
|
||||||
@@ -155,15 +188,30 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func toggle() {
|
func toggle(preferredScreen explicitScreen: NSScreen? = nil) {
|
||||||
if isVisible {
|
if isVisible {
|
||||||
hide()
|
hide()
|
||||||
} else {
|
} else {
|
||||||
show()
|
show(preferredScreen: explicitScreen)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func show() {
|
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 }
|
if isVisible || isAnimating { return }
|
||||||
isAnimating = true
|
isAnimating = true
|
||||||
isVisible = true
|
isVisible = true
|
||||||
@@ -171,7 +219,7 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel
|
|||||||
rememberTargetApplication()
|
rememberTargetApplication()
|
||||||
pollClipboardNow()
|
pollClipboardNow()
|
||||||
|
|
||||||
guard let screen = preferredScreen() else {
|
guard let screen = preferredScreen(explicitScreen: explicitScreen) else {
|
||||||
isVisible = false
|
isVisible = false
|
||||||
isAnimating = false
|
isAnimating = false
|
||||||
return
|
return
|
||||||
@@ -179,7 +227,8 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel
|
|||||||
activeScreenSnapshot = (screen.frame, screen.visibleFrame)
|
activeScreenSnapshot = (screen.frame, screen.visibleFrame)
|
||||||
let frames = Self.panelFrames(
|
let frames = Self.panelFrames(
|
||||||
forScreenFrame: screen.frame,
|
forScreenFrame: screen.frame,
|
||||||
visibleFrame: screen.visibleFrame
|
visibleFrame: screen.visibleFrame,
|
||||||
|
side: settings.panelSide
|
||||||
)
|
)
|
||||||
panelView.setBottomSafeInset(Self.contentBottomInset(forScreenFrame: screen.frame, visibleFrame: screen.visibleFrame))
|
panelView.setBottomSafeInset(Self.contentBottomInset(forScreenFrame: screen.frame, visibleFrame: screen.visibleFrame))
|
||||||
panel.setFrame(frames.hidden, display: false)
|
panel.setFrame(frames.hidden, display: false)
|
||||||
@@ -191,7 +240,7 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel
|
|||||||
panelView.beginOpeningTransition()
|
panelView.beginOpeningTransition()
|
||||||
|
|
||||||
NSAnimationContext.runAnimationGroup { context in
|
NSAnimationContext.runAnimationGroup { context in
|
||||||
context.duration = Animation.showDuration
|
context.duration = Animation.duration(Animation.showDuration)
|
||||||
context.allowsImplicitAnimation = true
|
context.allowsImplicitAnimation = true
|
||||||
context.timingFunction = CAMediaTimingFunction(name: Animation.easing)
|
context.timingFunction = CAMediaTimingFunction(name: Animation.easing)
|
||||||
panel.animator().setFrame(frames.shown, display: true)
|
panel.animator().setFrame(frames.shown, display: true)
|
||||||
@@ -202,7 +251,7 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel
|
|||||||
self.panelView.finishOpeningTransition()
|
self.panelView.finishOpeningTransition()
|
||||||
guard self.isVisible else { return }
|
guard self.isVisible else { return }
|
||||||
self.installClickMonitor()
|
self.installClickMonitor()
|
||||||
self.panelView.focusSearchField()
|
self.panelView.focusSelectedCardForKeyboardNavigation()
|
||||||
}
|
}
|
||||||
|
|
||||||
installKeyMonitor()
|
installKeyMonitor()
|
||||||
@@ -228,12 +277,13 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel
|
|||||||
let screenFrames = activeScreenSnapshot ?? activeScreenFrames()
|
let screenFrames = activeScreenSnapshot ?? activeScreenFrames()
|
||||||
let hidden = Self.panelFrames(
|
let hidden = Self.panelFrames(
|
||||||
forScreenFrame: screenFrames.screenFrame,
|
forScreenFrame: screenFrames.screenFrame,
|
||||||
visibleFrame: screenFrames.visibleFrame
|
visibleFrame: screenFrames.visibleFrame,
|
||||||
|
side: settings.panelSide
|
||||||
).hidden
|
).hidden
|
||||||
isAnimating = true
|
isAnimating = true
|
||||||
|
|
||||||
NSAnimationContext.runAnimationGroup { context in
|
NSAnimationContext.runAnimationGroup { context in
|
||||||
context.duration = Animation.hideDuration
|
context.duration = Animation.duration(Animation.hideDuration)
|
||||||
context.allowsImplicitAnimation = true
|
context.allowsImplicitAnimation = true
|
||||||
context.timingFunction = CAMediaTimingFunction(name: Animation.easing)
|
context.timingFunction = CAMediaTimingFunction(name: Animation.easing)
|
||||||
panel.animator().alphaValue = 0.0
|
panel.animator().alphaValue = 0.0
|
||||||
@@ -250,79 +300,145 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func performWhenVisible(_ action: @escaping () -> Void) {
|
||||||
|
if isVisible, !isAnimating {
|
||||||
|
action()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
show()
|
||||||
|
let animationDelay = Animation.duration(Animation.showDuration)
|
||||||
|
let deadline = DispatchTime.now() + animationDelay + (animationDelay > 0 ? 0.03 : 0)
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: deadline) { [weak self] in
|
||||||
|
guard let self, self.isVisible else { return }
|
||||||
|
action()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func windowDidResignKey(_ notification: Notification) {
|
func windowDidResignKey(_ notification: Notification) {
|
||||||
hide()
|
hide()
|
||||||
}
|
}
|
||||||
|
|
||||||
func windowDidBecomeKey(_ notification: Notification) {
|
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
|
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 panelFrames(forScreenFrame screenFrame: CGRect) -> (shown: NSRect, hidden: NSRect) {
|
static func selectedOpenScreen<Screen>(
|
||||||
return panelFrames(forScreenFrame: screenFrame, visibleFrame: screenFrame)
|
explicit: Screen?,
|
||||||
|
preferred: Screen?,
|
||||||
|
pointer: Screen?,
|
||||||
|
fallback: Screen?
|
||||||
|
) -> Screen? {
|
||||||
|
explicit ?? preferred ?? pointer ?? fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
static func panelFrames(forScreenFrame screenFrame: CGRect, visibleFrame: CGRect) -> (shown: NSRect, hidden: NSRect) {
|
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,
|
||||||
|
visibleFrame: CGRect,
|
||||||
|
side: ClipboardPanelSide = .right
|
||||||
|
) -> (shown: NSRect, hidden: NSRect) {
|
||||||
let intersectedFrame = visibleFrame.intersection(screenFrame)
|
let intersectedFrame = visibleFrame.intersection(screenFrame)
|
||||||
let effectiveFrame = intersectedFrame.width > 0 && intersectedFrame.height > 0 ? intersectedFrame : screenFrame
|
let effectiveFrame = intersectedFrame.width > 0 && intersectedFrame.height > 0 ? intersectedFrame : screenFrame
|
||||||
let frameHeight = effectiveFrame.height > 0 ? effectiveFrame.height : max(1, screenFrame.height)
|
return verticalPanelFrames(forScreenFrame: screenFrame, effectiveFrame: effectiveFrame, side: side)
|
||||||
let height = panelHeight(within: frameHeight)
|
}
|
||||||
let targetWidth = max(1, floor(effectiveFrame.width))
|
|
||||||
let shownMinX = effectiveFrame.minX
|
private static func verticalPanelFrames(
|
||||||
let shownMinY = max(screenFrame.minY, visibleFrame.minY)
|
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(
|
let shown = NSRect(
|
||||||
x: shownMinX,
|
x: shownX,
|
||||||
y: shownMinY,
|
y: screenFrame.minY,
|
||||||
width: targetWidth,
|
width: targetWidth,
|
||||||
height: height
|
height: targetHeight
|
||||||
)
|
)
|
||||||
let hidden = NSRect(
|
let hidden = NSRect(
|
||||||
x: shown.minX,
|
x: hiddenX,
|
||||||
y: shown.minY - height - 1,
|
y: shown.minY,
|
||||||
width: shown.width,
|
width: shown.width,
|
||||||
height: height
|
height: targetHeight
|
||||||
)
|
)
|
||||||
return (shown, hidden)
|
return (shown, hidden)
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func panelHeight(within visibleHeight: CGFloat) -> CGFloat {
|
private static func panelWidth(within visibleWidth: CGFloat) -> CGFloat {
|
||||||
let available = max(1, visibleHeight)
|
let available = max(1, visibleWidth)
|
||||||
let preferred = floor(available * Metrics.shelfHeightRatio)
|
let preferred = min(Metrics.preferredVerticalShelfWidth, floor(available * Metrics.maximumVerticalShelfWidthRatio))
|
||||||
let clamped = min(max(preferred, Metrics.minimumShelfHeight), Metrics.maximumShelfHeight)
|
return min(available, max(Metrics.minimumVerticalShelfWidth, preferred))
|
||||||
return min(available, clamped)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static func contentBottomInset(forScreenFrame screenFrame: CGRect, visibleFrame: CGRect) -> CGFloat {
|
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))
|
return max(Metrics.minimumBottomInset, min(Metrics.maximumBottomInset, dockInset + 2))
|
||||||
}
|
}
|
||||||
|
|
||||||
static var animationProfile: ClipboardPanelAnimationProfile {
|
private static func visibleBottomDockInset(forScreenFrame screenFrame: CGRect, visibleFrame: CGRect) -> CGFloat {
|
||||||
ClipboardPanelAnimationProfile(
|
let inset = max(0, visibleFrame.minY - screenFrame.minY)
|
||||||
showDuration: Animation.showDuration,
|
return inset > Metrics.hiddenDockRevealInsetLimit ? inset : 0
|
||||||
hideDuration: Animation.hideDuration,
|
|
||||||
reflowDuration: Animation.reflowDuration,
|
|
||||||
easing: Animation.easing
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static func reflowPlan(forScreenFrame screenFrame: CGRect, visibleFrame: CGRect) -> ClipboardPanelReflowPlan {
|
static var panelCollectionBehavior: NSWindow.CollectionBehavior {
|
||||||
let frames = panelFrames(forScreenFrame: screenFrame, visibleFrame: visibleFrame)
|
[.moveToActiveSpace, .fullScreenAuxiliary, .transient]
|
||||||
|
}
|
||||||
|
|
||||||
|
static func reflowPlan(
|
||||||
|
forScreenFrame screenFrame: CGRect,
|
||||||
|
visibleFrame: CGRect,
|
||||||
|
side: ClipboardPanelSide = .right
|
||||||
|
) -> ClipboardPanelReflowPlan {
|
||||||
|
let frames = panelFrames(
|
||||||
|
forScreenFrame: screenFrame,
|
||||||
|
visibleFrame: visibleFrame,
|
||||||
|
side: side
|
||||||
|
)
|
||||||
return ClipboardPanelReflowPlan(
|
return ClipboardPanelReflowPlan(
|
||||||
frame: frames.shown,
|
frame: frames.shown,
|
||||||
bottomSafeInset: contentBottomInset(forScreenFrame: screenFrame, visibleFrame: visibleFrame)
|
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() {
|
private func rememberTargetApplication() {
|
||||||
guard let frontmost = NSWorkspace.shared.frontmostApplication else {
|
guard let frontmost = NSWorkspace.shared.frontmostApplication else {
|
||||||
targetApplication = nil
|
targetApplication = nil
|
||||||
@@ -373,6 +489,15 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel
|
|||||||
self.viewModel.sortMode = mode
|
self.viewModel.sortMode = mode
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
if self.shouldHandlePanelKeyEvent(event, allowSearchFieldEditing: true),
|
||||||
|
Self.matchesShortcut(
|
||||||
|
keyCode: event.keyCode,
|
||||||
|
modifiers: event.modifierFlags,
|
||||||
|
binding: self.settings.settingsShortcut
|
||||||
|
) {
|
||||||
|
self.openSettings()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
if self.shouldHandlePanelKeyEvent(event, allowSearchFieldEditing: true),
|
if self.shouldHandlePanelKeyEvent(event, allowSearchFieldEditing: true),
|
||||||
let action = Self.commandShortcutAction(forKeyCode: event.keyCode, modifiers: event.modifierFlags) {
|
let action = Self.commandShortcutAction(forKeyCode: event.keyCode, modifiers: event.modifierFlags) {
|
||||||
self.performShortcutAction(action)
|
self.performShortcutAction(action)
|
||||||
@@ -383,6 +508,11 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel
|
|||||||
self.performShortcutAction(action)
|
self.performShortcutAction(action)
|
||||||
return nil
|
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 }
|
guard self.shouldHandlePanelKeyEvent(event) else { return event }
|
||||||
if let action = Self.navigationShortcutAction(forKeyCode: event.keyCode, modifiers: event.modifierFlags) {
|
if let action = Self.navigationShortcutAction(forKeyCode: event.keyCode, modifiers: event.modifierFlags) {
|
||||||
self.performNavigationAction(action)
|
self.performNavigationAction(action)
|
||||||
@@ -430,14 +560,47 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel
|
|||||||
panelView.focusSelectedCardForKeyboardNavigation()
|
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) {
|
private func performShortcutAction(_ action: ClipboardPanelShortcutAction) {
|
||||||
switch action {
|
switch action {
|
||||||
case .copy:
|
case .copy:
|
||||||
viewModel.copySelected()
|
viewModel.copySelected()
|
||||||
case .copyPlainText:
|
case .copyPlainText:
|
||||||
viewModel.copySelectedPlainText()
|
viewModel.copySelectedPlainText()
|
||||||
|
case .edit:
|
||||||
|
panelView.editSelectedClip()
|
||||||
|
case .focusSearch:
|
||||||
|
panelView.focusSearch()
|
||||||
case .newCollection:
|
case .newCollection:
|
||||||
panelView.createCollection()
|
panelView.createCollection()
|
||||||
|
case .nextCollection:
|
||||||
|
viewModel.selectAdjacentCollection(delta: 1)
|
||||||
case .open:
|
case .open:
|
||||||
viewModel.openSelected()
|
viewModel.openSelected()
|
||||||
case .pastePlainText:
|
case .pastePlainText:
|
||||||
@@ -446,17 +609,46 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel
|
|||||||
viewModel.pasteNextStackItem()
|
viewModel.pasteNextStackItem()
|
||||||
case .preview:
|
case .preview:
|
||||||
previewSelected()
|
previewSelected()
|
||||||
|
case .previousCollection:
|
||||||
|
viewModel.selectAdjacentCollection(delta: -1)
|
||||||
|
case .rename:
|
||||||
|
panelView.renameSelectedClip()
|
||||||
case .reveal:
|
case .reveal:
|
||||||
viewModel.revealSelected()
|
viewModel.revealSelected()
|
||||||
case .showInClipboard:
|
case .showInClipboard:
|
||||||
panelView.showSelectedInClipboard()
|
panelView.showSelectedInClipboard()
|
||||||
|
case .toggleCapturePause:
|
||||||
|
toggleCapturePauseFromShortcut()
|
||||||
case .toggleStack:
|
case .toggleStack:
|
||||||
viewModel.toggleSelectedStackMembership()
|
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() {
|
private func previewSelected() {
|
||||||
|
if let request = viewModel.linkPreviewRequestForSelected() {
|
||||||
|
quickLookURL = nil
|
||||||
|
QLPreviewPanel.shared()?.orderOut(nil)
|
||||||
|
showLinkPreview(request)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
guard let url = viewModel.previewURLForSelected() else { return }
|
guard let url = viewModel.previewURLForSelected() else { return }
|
||||||
|
linkPreviewController?.close()
|
||||||
quickLookURL = url
|
quickLookURL = url
|
||||||
guard let previewPanel = QLPreviewPanel.shared() else {
|
guard let previewPanel = QLPreviewPanel.shared() else {
|
||||||
NSWorkspace.shared.open(url)
|
NSWorkspace.shared.open(url)
|
||||||
@@ -468,6 +660,12 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel
|
|||||||
previewPanel.makeKeyAndOrderFront(nil)
|
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 {
|
private func shouldHandlePanelKeyEvent(_ event: NSEvent) -> Bool {
|
||||||
shouldHandlePanelKeyEvent(event, allowSearchFieldEditing: false)
|
shouldHandlePanelKeyEvent(event, allowSearchFieldEditing: false)
|
||||||
}
|
}
|
||||||
@@ -496,6 +694,13 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel
|
|||||||
|
|
||||||
static func navigationShortcutAction(forKeyCode keyCode: UInt16, modifiers: NSEvent.ModifierFlags) -> ClipboardPanelNavigationAction? {
|
static func navigationShortcutAction(forKeyCode keyCode: UInt16, modifiers: NSEvent.ModifierFlags) -> ClipboardPanelNavigationAction? {
|
||||||
let relevantModifiers = modifiers.intersection(.deviceIndependentFlagsMask)
|
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 }
|
guard relevantModifiers.isEmpty else { return nil }
|
||||||
switch keyCode {
|
switch keyCode {
|
||||||
case 115: return .first
|
case 115: return .first
|
||||||
@@ -508,6 +713,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? {
|
static func quickPastePlainTextIndex(forKeyCode keyCode: UInt16, modifiers: NSEvent.ModifierFlags) -> Int? {
|
||||||
let relevantModifiers = modifiers.intersection(.deviceIndependentFlagsMask)
|
let relevantModifiers = modifiers.intersection(.deviceIndependentFlagsMask)
|
||||||
guard relevantModifiers == [.command, .shift] else { return nil }
|
guard relevantModifiers == [.command, .shift] else { return nil }
|
||||||
@@ -525,12 +747,28 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel
|
|||||||
return keyCode == 49 && relevantModifiers.isEmpty && searchText.clipboardTrimmed.isEmpty
|
return keyCode == 49 && relevantModifiers.isEmpty && searchText.clipboardTrimmed.isEmpty
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static func matchesShortcut(
|
||||||
|
keyCode: UInt16,
|
||||||
|
modifiers: NSEvent.ModifierFlags,
|
||||||
|
binding: ShortcutBinding
|
||||||
|
) -> Bool {
|
||||||
|
guard ShortcutManager.virtualKeyCode(for: binding.key) == keyCode else { return false }
|
||||||
|
let relevantModifiers = modifiers.intersection(.deviceIndependentFlagsMask)
|
||||||
|
let bindingModifiers = NSEvent.ModifierFlags(rawValue: binding.modifierFlags)
|
||||||
|
.intersection(.deviceIndependentFlagsMask)
|
||||||
|
return relevantModifiers == bindingModifiers
|
||||||
|
}
|
||||||
|
|
||||||
static func commandShortcutAction(forKeyCode keyCode: UInt16, modifiers: NSEvent.ModifierFlags) -> ClipboardPanelShortcutAction? {
|
static func commandShortcutAction(forKeyCode keyCode: UInt16, modifiers: NSEvent.ModifierFlags) -> ClipboardPanelShortcutAction? {
|
||||||
let relevantModifiers = modifiers.intersection(.deviceIndependentFlagsMask)
|
let relevantModifiers = modifiers.intersection(.deviceIndependentFlagsMask)
|
||||||
guard relevantModifiers == .command else { return nil }
|
guard relevantModifiers == .command else { return nil }
|
||||||
switch keyCode {
|
switch keyCode {
|
||||||
case 8:
|
case 8:
|
||||||
return .copy
|
return .copy
|
||||||
|
case 3:
|
||||||
|
return .focusSearch
|
||||||
|
case 14:
|
||||||
|
return .edit
|
||||||
case 31:
|
case 31:
|
||||||
return .open
|
return .open
|
||||||
case 5:
|
case 5:
|
||||||
@@ -538,7 +776,15 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel
|
|||||||
case 16:
|
case 16:
|
||||||
return .preview
|
return .preview
|
||||||
case 15:
|
case 15:
|
||||||
return .reveal
|
return .rename
|
||||||
|
case 17:
|
||||||
|
return .toggleCapturePause
|
||||||
|
case 6:
|
||||||
|
return .undoDelete
|
||||||
|
case 123:
|
||||||
|
return .previousCollection
|
||||||
|
case 124:
|
||||||
|
return .nextCollection
|
||||||
default:
|
default:
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -546,12 +792,15 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel
|
|||||||
|
|
||||||
static func modifiedShortcutAction(forKeyCode keyCode: UInt16, modifiers: NSEvent.ModifierFlags) -> ClipboardPanelShortcutAction? {
|
static func modifiedShortcutAction(forKeyCode keyCode: UInt16, modifiers: NSEvent.ModifierFlags) -> ClipboardPanelShortcutAction? {
|
||||||
let relevantModifiers = modifiers.intersection(.deviceIndependentFlagsMask)
|
let relevantModifiers = modifiers.intersection(.deviceIndependentFlagsMask)
|
||||||
|
if relevantModifiers == .shift {
|
||||||
|
return keyCode == 36 ? .pastePlainText : nil
|
||||||
|
}
|
||||||
guard relevantModifiers == [.command, .shift] else { return nil }
|
guard relevantModifiers == [.command, .shift] else { return nil }
|
||||||
switch keyCode {
|
switch keyCode {
|
||||||
case 1:
|
case 1:
|
||||||
return .toggleStack
|
return .toggleStack
|
||||||
case 8:
|
case 8:
|
||||||
return .copyPlainText
|
return .toggleStackCapture
|
||||||
case 45:
|
case 45:
|
||||||
return .newCollection
|
return .newCollection
|
||||||
case 9:
|
case 9:
|
||||||
@@ -571,19 +820,6 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel
|
|||||||
quickLookURL as NSURL?
|
quickLookURL as NSURL?
|
||||||
}
|
}
|
||||||
|
|
||||||
#if DEBUG
|
|
||||||
var debugPanelFrame: NSRect {
|
|
||||||
panel.frame
|
|
||||||
}
|
|
||||||
|
|
||||||
var debugPanelAlpha: CGFloat {
|
|
||||||
panel.alphaValue
|
|
||||||
}
|
|
||||||
|
|
||||||
var debugIsAnimating: Bool {
|
|
||||||
isAnimating
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
private func installClickMonitor() {
|
private func installClickMonitor() {
|
||||||
removeClickMonitor()
|
removeClickMonitor()
|
||||||
@@ -611,15 +847,27 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel
|
|||||||
private func reflowPanelForScreenChange() {
|
private func reflowPanelForScreenChange() {
|
||||||
guard isVisible else { return }
|
guard isVisible else { return }
|
||||||
guard !isAnimating 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)
|
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)
|
panelView.setBottomSafeInset(plan.bottomSafeInset)
|
||||||
|
|
||||||
isAnimating = true
|
isAnimating = true
|
||||||
NSAnimationContext.runAnimationGroup { context in
|
NSAnimationContext.runAnimationGroup { context in
|
||||||
context.duration = Animation.reflowDuration
|
context.duration = Animation.duration(Animation.reflowDuration)
|
||||||
context.allowsImplicitAnimation = true
|
context.allowsImplicitAnimation = true
|
||||||
context.timingFunction = CAMediaTimingFunction(name: Animation.easing)
|
context.timingFunction = CAMediaTimingFunction(name: Animation.easing)
|
||||||
panel.animator().setFrame(plan.frame, display: true)
|
panel.animator().setFrame(plan.frame, display: true)
|
||||||
@@ -645,6 +893,11 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel
|
|||||||
let fallback = preferredScreen() ?? NSScreen.screens.first
|
let fallback = preferredScreen() ?? NSScreen.screens.first
|
||||||
return (fallback?.frame ?? .zero, fallback?.visibleFrame ?? .zero)
|
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 {
|
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
355
sources/clipbored/views/LinkPreviewWindowController.swift
Normal file
355
sources/clipbored/views/LinkPreviewWindowController.swift
Normal file
@@ -0,0 +1,355 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
358
sources/clipbored/views/OnboardingWindowController.swift
Normal file
358
sources/clipbored/views/OnboardingWindowController.swift
Normal file
@@ -0,0 +1,358 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -78,9 +78,14 @@ final class AppDelegateTests: XCTestCase {
|
|||||||
"Captured text from Safari.",
|
"Captured text from Safari.",
|
||||||
"-",
|
"-",
|
||||||
"Show Clipboard",
|
"Show Clipboard",
|
||||||
|
"New Collection",
|
||||||
|
"Stack Capture",
|
||||||
settingsTitle,
|
settingsTitle,
|
||||||
"-",
|
"-",
|
||||||
"Pause Capture",
|
"Pause Capture",
|
||||||
|
"Pause for 5 Minutes",
|
||||||
|
"Pause for 30 Minutes",
|
||||||
|
"Pause for 1 Hour",
|
||||||
"-",
|
"-",
|
||||||
"Quit ClipBored"
|
"Quit ClipBored"
|
||||||
]
|
]
|
||||||
@@ -91,9 +96,25 @@ final class AppDelegateTests: XCTestCase {
|
|||||||
XCTAssertTrue(showClipboard?.keyEquivalentModifierMask.contains(.command) == true)
|
XCTAssertTrue(showClipboard?.keyEquivalentModifierMask.contains(.command) == true)
|
||||||
XCTAssertTrue(showClipboard?.keyEquivalentModifierMask.contains(.option) == 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 }
|
let settings = menu.items.first { $0.title == settingsTitle }
|
||||||
XCTAssertEqual(settings?.keyEquivalent, ",")
|
XCTAssertEqual(settings?.keyEquivalent, ",")
|
||||||
XCTAssertTrue(settings?.keyEquivalentModifierMask.contains(.command) == true)
|
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() {
|
func testStatusMenuPausedStateTakesPriorityOverOlderCaptureStatus() {
|
||||||
@@ -116,8 +137,55 @@ final class AppDelegateTests: XCTestCase {
|
|||||||
|
|
||||||
XCTAssertEqual(presentation.summary, "Capture Paused - 1 clip")
|
XCTAssertEqual(presentation.summary, "Capture Paused - 1 clip")
|
||||||
XCTAssertEqual(presentation.detail, "Capture is paused.")
|
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 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() {
|
func testStatusMenuPresentationTruncatesLongStatusText() {
|
||||||
|
|||||||
@@ -91,6 +91,32 @@ final class ClipboardCacheServiceTests: XCTestCase {
|
|||||||
XCTAssertNotNil(cacheService.previewThumbnail(for: pdfItem(path: path)))
|
XCTAssertNotNil(cacheService.previewThumbnail(for: pdfItem(path: path)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testPreviewThumbnailUsesDecryptedVideoTemporaryCopyAndCachesResult() throws {
|
||||||
|
let baseURL = try makeTempDirectory()
|
||||||
|
let videoData = Data([0, 0, 0, 24, 102, 116, 121, 112, 109, 112, 52, 50])
|
||||||
|
var providerURLs: [URL] = []
|
||||||
|
let cacheService = ClipboardCacheService(
|
||||||
|
baseURL: baseURL,
|
||||||
|
encryptionService: fixedEncryptionService(),
|
||||||
|
videoThumbnailProvider: { url in
|
||||||
|
providerURLs.append(url)
|
||||||
|
XCTAssertEqual(url.pathExtension, "mp4")
|
||||||
|
XCTAssertEqual(try? Data(contentsOf: url), videoData)
|
||||||
|
return self.makeImage(color: .systemIndigo)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
let path = try XCTUnwrap(cacheService.cacheVideo(videoData, id: UUID(), fileExtension: "mp4"))
|
||||||
|
let item = videoItem(path: path)
|
||||||
|
|
||||||
|
let thumbnail = cacheService.previewThumbnail(for: item)
|
||||||
|
let cachedThumbnail = cacheService.previewThumbnail(for: item)
|
||||||
|
|
||||||
|
XCTAssertNotNil(thumbnail)
|
||||||
|
XCTAssertNotNil(cachedThumbnail)
|
||||||
|
XCTAssertEqual(providerURLs.count, 1)
|
||||||
|
XCTAssertFalse(FileManager.default.fileExists(atPath: try XCTUnwrap(providerURLs.first).path))
|
||||||
|
}
|
||||||
|
|
||||||
func testPDFCacheFilesAreEncryptedAndReadable() throws {
|
func testPDFCacheFilesAreEncryptedAndReadable() throws {
|
||||||
let baseURL = try makeTempDirectory()
|
let baseURL = try makeTempDirectory()
|
||||||
let cacheService = ClipboardCacheService(baseURL: baseURL, encryptionService: fixedEncryptionService())
|
let cacheService = ClipboardCacheService(baseURL: baseURL, encryptionService: fixedEncryptionService())
|
||||||
@@ -119,6 +145,20 @@ final class ClipboardCacheServiceTests: XCTestCase {
|
|||||||
XCTAssertEqual(try posixPermissions(URL(fileURLWithPath: path)), 0o600)
|
XCTAssertEqual(try posixPermissions(URL(fileURLWithPath: path)), 0o600)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testVideoCacheFilesAreEncryptedAndReadable() throws {
|
||||||
|
let baseURL = try makeTempDirectory()
|
||||||
|
let cacheService = ClipboardCacheService(baseURL: baseURL, encryptionService: fixedEncryptionService())
|
||||||
|
let videoData = Data([0, 0, 0, 24, 102, 116, 121, 112, 109, 112, 52, 50])
|
||||||
|
|
||||||
|
let path = try XCTUnwrap(cacheService.cacheVideo(videoData, id: UUID(), fileExtension: "mp4"))
|
||||||
|
let rawVideo = try Data(contentsOf: URL(fileURLWithPath: path))
|
||||||
|
|
||||||
|
XCTAssertTrue(ClipboardEncryptionService.isProtected(rawVideo))
|
||||||
|
XCTAssertNotEqual(rawVideo, videoData)
|
||||||
|
XCTAssertEqual(cacheService.data(for: path), videoData)
|
||||||
|
XCTAssertEqual(try posixPermissions(URL(fileURLWithPath: path)), 0o600)
|
||||||
|
}
|
||||||
|
|
||||||
func testRichTextCacheFilesAreEncryptedAndReadable() throws {
|
func testRichTextCacheFilesAreEncryptedAndReadable() throws {
|
||||||
let baseURL = try makeTempDirectory()
|
let baseURL = try makeTempDirectory()
|
||||||
let cacheService = ClipboardCacheService(baseURL: baseURL, encryptionService: fixedEncryptionService())
|
let cacheService = ClipboardCacheService(baseURL: baseURL, encryptionService: fixedEncryptionService())
|
||||||
@@ -178,6 +218,19 @@ final class ClipboardCacheServiceTests: XCTestCase {
|
|||||||
XCTAssertEqual(try posixPermissions(previewURL), 0o600)
|
XCTAssertEqual(try posixPermissions(previewURL), 0o600)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testTemporaryReadableURLWorksForVideo() throws {
|
||||||
|
let baseURL = try makeTempDirectory()
|
||||||
|
let cacheService = ClipboardCacheService(baseURL: baseURL, encryptionService: fixedEncryptionService())
|
||||||
|
let videoData = Data([0, 0, 0, 24, 102, 116, 121, 112, 109, 112, 52, 50])
|
||||||
|
let path = try XCTUnwrap(cacheService.cacheVideo(videoData, id: UUID(), fileExtension: "mp4"))
|
||||||
|
|
||||||
|
let previewURL = try XCTUnwrap(cacheService.temporaryReadableURL(for: videoItem(path: path)))
|
||||||
|
|
||||||
|
XCTAssertEqual(try Data(contentsOf: previewURL), videoData)
|
||||||
|
XCTAssertEqual(previewURL.pathExtension, "mp4")
|
||||||
|
XCTAssertEqual(try posixPermissions(previewURL), 0o600)
|
||||||
|
}
|
||||||
|
|
||||||
func testTemporaryReadableURLWorksForRichText() throws {
|
func testTemporaryReadableURLWorksForRichText() throws {
|
||||||
let baseURL = try makeTempDirectory()
|
let baseURL = try makeTempDirectory()
|
||||||
let cacheService = ClipboardCacheService(baseURL: baseURL, encryptionService: fixedEncryptionService())
|
let cacheService = ClipboardCacheService(baseURL: baseURL, encryptionService: fixedEncryptionService())
|
||||||
@@ -325,6 +378,22 @@ final class ClipboardCacheServiceTests: XCTestCase {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func videoItem(path: String) -> ClipboardItem {
|
||||||
|
ClipboardItem(
|
||||||
|
id: UUID(),
|
||||||
|
kind: .video,
|
||||||
|
displayText: "Video",
|
||||||
|
payload: path,
|
||||||
|
payloadHash: "hash",
|
||||||
|
createdAt: Date(),
|
||||||
|
lastUsedAt: Date(),
|
||||||
|
useCount: 0,
|
||||||
|
sourceApp: nil,
|
||||||
|
imagePath: nil,
|
||||||
|
thumbnailPath: nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private func richTextItem(path: String) -> ClipboardItem {
|
private func richTextItem(path: String) -> ClipboardItem {
|
||||||
ClipboardItem(
|
ClipboardItem(
|
||||||
id: UUID(),
|
id: UUID(),
|
||||||
@@ -346,7 +415,7 @@ final class ClipboardCacheServiceTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func makeImage(color: NSColor) -> NSImage {
|
private func makeImage(color: NSColor) -> NSImage {
|
||||||
let size = NSSize(width: 24, height: 24)
|
let size = NSSize(width: 64, height: 40)
|
||||||
let image = NSImage(size: size)
|
let image = NSImage(size: size)
|
||||||
image.lockFocus()
|
image.lockFocus()
|
||||||
color.setFill()
|
color.setFill()
|
||||||
|
|||||||
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))
|
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 {
|
private func makeService(byte: UInt8) -> ClipboardEncryptionService {
|
||||||
let keyData = Data(repeating: byte, count: 32)
|
let keyData = Data(repeating: byte, count: 32)
|
||||||
return ClipboardEncryptionService(keyProvider: { SymmetricKey(data: keyData) })
|
return ClipboardEncryptionService(keyProvider: { SymmetricKey(data: keyData) })
|
||||||
|
|||||||
@@ -20,10 +20,11 @@ final class ClipboardMonitorServiceTests: XCTestCase {
|
|||||||
func testClampedIntervalEnforcesResponsiveMinimum() {
|
func testClampedIntervalEnforcesResponsiveMinimum() {
|
||||||
let settings = SettingsModel(defaults: makeTestDefaults())
|
let settings = SettingsModel(defaults: makeTestDefaults())
|
||||||
settings.pollProfile = .responsive
|
settings.pollProfile = .responsive
|
||||||
|
let (store, cacheService) = makeStoreAndCache(settings: settings)
|
||||||
|
|
||||||
let monitor = ClipboardMonitorService(
|
let monitor = ClipboardMonitorService(
|
||||||
store: makeStore(settings: settings),
|
store: store,
|
||||||
cacheService: ClipboardCacheService(),
|
cacheService: cacheService,
|
||||||
settings: settings
|
settings: settings
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -36,10 +37,11 @@ final class ClipboardMonitorServiceTests: XCTestCase {
|
|||||||
func testClampedIntervalDoesNotIncreaseBalancedProfileWindow() {
|
func testClampedIntervalDoesNotIncreaseBalancedProfileWindow() {
|
||||||
let settings = SettingsModel(defaults: makeTestDefaults())
|
let settings = SettingsModel(defaults: makeTestDefaults())
|
||||||
settings.pollProfile = .balanced
|
settings.pollProfile = .balanced
|
||||||
|
let (store, cacheService) = makeStoreAndCache(settings: settings)
|
||||||
|
|
||||||
let monitor = ClipboardMonitorService(
|
let monitor = ClipboardMonitorService(
|
||||||
store: makeStore(settings: settings),
|
store: store,
|
||||||
cacheService: ClipboardCacheService(),
|
cacheService: cacheService,
|
||||||
settings: settings
|
settings: settings
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -108,6 +110,81 @@ final class ClipboardMonitorServiceTests: XCTestCase {
|
|||||||
XCTAssertEqual(store.items.filter { $0.payload == text }.count, 1)
|
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
|
||||||
|
let (store, cacheService) = makeStoreAndCache(settings: settings)
|
||||||
|
let monitor = ClipboardMonitorService(
|
||||||
|
store: store,
|
||||||
|
cacheService: cacheService,
|
||||||
|
settings: settings
|
||||||
|
)
|
||||||
|
let snippet = "func greet(name: String) -> String {\n return \"Hi \\(name)\"\n}"
|
||||||
|
|
||||||
|
let captured = expectation(description: "code snippet captured")
|
||||||
|
store.observeItems { items in
|
||||||
|
if items.contains(where: { $0.kind == .code && $0.payload == snippet }) {
|
||||||
|
captured.fulfill()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let pasteboard = NSPasteboard.general
|
||||||
|
pasteboard.clearContents()
|
||||||
|
XCTAssertTrue(pasteboard.setString(snippet, forType: .string))
|
||||||
|
|
||||||
|
monitor.pollNowAndWait()
|
||||||
|
wait(for: [captured], timeout: 1.0)
|
||||||
|
|
||||||
|
let item = store.items.first
|
||||||
|
XCTAssertEqual(item?.kind, .code)
|
||||||
|
XCTAssertEqual(item?.displayText, "Swift Snippet")
|
||||||
|
XCTAssertEqual(item?.payload, snippet)
|
||||||
|
}
|
||||||
|
|
||||||
func testPollNowIgnoresClipBoredPasteboardWrites() {
|
func testPollNowIgnoresClipBoredPasteboardWrites() {
|
||||||
let settings = SettingsModel(defaults: makeTestDefaults())
|
let settings = SettingsModel(defaults: makeTestDefaults())
|
||||||
let (store, cacheService) = makeStoreAndCache(settings: settings)
|
let (store, cacheService) = makeStoreAndCache(settings: settings)
|
||||||
@@ -126,7 +203,7 @@ final class ClipboardMonitorServiceTests: XCTestCase {
|
|||||||
thumbnailPath: nil
|
thumbnailPath: nil
|
||||||
)
|
)
|
||||||
|
|
||||||
XCTAssertEqual(PasteActionService().copy(item), .copied)
|
XCTAssertEqual(PasteActionService(cacheService: cacheService).copy(item), .copied)
|
||||||
monitor.pollNowAndWait()
|
monitor.pollNowAndWait()
|
||||||
RunLoop.main.run(until: Date().addingTimeInterval(0.05))
|
RunLoop.main.run(until: Date().addingTimeInterval(0.05))
|
||||||
|
|
||||||
@@ -187,6 +264,63 @@ final class ClipboardMonitorServiceTests: XCTestCase {
|
|||||||
XCTAssertEqual(NSPasteboard.general.data(forType: .sound), audioData)
|
XCTAssertEqual(NSPasteboard.general.data(forType: .sound), audioData)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testPollNowCapturesVideoAsRestorableAttachment() throws {
|
||||||
|
let settings = SettingsModel(defaults: makeTestDefaults())
|
||||||
|
let (store, cacheService) = makeStoreAndCache(settings: settings)
|
||||||
|
let monitor = ClipboardMonitorService(store: store, cacheService: cacheService, settings: settings)
|
||||||
|
let videoData = Data([0, 0, 0, 24, 102, 116, 121, 112, 109, 112, 52, 50])
|
||||||
|
let captured = expectation(description: "video captured")
|
||||||
|
|
||||||
|
store.observeItems { items in
|
||||||
|
if items.contains(where: { $0.kind == .video }) {
|
||||||
|
captured.fulfill()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let pasteboard = NSPasteboard.general
|
||||||
|
pasteboard.clearContents()
|
||||||
|
XCTAssertTrue(pasteboard.setData(videoData, forType: VideoPayload.pasteboardTypes[0]))
|
||||||
|
|
||||||
|
monitor.pollNowAndWait()
|
||||||
|
wait(for: [captured], timeout: 1.0)
|
||||||
|
|
||||||
|
let item = try XCTUnwrap(store.items.first(where: { $0.kind == .video }))
|
||||||
|
XCTAssertTrue(FileManager.default.fileExists(atPath: item.payload))
|
||||||
|
XCTAssertEqual(item.displayText, VideoPayload.displayTitle(byteCount: videoData.count))
|
||||||
|
XCTAssertEqual(cacheService.data(for: item.payload), videoData)
|
||||||
|
XCTAssertEqual(PasteActionService(cacheService: cacheService).copy(item), .copied)
|
||||||
|
XCTAssertEqual(NSPasteboard.general.data(forType: VideoPayload.pasteboardTypes[0]), videoData)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPollNowCapturesColorAsRestorableSwatch() throws {
|
||||||
|
let settings = SettingsModel(defaults: makeTestDefaults())
|
||||||
|
let (store, cacheService) = makeStoreAndCache(settings: settings)
|
||||||
|
let monitor = ClipboardMonitorService(store: store, cacheService: cacheService, settings: settings)
|
||||||
|
let color = NSColor(deviceRed: 10 / 255, green: 132 / 255, blue: 255 / 255, alpha: 1)
|
||||||
|
let captured = expectation(description: "color captured")
|
||||||
|
|
||||||
|
store.observeItems { items in
|
||||||
|
if items.contains(where: { $0.kind == .color && $0.payload == "#0A84FF" }) {
|
||||||
|
captured.fulfill()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let pasteboard = NSPasteboard.general
|
||||||
|
pasteboard.clearContents()
|
||||||
|
XCTAssertTrue(pasteboard.writeObjects([color]))
|
||||||
|
|
||||||
|
monitor.pollNowAndWait()
|
||||||
|
wait(for: [captured], timeout: 1.0)
|
||||||
|
|
||||||
|
let item = try XCTUnwrap(store.items.first(where: { $0.kind == .color }))
|
||||||
|
XCTAssertEqual(item.displayText, "#0A84FF")
|
||||||
|
XCTAssertEqual(item.payload, "#0A84FF")
|
||||||
|
XCTAssertEqual(PasteActionService(cacheService: cacheService).copy(item), .copied)
|
||||||
|
let restored = try XCTUnwrap(NSColor(from: NSPasteboard.general))
|
||||||
|
XCTAssertEqual(ColorPayload.hexString(from: restored), "#0A84FF")
|
||||||
|
XCTAssertEqual(NSPasteboard.general.string(forType: .string), "#0A84FF")
|
||||||
|
}
|
||||||
|
|
||||||
func testPollNowCapturesFileReference() throws {
|
func testPollNowCapturesFileReference() throws {
|
||||||
let settings = SettingsModel(defaults: makeTestDefaults())
|
let settings = SettingsModel(defaults: makeTestDefaults())
|
||||||
let (store, cacheService) = makeStoreAndCache(settings: settings)
|
let (store, cacheService) = makeStoreAndCache(settings: settings)
|
||||||
@@ -592,6 +726,42 @@ final class ClipboardMonitorServiceTests: XCTestCase {
|
|||||||
XCTAssertTrue(try imageCacheFileURLs(in: baseURL).isEmpty)
|
XCTAssertTrue(try imageCacheFileURLs(in: baseURL).isEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testIgnoredColorKindDoesNotCaptureSwatch() throws {
|
||||||
|
let settings = SettingsModel(defaults: makeTestDefaults())
|
||||||
|
settings.ignoredItemKindsRaw = [ClipboardItemKind.color.rawValue]
|
||||||
|
let (store, cacheService, _) = makeStoreCacheAndBaseURL(settings: settings)
|
||||||
|
let monitor = ClipboardMonitorService(store: store, cacheService: cacheService, settings: settings)
|
||||||
|
let color = NSColor(deviceRed: 10 / 255, green: 132 / 255, blue: 255 / 255, alpha: 1)
|
||||||
|
|
||||||
|
let pasteboard = NSPasteboard.general
|
||||||
|
pasteboard.clearContents()
|
||||||
|
XCTAssertTrue(pasteboard.writeObjects([color]))
|
||||||
|
|
||||||
|
monitor.pollNowAndWait()
|
||||||
|
RunLoop.main.run(until: Date().addingTimeInterval(0.05))
|
||||||
|
|
||||||
|
XCTAssertTrue(store.items.isEmpty)
|
||||||
|
XCTAssertEqual(settings.captureStatusMessage, "Skipped: Color items are ignored in capture settings.")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testIgnoredCodeKindDoesNotCaptureSnippet() throws {
|
||||||
|
let settings = SettingsModel(defaults: makeTestDefaults())
|
||||||
|
settings.ignoredItemKindsRaw = [ClipboardItemKind.code.rawValue]
|
||||||
|
let (store, cacheService, _) = makeStoreCacheAndBaseURL(settings: settings)
|
||||||
|
let monitor = ClipboardMonitorService(store: store, cacheService: cacheService, settings: settings)
|
||||||
|
let snippet = "const title = \"ClipBored\";\nreturn title.toUpperCase();"
|
||||||
|
|
||||||
|
let pasteboard = NSPasteboard.general
|
||||||
|
pasteboard.clearContents()
|
||||||
|
XCTAssertTrue(pasteboard.setString(snippet, forType: .string))
|
||||||
|
|
||||||
|
monitor.pollNowAndWait()
|
||||||
|
RunLoop.main.run(until: Date().addingTimeInterval(0.05))
|
||||||
|
|
||||||
|
XCTAssertTrue(store.items.isEmpty)
|
||||||
|
XCTAssertEqual(settings.captureStatusMessage, "Skipped: Code items are ignored in capture settings.")
|
||||||
|
}
|
||||||
|
|
||||||
func testIgnoredPDFKindDoesNotWriteAttachmentFiles() throws {
|
func testIgnoredPDFKindDoesNotWriteAttachmentFiles() throws {
|
||||||
let settings = SettingsModel(defaults: makeTestDefaults())
|
let settings = SettingsModel(defaults: makeTestDefaults())
|
||||||
settings.ignoredItemKindsRaw = [ClipboardItemKind.pdf.rawValue]
|
settings.ignoredItemKindsRaw = [ClipboardItemKind.pdf.rawValue]
|
||||||
@@ -630,6 +800,25 @@ final class ClipboardMonitorServiceTests: XCTestCase {
|
|||||||
XCTAssertEqual(settings.captureStatusMessage, "Skipped: Audio items are ignored in capture settings.")
|
XCTAssertEqual(settings.captureStatusMessage, "Skipped: Audio items are ignored in capture settings.")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testIgnoredVideoKindDoesNotWriteAttachmentFiles() throws {
|
||||||
|
let settings = SettingsModel(defaults: makeTestDefaults())
|
||||||
|
settings.ignoredItemKindsRaw = [ClipboardItemKind.video.rawValue]
|
||||||
|
let (store, cacheService, baseURL) = makeStoreCacheAndBaseURL(settings: settings)
|
||||||
|
let monitor = ClipboardMonitorService(store: store, cacheService: cacheService, settings: settings)
|
||||||
|
|
||||||
|
let pasteboard = NSPasteboard.general
|
||||||
|
pasteboard.clearContents()
|
||||||
|
XCTAssertTrue(pasteboard.setData(Data([0, 0, 0, 24, 102, 116, 121, 112]), forType: VideoPayload.pasteboardTypes[0]))
|
||||||
|
|
||||||
|
monitor.pollNowAndWait()
|
||||||
|
RunLoop.main.run(until: Date().addingTimeInterval(0.05))
|
||||||
|
cacheService.flushForTesting()
|
||||||
|
|
||||||
|
XCTAssertTrue(store.items.isEmpty)
|
||||||
|
XCTAssertTrue(try attachmentFileURLs(in: baseURL).isEmpty)
|
||||||
|
XCTAssertEqual(settings.captureStatusMessage, "Skipped: Video items are ignored in capture settings.")
|
||||||
|
}
|
||||||
|
|
||||||
func testIgnoredRichTextKindDoesNotWriteHTMLAttachmentFiles() throws {
|
func testIgnoredRichTextKindDoesNotWriteHTMLAttachmentFiles() throws {
|
||||||
let settings = SettingsModel(defaults: makeTestDefaults())
|
let settings = SettingsModel(defaults: makeTestDefaults())
|
||||||
settings.ignoredItemKindsRaw = [ClipboardItemKind.richText.rawValue]
|
settings.ignoredItemKindsRaw = [ClipboardItemKind.richText.rawValue]
|
||||||
@@ -655,7 +844,9 @@ final class ClipboardMonitorServiceTests: XCTestCase {
|
|||||||
private func makeTestDefaults() -> UserDefaults {
|
private func makeTestDefaults() -> UserDefaults {
|
||||||
let suiteName = "com.clipbored.testmonitor.\(UUID().uuidString)"
|
let suiteName = "com.clipbored.testmonitor.\(UUID().uuidString)"
|
||||||
suiteNames.append(suiteName)
|
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 {
|
private func makeStore(settings: SettingsModel) -> ClipboardStore {
|
||||||
|
|||||||
@@ -1,16 +1,63 @@
|
|||||||
|
import AppKit
|
||||||
|
import Carbon
|
||||||
import XCTest
|
import XCTest
|
||||||
@testable import ClipBored
|
@testable import ClipBored
|
||||||
|
|
||||||
final class ClipboardPanelControllerTests: XCTestCase {
|
final class ClipboardPanelControllerTests: XCTestCase {
|
||||||
func testPanelFrameUsesFullWidthBottomShelf() {
|
func testPanelFrameUsesRightSideShelfByDefault() {
|
||||||
let screenFrame = CGRect(x: -1200, y: -200, width: 1200, height: 800)
|
let screenFrame = CGRect(x: -1200, y: -200, width: 1200, height: 800)
|
||||||
let frames = ClipboardPanelController.panelFrames(forScreenFrame: screenFrame, visibleFrame: screenFrame)
|
let frames = ClipboardPanelController.panelFrames(forScreenFrame: screenFrame, visibleFrame: screenFrame)
|
||||||
|
|
||||||
XCTAssertEqual(frames.shown.minX, screenFrame.minX)
|
|
||||||
XCTAssertEqual(frames.shown.maxX, screenFrame.maxX)
|
XCTAssertEqual(frames.shown.maxX, screenFrame.maxX)
|
||||||
|
XCTAssertEqual(frames.shown.width, 336)
|
||||||
XCTAssertEqual(frames.shown.minY, screenFrame.minY)
|
XCTAssertEqual(frames.shown.minY, screenFrame.minY)
|
||||||
XCTAssertEqual(frames.shown.height, 408)
|
XCTAssertEqual(frames.shown.maxY, screenFrame.maxY)
|
||||||
XCTAssertLessThan(frames.hidden.maxY, screenFrame.minY)
|
XCTAssertEqual(frames.hidden.minX, screenFrame.maxX + 1)
|
||||||
|
XCTAssertEqual(frames.hidden.minY, frames.shown.minY)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testOpenScreenSelectionPrefersExplicitThenPointerScreen() {
|
||||||
|
XCTAssertEqual(
|
||||||
|
ClipboardPanelController.selectedOpenScreen(
|
||||||
|
explicit: "status-item-screen",
|
||||||
|
preferred: nil,
|
||||||
|
pointer: "pointer-screen",
|
||||||
|
fallback: "fallback-screen"
|
||||||
|
),
|
||||||
|
"status-item-screen"
|
||||||
|
)
|
||||||
|
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() {
|
func testPanelFrameUsesVisibleFrameAroundDock() {
|
||||||
@@ -19,33 +66,48 @@ final class ClipboardPanelControllerTests: XCTestCase {
|
|||||||
|
|
||||||
let frames = ClipboardPanelController.panelFrames(forScreenFrame: screenFrame, visibleFrame: visibleFrame)
|
let frames = ClipboardPanelController.panelFrames(forScreenFrame: screenFrame, visibleFrame: visibleFrame)
|
||||||
|
|
||||||
XCTAssertEqual(frames.shown.minX, visibleFrame.minX)
|
|
||||||
XCTAssertEqual(frames.shown.maxX, visibleFrame.maxX)
|
XCTAssertEqual(frames.shown.maxX, visibleFrame.maxX)
|
||||||
XCTAssertEqual(frames.shown.minY, visibleFrame.minY)
|
XCTAssertEqual(frames.shown.minX, visibleFrame.maxX - 336)
|
||||||
XCTAssertEqual(frames.shown.height, 408)
|
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 screenFrame = CGRect(x: 0, y: 0, width: 1512, height: 982)
|
||||||
let visibleFrame = CGRect(x: 0, y: 96, width: 1512, height: 861)
|
let visibleFrame = CGRect(x: 0, y: 96, width: 1512, height: 861)
|
||||||
|
|
||||||
let frames = ClipboardPanelController.panelFrames(forScreenFrame: screenFrame, visibleFrame: visibleFrame)
|
let frames = ClipboardPanelController.panelFrames(forScreenFrame: screenFrame, visibleFrame: visibleFrame)
|
||||||
|
|
||||||
XCTAssertEqual(frames.shown.minX, visibleFrame.minX)
|
|
||||||
XCTAssertEqual(frames.shown.maxX, visibleFrame.maxX)
|
XCTAssertEqual(frames.shown.maxX, visibleFrame.maxX)
|
||||||
XCTAssertEqual(frames.shown.minY, visibleFrame.minY)
|
XCTAssertEqual(frames.shown.minY, screenFrame.minY)
|
||||||
XCTAssertEqual(frames.shown.height, 408)
|
XCTAssertEqual(frames.shown.maxY, visibleFrame.maxY)
|
||||||
XCTAssertLessThan(frames.hidden.maxY, visibleFrame.minY)
|
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 screenFrame = CGRect(x: 0, y: 0, width: 3008, height: 2000)
|
||||||
|
|
||||||
let frames = ClipboardPanelController.panelFrames(forScreenFrame: screenFrame, visibleFrame: screenFrame)
|
let frames = ClipboardPanelController.panelFrames(forScreenFrame: screenFrame, visibleFrame: screenFrame)
|
||||||
|
|
||||||
XCTAssertEqual(frames.shown.width, 3008)
|
XCTAssertEqual(frames.shown.width, 336)
|
||||||
XCTAssertEqual(frames.shown.height, 430)
|
XCTAssertEqual(frames.shown.height, 2000)
|
||||||
XCTAssertEqual(frames.shown.minY, screenFrame.minY)
|
XCTAssertEqual(frames.shown.minY, screenFrame.minY)
|
||||||
|
XCTAssertEqual(frames.shown.maxX, screenFrame.maxX)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testPanelFrameFitsTinyVisibleFrameWithoutOverflowing() {
|
func testPanelFrameFitsTinyVisibleFrameWithoutOverflowing() {
|
||||||
@@ -54,114 +116,140 @@ final class ClipboardPanelControllerTests: XCTestCase {
|
|||||||
|
|
||||||
let frames = ClipboardPanelController.panelFrames(forScreenFrame: screenFrame, visibleFrame: visibleFrame)
|
let frames = ClipboardPanelController.panelFrames(forScreenFrame: screenFrame, visibleFrame: visibleFrame)
|
||||||
|
|
||||||
XCTAssertEqual(frames.shown.minY, visibleFrame.minY)
|
XCTAssertEqual(frames.shown.minY, screenFrame.minY)
|
||||||
XCTAssertEqual(frames.shown.height, visibleFrame.height)
|
XCTAssertEqual(frames.shown.maxY, visibleFrame.maxY)
|
||||||
XCTAssertLessThan(frames.hidden.maxY, visibleFrame.minY)
|
XCTAssertEqual(frames.shown.width, 320)
|
||||||
|
XCTAssertLessThanOrEqual(frames.shown.width, visibleFrame.width)
|
||||||
|
XCTAssertEqual(frames.hidden.minX, screenFrame.maxX + 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testPanelFramePlanningIsDeterministicAcrossRepeatedToggles() {
|
func testPanelCollectionBehaviorStaysLocalToActiveSpaceAndSupportsFullscreen() {
|
||||||
|
let behavior = ClipboardPanelController.panelCollectionBehavior
|
||||||
|
|
||||||
|
XCTAssertTrue(behavior.contains(.moveToActiveSpace))
|
||||||
|
XCTAssertTrue(behavior.contains(.fullScreenAuxiliary))
|
||||||
|
XCTAssertTrue(behavior.contains(.transient))
|
||||||
|
XCTAssertFalse(behavior.contains(.canJoinAllSpaces))
|
||||||
|
}
|
||||||
|
|
||||||
|
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 screenFrame = CGRect(x: -1512, y: -120, width: 1512, height: 982)
|
||||||
let visibleFrame = CGRect(x: -1512, y: -24, width: 1512, height: 861)
|
|
||||||
let first = ClipboardPanelController.panelFrames(forScreenFrame: screenFrame, visibleFrame: visibleFrame)
|
|
||||||
|
|
||||||
for _ in 0..<50 {
|
let frames = ClipboardPanelController.panelFrames(
|
||||||
let frames = ClipboardPanelController.panelFrames(forScreenFrame: screenFrame, visibleFrame: visibleFrame)
|
forScreenFrame: screenFrame,
|
||||||
XCTAssertEqual(frames.shown, first.shown)
|
visibleFrame: screenFrame,
|
||||||
XCTAssertEqual(frames.hidden, first.hidden)
|
side: .right
|
||||||
XCTAssertEqual(frames.hidden.maxY, frames.shown.minY - 1)
|
)
|
||||||
}
|
|
||||||
|
XCTAssertEqual(frames.shown.minY, screenFrame.minY)
|
||||||
|
XCTAssertEqual(frames.shown.maxY, screenFrame.maxY)
|
||||||
|
XCTAssertEqual(frames.shown.maxX, screenFrame.maxX)
|
||||||
|
XCTAssertEqual(frames.shown.width, 336)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testPanelAnimationProfileStaysShortForSixtyFpsFeel() {
|
func testContentBottomInsetHandlesBottomAndSideDock() {
|
||||||
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.easing, .easeInEaseOut)
|
|
||||||
}
|
|
||||||
|
|
||||||
func testReflowPlanMovesOpenPanelAboveNewBottomDockVisibleFrame() {
|
|
||||||
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.bottomSafeInset, 20)
|
|
||||||
}
|
|
||||||
|
|
||||||
func testReflowPlanTracksSideDockVisibleWidthWithoutBottomInsetInflation() {
|
|
||||||
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.bottomSafeInset, 18)
|
|
||||||
}
|
|
||||||
|
|
||||||
func testContentBottomInsetReservesBottomDockSpace() {
|
|
||||||
let screenFrame = CGRect(x: 0, y: 0, width: 1512, height: 982)
|
let screenFrame = CGRect(x: 0, y: 0, width: 1512, height: 982)
|
||||||
let visibleFrame = CGRect(x: 0, y: 96, width: 1512, height: 861)
|
let visibleFrame = CGRect(x: 0, y: 96, width: 1512, height: 861)
|
||||||
|
|
||||||
let inset = ClipboardPanelController.contentBottomInset(forScreenFrame: screenFrame, visibleFrame: visibleFrame)
|
let inset = ClipboardPanelController.contentBottomInset(forScreenFrame: screenFrame, visibleFrame: visibleFrame)
|
||||||
|
|
||||||
XCTAssertEqual(inset, 20)
|
XCTAssertEqual(inset, 20)
|
||||||
|
XCTAssertEqual(
|
||||||
|
ClipboardPanelController.contentBottomInset(
|
||||||
|
forScreenFrame: screenFrame,
|
||||||
|
visibleFrame: CGRect(x: 80, y: 0, width: 1432, height: 957)
|
||||||
|
),
|
||||||
|
18
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testContentBottomInsetUsesMinimumWhenDockIsNotAtBottom() {
|
func testPanelSharingTypeHidesWindowFromScreenCaptureWhenEnabled() {
|
||||||
let screenFrame = CGRect(x: 0, y: 0, width: 1512, height: 982)
|
XCTAssertEqual(ClipboardPanelController.panelSharingType(hideFromScreenCapture: false), .readOnly)
|
||||||
let visibleFrame = CGRect(x: 80, y: 0, width: 1432, height: 957)
|
XCTAssertEqual(ClipboardPanelController.panelSharingType(hideFromScreenCapture: true), .none)
|
||||||
|
}
|
||||||
|
|
||||||
let inset = ClipboardPanelController.contentBottomInset(forScreenFrame: screenFrame, visibleFrame: visibleFrame)
|
func testLinkPreviewFrameSitsAboveBottomShelfWhenSpaceAllows() {
|
||||||
|
let visibleFrame = NSRect(x: 0, y: 0, width: 1512, height: 982)
|
||||||
|
let shelfFrame = NSRect(x: 0, y: 0, width: 1512, height: 408)
|
||||||
|
|
||||||
XCTAssertEqual(inset, 18)
|
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() {
|
func testCommandNumberShortcutsMapToQuickPasteSlots() {
|
||||||
XCTAssertEqual(ClipboardPanelController.quickPasteIndex(forKeyCode: 18, modifiers: .command), 0)
|
assertShortcutMappings([
|
||||||
XCTAssertEqual(ClipboardPanelController.quickPasteIndex(forKeyCode: 19, modifiers: .command), 1)
|
(18, .command, 0), (19, .command, 1), (20, .command, 2),
|
||||||
XCTAssertEqual(ClipboardPanelController.quickPasteIndex(forKeyCode: 20, modifiers: .command), 2)
|
(21, .command, 3), (23, .command, 4), (22, .command, 5),
|
||||||
XCTAssertEqual(ClipboardPanelController.quickPasteIndex(forKeyCode: 21, modifiers: .command), 3)
|
(26, .command, 6), (28, .command, 7), (25, .command, 8)
|
||||||
XCTAssertEqual(ClipboardPanelController.quickPasteIndex(forKeyCode: 23, modifiers: .command), 4)
|
], using: ClipboardPanelController.quickPasteIndex)
|
||||||
XCTAssertEqual(ClipboardPanelController.quickPasteIndex(forKeyCode: 22, modifiers: .command), 5)
|
|
||||||
XCTAssertEqual(ClipboardPanelController.quickPasteIndex(forKeyCode: 26, modifiers: .command), 6)
|
|
||||||
XCTAssertEqual(ClipboardPanelController.quickPasteIndex(forKeyCode: 28, modifiers: .command), 7)
|
|
||||||
XCTAssertEqual(ClipboardPanelController.quickPasteIndex(forKeyCode: 25, modifiers: .command), 8)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func testShiftCommandNumberShortcutsMapToPlainTextQuickPasteSlots() {
|
func testShiftCommandNumberShortcutsMapToPlainTextQuickPasteSlots() {
|
||||||
XCTAssertEqual(ClipboardPanelController.quickPastePlainTextIndex(forKeyCode: 18, modifiers: [.command, .shift]), 0)
|
assertShortcutMappings([
|
||||||
XCTAssertEqual(ClipboardPanelController.quickPastePlainTextIndex(forKeyCode: 25, modifiers: [.command, .shift]), 8)
|
(18, [.command, .shift], 0),
|
||||||
XCTAssertNil(ClipboardPanelController.quickPastePlainTextIndex(forKeyCode: 18, modifiers: .command))
|
(25, [.command, .shift], 8),
|
||||||
XCTAssertNil(ClipboardPanelController.quickPastePlainTextIndex(forKeyCode: 18, modifiers: [.command, .option, .shift]))
|
(18, .command, nil),
|
||||||
|
(18, [.command, .option, .shift], nil)
|
||||||
|
], using: ClipboardPanelController.quickPastePlainTextIndex)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testCommandOptionNumberShortcutsMapToCollections() {
|
func testCommandOptionNumberShortcutsMapToCollections() {
|
||||||
XCTAssertEqual(ClipboardPanelController.collectionShortcutMode(forKeyCode: 18, modifiers: [.command, .option]), .mostRecent)
|
assertShortcutMappings([
|
||||||
XCTAssertEqual(ClipboardPanelController.collectionShortcutMode(forKeyCode: 19, modifiers: [.command, .option]), .mostUsed)
|
(18, [.command, .option], .mostRecent),
|
||||||
XCTAssertEqual(ClipboardPanelController.collectionShortcutMode(forKeyCode: 20, modifiers: [.command, .option]), .text)
|
(19, [.command, .option], .mostUsed),
|
||||||
XCTAssertEqual(ClipboardPanelController.collectionShortcutMode(forKeyCode: 21, modifiers: [.command, .option]), .links)
|
(20, [.command, .option], .text),
|
||||||
XCTAssertEqual(ClipboardPanelController.collectionShortcutMode(forKeyCode: 23, modifiers: [.command, .option]), .images)
|
(21, [.command, .option], .links),
|
||||||
XCTAssertEqual(ClipboardPanelController.collectionShortcutMode(forKeyCode: 22, modifiers: [.command, .option]), .files)
|
(23, [.command, .option], .images),
|
||||||
XCTAssertEqual(ClipboardPanelController.collectionShortcutMode(forKeyCode: 26, modifiers: [.command, .option]), .pinned)
|
(22, [.command, .option], .files),
|
||||||
XCTAssertEqual(ClipboardPanelController.collectionShortcutMode(forKeyCode: 28, modifiers: [.command, .option]), .audio)
|
(26, [.command, .option], .pinned),
|
||||||
}
|
(28, [.command, .option], .audio),
|
||||||
|
(25, [.command, .option], .colors),
|
||||||
func testCollectionShortcutsRequireCommandOptionSoQuickPasteKeepsCommandNumbers() {
|
(29, [.command, .option], .code),
|
||||||
XCTAssertNil(ClipboardPanelController.collectionShortcutMode(forKeyCode: 18, modifiers: []))
|
(18, [], nil),
|
||||||
XCTAssertNil(ClipboardPanelController.collectionShortcutMode(forKeyCode: 18, modifiers: .command))
|
(18, .command, nil),
|
||||||
XCTAssertNil(ClipboardPanelController.collectionShortcutMode(forKeyCode: 29, modifiers: .command))
|
(29, .command, nil)
|
||||||
|
], using: ClipboardPanelController.collectionShortcutMode)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testSearchFieldSpacePreviewShortcutRequiresEmptySearchAndNoModifiers() {
|
func testSearchFieldSpacePreviewShortcutRequiresEmptySearchAndNoModifiers() {
|
||||||
@@ -173,45 +261,90 @@ final class ClipboardPanelControllerTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func testNavigationShortcutsMapToShelfMovement() {
|
func testNavigationShortcutsMapToShelfMovement() {
|
||||||
XCTAssertEqual(ClipboardPanelController.navigationShortcutAction(forKeyCode: 115, modifiers: []), .first)
|
assertShortcutMappings([
|
||||||
XCTAssertEqual(ClipboardPanelController.navigationShortcutAction(forKeyCode: 119, modifiers: []), .last)
|
(115, [], .first), (119, [], .last), (124, [], .next),
|
||||||
XCTAssertEqual(ClipboardPanelController.navigationShortcutAction(forKeyCode: 124, modifiers: []), .next)
|
(121, [], .pageNext), (116, [], .pagePrevious), (123, [], .previous),
|
||||||
XCTAssertEqual(ClipboardPanelController.navigationShortcutAction(forKeyCode: 121, modifiers: []), .pageNext)
|
(126, .command, .first), (125, .command, .last),
|
||||||
XCTAssertEqual(ClipboardPanelController.navigationShortcutAction(forKeyCode: 116, modifiers: []), .pagePrevious)
|
(124, .command, nil), (126, [], nil), (125, [], nil),
|
||||||
XCTAssertEqual(ClipboardPanelController.navigationShortcutAction(forKeyCode: 123, modifiers: []), .previous)
|
(121, .shift, nil), (35, [], nil)
|
||||||
|
], using: ClipboardPanelController.navigationShortcutAction)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testNavigationShortcutsRequireNoModifiers() {
|
func testSelectionShortcutsMapToRangeAndSelectAllActions() {
|
||||||
XCTAssertNil(ClipboardPanelController.navigationShortcutAction(forKeyCode: 124, modifiers: .command))
|
assertShortcutMappings([
|
||||||
XCTAssertNil(ClipboardPanelController.navigationShortcutAction(forKeyCode: 121, modifiers: .shift))
|
(0, .command, .selectAll),
|
||||||
XCTAssertNil(ClipboardPanelController.navigationShortcutAction(forKeyCode: 35, modifiers: []))
|
(115, .shift, .extendFirst), (119, .shift, .extendLast),
|
||||||
|
(124, .shift, .extendNext), (121, .shift, .extendPageNext),
|
||||||
|
(116, .shift, .extendPagePrevious), (123, .shift, .extendPrevious),
|
||||||
|
(0, [], nil), (0, [.command, .shift], nil),
|
||||||
|
(124, [], nil), (124, [.command, .shift], nil)
|
||||||
|
], using: ClipboardPanelController.selectionShortcutAction)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testCommandActionShortcutsMapToSelectedClipActions() {
|
func testCommandActionShortcutsMapToSelectedClipActions() {
|
||||||
XCTAssertEqual(ClipboardPanelController.commandShortcutAction(forKeyCode: 8, modifiers: .command), .copy)
|
assertShortcutMappings([
|
||||||
XCTAssertEqual(ClipboardPanelController.commandShortcutAction(forKeyCode: 5, modifiers: .command), .showInClipboard)
|
(8, .command, .copy), (14, .command, .edit), (3, .command, .focusSearch),
|
||||||
XCTAssertEqual(ClipboardPanelController.commandShortcutAction(forKeyCode: 16, modifiers: .command), .preview)
|
(45, .command, nil), (5, .command, .showInClipboard),
|
||||||
XCTAssertEqual(ClipboardPanelController.commandShortcutAction(forKeyCode: 31, modifiers: .command), .open)
|
(16, .command, .preview), (31, .command, .open), (15, .command, .rename),
|
||||||
XCTAssertEqual(ClipboardPanelController.commandShortcutAction(forKeyCode: 15, modifiers: .command), .reveal)
|
(17, .command, .toggleCapturePause), (6, .command, .undoDelete),
|
||||||
|
(123, .command, .previousCollection), (124, .command, .nextCollection),
|
||||||
|
(8, [], nil), (8, [.command, .shift], nil), (9, .command, nil)
|
||||||
|
], using: ClipboardPanelController.commandShortcutAction)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testCommandActionShortcutsRequireCommandOnlySoSearchTypingIsUntouched() {
|
func testSettingsShortcutMatchesOnlyItsExactLocalBinding() {
|
||||||
XCTAssertNil(ClipboardPanelController.commandShortcutAction(forKeyCode: 8, modifiers: []))
|
let binding = AppConfiguration.defaultSettingsShortcut
|
||||||
XCTAssertNil(ClipboardPanelController.commandShortcutAction(forKeyCode: 8, modifiers: [.command, .shift]))
|
|
||||||
XCTAssertNil(ClipboardPanelController.commandShortcutAction(forKeyCode: 9, modifiers: .command))
|
XCTAssertTrue(
|
||||||
|
ClipboardPanelController.matchesShortcut(
|
||||||
|
keyCode: UInt16(kVK_ANSI_Comma),
|
||||||
|
modifiers: .command,
|
||||||
|
binding: binding
|
||||||
|
)
|
||||||
|
)
|
||||||
|
XCTAssertFalse(
|
||||||
|
ClipboardPanelController.matchesShortcut(
|
||||||
|
keyCode: UInt16(kVK_ANSI_Comma),
|
||||||
|
modifiers: [.command, .shift],
|
||||||
|
binding: binding
|
||||||
|
)
|
||||||
|
)
|
||||||
|
XCTAssertFalse(
|
||||||
|
ClipboardPanelController.matchesShortcut(
|
||||||
|
keyCode: UInt16(kVK_ANSI_Period),
|
||||||
|
modifiers: .command,
|
||||||
|
binding: binding
|
||||||
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testModifiedShortcutsMapToPlainTextActions() {
|
func testModifiedShortcutsMapToPanelActions() {
|
||||||
XCTAssertEqual(ClipboardPanelController.modifiedShortcutAction(forKeyCode: 1, modifiers: [.command, .shift]), .toggleStack)
|
assertShortcutMappings([
|
||||||
XCTAssertEqual(ClipboardPanelController.modifiedShortcutAction(forKeyCode: 8, modifiers: [.command, .shift]), .copyPlainText)
|
(36, .shift, .pastePlainText),
|
||||||
XCTAssertEqual(ClipboardPanelController.modifiedShortcutAction(forKeyCode: 9, modifiers: [.command, .shift]), .pastePlainText)
|
(1, [.command, .shift], .toggleStack),
|
||||||
XCTAssertEqual(ClipboardPanelController.modifiedShortcutAction(forKeyCode: 45, modifiers: [.command, .shift]), .newCollection)
|
(8, [.command, .shift], .toggleStackCapture),
|
||||||
XCTAssertEqual(ClipboardPanelController.modifiedShortcutAction(forKeyCode: 36, modifiers: [.command, .shift]), .pasteStackNext)
|
(9, [.command, .shift], .pastePlainText),
|
||||||
|
(45, [.command, .shift], .newCollection),
|
||||||
|
(36, [.command, .shift], .pasteStackNext),
|
||||||
|
(36, [], nil), (8, .shift, nil), (8, .command, nil),
|
||||||
|
(8, [.command, .option, .shift], nil), (31, [.command, .shift], nil)
|
||||||
|
], using: ClipboardPanelController.modifiedShortcutAction)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testModifiedShortcutsRequireCommandShiftOnly() {
|
private func assertShortcutMappings<Value: Equatable>(
|
||||||
XCTAssertNil(ClipboardPanelController.modifiedShortcutAction(forKeyCode: 8, modifiers: .command))
|
_ cases: [(keyCode: UInt16, modifiers: NSEvent.ModifierFlags, expected: Value?)],
|
||||||
XCTAssertNil(ClipboardPanelController.modifiedShortcutAction(forKeyCode: 8, modifiers: [.command, .option, .shift]))
|
using mapping: (UInt16, NSEvent.ModifierFlags) -> Value?,
|
||||||
XCTAssertNil(ClipboardPanelController.modifiedShortcutAction(forKeyCode: 31, modifiers: [.command, .shift]))
|
file: StaticString = #filePath,
|
||||||
|
line: UInt = #line
|
||||||
|
) {
|
||||||
|
for testCase in cases {
|
||||||
|
XCTAssertEqual(
|
||||||
|
mapping(testCase.keyCode, testCase.modifiers),
|
||||||
|
testCase.expected,
|
||||||
|
"keyCode \(testCase.keyCode), modifiers \(testCase.modifiers.rawValue)",
|
||||||
|
file: file,
|
||||||
|
line: line
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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?.id, itemID)
|
||||||
XCTAssertEqual(store.items.first?.payload, "legacy payload")
|
XCTAssertEqual(store.items.first?.payload, "legacy payload")
|
||||||
XCTAssertEqual(store.items.first?.useCount, 3)
|
XCTAssertEqual(store.items.first?.useCount, 3)
|
||||||
|
XCTAssertEqual(store.items.first?.sourceDeviceName, ClipboardItem.localDeviceName)
|
||||||
|
|
||||||
let restored = makeStore(settings: settings)
|
let restored = makeStore(settings: settings)
|
||||||
restored.flushPersistenceForTesting()
|
restored.flushPersistenceForTesting()
|
||||||
XCTAssertEqual(restored.items.first?.payload, "legacy payload")
|
XCTAssertEqual(restored.items.first?.payload, "legacy payload")
|
||||||
|
XCTAssertEqual(restored.items.first?.sourceDeviceName, ClipboardItem.localDeviceName)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testPinnedItemsSurviveNormalHistoryPrune() {
|
func testPinnedItemsSurviveNormalHistoryPrune() {
|
||||||
@@ -266,6 +268,114 @@ final class ClipboardStoreTests: XCTestCase {
|
|||||||
XCTAssertTrue(restored.items.contains(where: { $0.payload == "pinned-old" && $0.isPinned }))
|
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 {
|
func testStorageFilesUsePrivatePermissions() throws {
|
||||||
let settings = makeSettings(maxHistory: 50)
|
let settings = makeSettings(maxHistory: 50)
|
||||||
let store = makeStore(settings: settings)
|
let store = makeStore(settings: settings)
|
||||||
@@ -360,7 +470,8 @@ final class ClipboardStoreTests: XCTestCase {
|
|||||||
isPinned: false,
|
isPinned: false,
|
||||||
sourceAppBundleId: "com.example.secret.\(UUID().uuidString)",
|
sourceAppBundleId: "com.example.secret.\(UUID().uuidString)",
|
||||||
ocrText: "OCR 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)
|
store.upsert(item)
|
||||||
@@ -375,6 +486,7 @@ final class ClipboardStoreTests: XCTestCase {
|
|||||||
XCTAssertFalse(rawDatabaseText.contains(item.sourceAppBundleId!))
|
XCTAssertFalse(rawDatabaseText.contains(item.sourceAppBundleId!))
|
||||||
XCTAssertFalse(rawDatabaseText.contains(item.ocrText!))
|
XCTAssertFalse(rawDatabaseText.contains(item.ocrText!))
|
||||||
XCTAssertFalse(rawDatabaseText.contains(item.collectionName!))
|
XCTAssertFalse(rawDatabaseText.contains(item.collectionName!))
|
||||||
|
XCTAssertFalse(rawDatabaseText.contains(item.sourceDeviceName!))
|
||||||
|
|
||||||
let restored = makeStore(settings: settings, encryptionService: encryptionService)
|
let restored = makeStore(settings: settings, encryptionService: encryptionService)
|
||||||
restored.flushPersistenceForTesting()
|
restored.flushPersistenceForTesting()
|
||||||
@@ -386,6 +498,7 @@ final class ClipboardStoreTests: XCTestCase {
|
|||||||
XCTAssertEqual(restored.items.first?.sourceAppBundleId, item.sourceAppBundleId)
|
XCTAssertEqual(restored.items.first?.sourceAppBundleId, item.sourceAppBundleId)
|
||||||
XCTAssertEqual(restored.items.first?.ocrText, item.ocrText)
|
XCTAssertEqual(restored.items.first?.ocrText, item.ocrText)
|
||||||
XCTAssertEqual(restored.items.first?.collectionName, item.collectionName)
|
XCTAssertEqual(restored.items.first?.collectionName, item.collectionName)
|
||||||
|
XCTAssertEqual(restored.items.first?.sourceDeviceName, item.sourceDeviceName)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testPlaintextDatabaseMigratesToEncryptedFieldsOnLoad() throws {
|
func testPlaintextDatabaseMigratesToEncryptedFieldsOnLoad() throws {
|
||||||
@@ -406,7 +519,8 @@ final class ClipboardStoreTests: XCTestCase {
|
|||||||
isPinned: false,
|
isPinned: false,
|
||||||
sourceAppBundleId: "com.example.legacy.\(UUID().uuidString)",
|
sourceAppBundleId: "com.example.legacy.\(UUID().uuidString)",
|
||||||
ocrText: "Legacy OCR \(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)
|
plaintextStore.upsert(item)
|
||||||
@@ -424,6 +538,7 @@ final class ClipboardStoreTests: XCTestCase {
|
|||||||
XCTAssertEqual(restored.items.first?.sourceAppBundleId, item.sourceAppBundleId)
|
XCTAssertEqual(restored.items.first?.sourceAppBundleId, item.sourceAppBundleId)
|
||||||
XCTAssertEqual(restored.items.first?.ocrText, item.ocrText)
|
XCTAssertEqual(restored.items.first?.ocrText, item.ocrText)
|
||||||
XCTAssertEqual(restored.items.first?.collectionName, item.collectionName)
|
XCTAssertEqual(restored.items.first?.collectionName, item.collectionName)
|
||||||
|
XCTAssertEqual(restored.items.first?.sourceDeviceName, item.sourceDeviceName)
|
||||||
|
|
||||||
let migratedDatabaseText = try databaseText()
|
let migratedDatabaseText = try databaseText()
|
||||||
XCTAssertTrue(migratedDatabaseText.contains(ClipboardEncryptionService.marker))
|
XCTAssertTrue(migratedDatabaseText.contains(ClipboardEncryptionService.marker))
|
||||||
@@ -434,6 +549,7 @@ final class ClipboardStoreTests: XCTestCase {
|
|||||||
XCTAssertFalse(migratedDatabaseText.contains(item.sourceAppBundleId!))
|
XCTAssertFalse(migratedDatabaseText.contains(item.sourceAppBundleId!))
|
||||||
XCTAssertFalse(migratedDatabaseText.contains(item.ocrText!))
|
XCTAssertFalse(migratedDatabaseText.contains(item.ocrText!))
|
||||||
XCTAssertFalse(migratedDatabaseText.contains(item.collectionName!))
|
XCTAssertFalse(migratedDatabaseText.contains(item.collectionName!))
|
||||||
|
XCTAssertFalse(migratedDatabaseText.contains(item.sourceDeviceName!))
|
||||||
}
|
}
|
||||||
|
|
||||||
func testDuplicatePDFReplacementRemovesOldAttachment() throws {
|
func testDuplicatePDFReplacementRemovesOldAttachment() throws {
|
||||||
@@ -454,6 +570,47 @@ final class ClipboardStoreTests: XCTestCase {
|
|||||||
XCTAssertTrue(FileManager.default.fileExists(atPath: newPath))
|
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 {
|
func testDuplicateReplacementClearsStaleImageSearchMetadata() throws {
|
||||||
let settings = makeSettings(maxHistory: 50)
|
let settings = makeSettings(maxHistory: 50)
|
||||||
settings.keepFirstImage = false
|
settings.keepFirstImage = false
|
||||||
@@ -501,9 +658,204 @@ final class ClipboardStoreTests: XCTestCase {
|
|||||||
XCTAssertFalse(FileManager.default.fileExists(atPath: staleImage.thumb))
|
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 {
|
private func makeSettings(maxHistory: Int) -> SettingsModel {
|
||||||
let settings = SettingsModel(defaults: defaults)
|
let settings = SettingsModel(defaults: defaults)
|
||||||
settings.maxHistoryItems = maxHistory
|
settings.maxHistoryItems = maxHistory
|
||||||
|
settings.historyRetention = .forever
|
||||||
settings.pruneDuplicates = true
|
settings.pruneDuplicates = true
|
||||||
settings.keepFirstImage = true
|
settings.keepFirstImage = true
|
||||||
return settings
|
return settings
|
||||||
|
|||||||
@@ -1,35 +0,0 @@
|
|||||||
import XCTest
|
|
||||||
@testable import ClipBored
|
|
||||||
|
|
||||||
final class DiagnosticsServiceTests: XCTestCase {
|
|
||||||
func testCountersCanBeIncrementedAndReset() {
|
|
||||||
let diagnostics = DiagnosticsService.shared
|
|
||||||
diagnostics.reset()
|
|
||||||
|
|
||||||
diagnostics.incrementMonitorTick()
|
|
||||||
diagnostics.incrementPasteboardChange()
|
|
||||||
diagnostics.incrementExtractionAttempt()
|
|
||||||
diagnostics.incrementDatabaseMutation()
|
|
||||||
diagnostics.incrementCachePurge()
|
|
||||||
|
|
||||||
// The counters use a serial async queue for low overhead; sync through reset's queue by reading a snapshot.
|
|
||||||
let snapshot = waitForSnapshot { diagnostics.currentSnapshot() }
|
|
||||||
XCTAssertEqual(snapshot.monitorTicks, 1)
|
|
||||||
XCTAssertEqual(snapshot.pasteboardChanges, 1)
|
|
||||||
XCTAssertEqual(snapshot.extractionAttempts, 1)
|
|
||||||
XCTAssertEqual(snapshot.databaseMutations, 1)
|
|
||||||
XCTAssertEqual(snapshot.cachePurges, 1)
|
|
||||||
|
|
||||||
diagnostics.reset()
|
|
||||||
XCTAssertEqual(diagnostics.currentSnapshot(), .init(monitorTicks: 0, pasteboardChanges: 0, extractionAttempts: 0, databaseMutations: 0, cachePurges: 0))
|
|
||||||
}
|
|
||||||
|
|
||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
54
tests/clipboredtests/OnboardingWindowControllerTests.swift
Normal file
54
tests/clipboredtests/OnboardingWindowControllerTests.swift
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -13,7 +13,7 @@ final class PasteActionServiceTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func testCopyWritesTextToPasteboard() {
|
func testCopyWritesTextToPasteboard() {
|
||||||
let service = PasteActionService()
|
let service = makeService()
|
||||||
let item = ClipboardItem(
|
let item = ClipboardItem(
|
||||||
id: UUID(),
|
id: UUID(),
|
||||||
kind: .text,
|
kind: .text,
|
||||||
@@ -32,8 +32,29 @@ final class PasteActionServiceTests: XCTestCase {
|
|||||||
XCTAssertEqual(NSPasteboard.general.string(forType: .string), "Hello")
|
XCTAssertEqual(NSPasteboard.general.string(forType: .string), "Hello")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testCopyWritesCodeSnippetAsPlainString() {
|
||||||
|
let service = makeService()
|
||||||
|
let snippet = "func greet(name: String) -> String {\n return \"Hi \\(name)\"\n}"
|
||||||
|
let item = ClipboardItem(
|
||||||
|
id: UUID(),
|
||||||
|
kind: .code,
|
||||||
|
displayText: "Swift Snippet",
|
||||||
|
payload: snippet,
|
||||||
|
payloadHash: "hash",
|
||||||
|
createdAt: Date(),
|
||||||
|
lastUsedAt: Date(),
|
||||||
|
useCount: 0,
|
||||||
|
sourceApp: "Xcode",
|
||||||
|
imagePath: nil,
|
||||||
|
thumbnailPath: nil
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertEqual(service.copy(item), .copied)
|
||||||
|
XCTAssertEqual(NSPasteboard.general.string(forType: .string), snippet)
|
||||||
|
}
|
||||||
|
|
||||||
func testPasteboardWritersExposeTextForDragOut() {
|
func testPasteboardWritersExposeTextForDragOut() {
|
||||||
let service = PasteActionService()
|
let service = makeService()
|
||||||
let item = ClipboardItem(
|
let item = ClipboardItem(
|
||||||
id: UUID(),
|
id: UUID(),
|
||||||
kind: .text,
|
kind: .text,
|
||||||
@@ -56,7 +77,7 @@ final class PasteActionServiceTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func testPasteboardWritersExposeURLAndTitleForDragOut() {
|
func testPasteboardWritersExposeURLAndTitleForDragOut() {
|
||||||
let service = PasteActionService()
|
let service = makeService()
|
||||||
let item = ClipboardItem(
|
let item = ClipboardItem(
|
||||||
id: UUID(),
|
id: UUID(),
|
||||||
kind: .url,
|
kind: .url,
|
||||||
@@ -83,8 +104,88 @@ 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 = makeService()
|
||||||
|
let item = ClipboardItem(
|
||||||
|
id: UUID(),
|
||||||
|
kind: .color,
|
||||||
|
displayText: "#0A84FF",
|
||||||
|
payload: "#0A84FF",
|
||||||
|
payloadHash: "hash",
|
||||||
|
createdAt: Date(),
|
||||||
|
lastUsedAt: Date(),
|
||||||
|
useCount: 0,
|
||||||
|
sourceApp: nil,
|
||||||
|
imagePath: nil,
|
||||||
|
thumbnailPath: nil
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertEqual(service.copy(item), .copied)
|
||||||
|
let restored = try XCTUnwrap(NSColor(from: NSPasteboard.general))
|
||||||
|
XCTAssertEqual(ColorPayload.hexString(from: restored), "#0A84FF")
|
||||||
|
XCTAssertEqual(NSPasteboard.general.string(forType: .string), "#0A84FF")
|
||||||
|
XCTAssertEqual(service.copyPlainText(item), .copiedPlainText)
|
||||||
|
XCTAssertEqual(NSPasteboard.general.string(forType: .string), "#0A84FF")
|
||||||
|
}
|
||||||
|
|
||||||
func testPasteWithoutTargetCopiesWithoutRequestingAutomaticPaste() {
|
func testPasteWithoutTargetCopiesWithoutRequestingAutomaticPaste() {
|
||||||
let service = PasteActionService()
|
let service = makeService()
|
||||||
let item = ClipboardItem(
|
let item = ClipboardItem(
|
||||||
id: UUID(),
|
id: UUID(),
|
||||||
kind: .text,
|
kind: .text,
|
||||||
@@ -107,7 +208,7 @@ final class PasteActionServiceTests: XCTestCase {
|
|||||||
var activatedProcessID: pid_t?
|
var activatedProcessID: pid_t?
|
||||||
let targetApp = try makeRunningTargetApp()
|
let targetApp = try makeRunningTargetApp()
|
||||||
var didScheduleKeyboardPaste = false
|
var didScheduleKeyboardPaste = false
|
||||||
let service = PasteActionService(
|
let service = makeService(
|
||||||
accessibilityPermissionProvider: { true },
|
accessibilityPermissionProvider: { true },
|
||||||
targetActivator: { app in
|
targetActivator: { app in
|
||||||
activatedProcessID = app.processIdentifier
|
activatedProcessID = app.processIdentifier
|
||||||
@@ -128,7 +229,7 @@ final class PasteActionServiceTests: XCTestCase {
|
|||||||
var activatedProcessID: pid_t?
|
var activatedProcessID: pid_t?
|
||||||
let targetApp = try makeRunningTargetApp()
|
let targetApp = try makeRunningTargetApp()
|
||||||
var didScheduleKeyboardPaste = false
|
var didScheduleKeyboardPaste = false
|
||||||
let service = PasteActionService(
|
let service = makeService(
|
||||||
accessibilityPermissionProvider: { true },
|
accessibilityPermissionProvider: { true },
|
||||||
targetActivator: { app in
|
targetActivator: { app in
|
||||||
activatedProcessID = app.processIdentifier
|
activatedProcessID = app.processIdentifier
|
||||||
@@ -162,7 +263,7 @@ final class PasteActionServiceTests: XCTestCase {
|
|||||||
func testAutomaticPasteDoesNotPostShortcutWhenTargetActivationFails() throws {
|
func testAutomaticPasteDoesNotPostShortcutWhenTargetActivationFails() throws {
|
||||||
var didAttemptActivation = false
|
var didAttemptActivation = false
|
||||||
let targetApp = try makeRunningTargetApp()
|
let targetApp = try makeRunningTargetApp()
|
||||||
let service = PasteActionService(
|
let service = makeService(
|
||||||
accessibilityPermissionProvider: { true },
|
accessibilityPermissionProvider: { true },
|
||||||
targetActivator: { _ in
|
targetActivator: { _ in
|
||||||
didAttemptActivation = true
|
didAttemptActivation = true
|
||||||
@@ -180,7 +281,7 @@ final class PasteActionServiceTests: XCTestCase {
|
|||||||
|
|
||||||
func testAutomaticPasteWithoutPermissionDoesNotActivateTarget() throws {
|
func testAutomaticPasteWithoutPermissionDoesNotActivateTarget() throws {
|
||||||
let targetApp = try makeRunningTargetApp()
|
let targetApp = try makeRunningTargetApp()
|
||||||
let service = PasteActionService(
|
let service = makeService(
|
||||||
accessibilityPermissionProvider: { false },
|
accessibilityPermissionProvider: { false },
|
||||||
targetActivator: { _ in
|
targetActivator: { _ in
|
||||||
XCTFail("Target should not be activated without Accessibility permission")
|
XCTFail("Target should not be activated without Accessibility permission")
|
||||||
@@ -196,7 +297,7 @@ final class PasteActionServiceTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func testCopyMissingFileDoesNotClearExistingPasteboard() {
|
func testCopyMissingFileDoesNotClearExistingPasteboard() {
|
||||||
let service = PasteActionService()
|
let service = makeService()
|
||||||
let board = NSPasteboard.general
|
let board = NSPasteboard.general
|
||||||
board.clearContents()
|
board.clearContents()
|
||||||
XCTAssertTrue(board.setString("keep me", forType: .string))
|
XCTAssertTrue(board.setString("keep me", forType: .string))
|
||||||
@@ -220,7 +321,7 @@ final class PasteActionServiceTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func testCopyEmptyTextDoesNotClearExistingPasteboard() {
|
func testCopyEmptyTextDoesNotClearExistingPasteboard() {
|
||||||
let service = PasteActionService()
|
let service = makeService()
|
||||||
let board = NSPasteboard.general
|
let board = NSPasteboard.general
|
||||||
board.clearContents()
|
board.clearContents()
|
||||||
XCTAssertTrue(board.setString("keep me", forType: .string))
|
XCTAssertTrue(board.setString("keep me", forType: .string))
|
||||||
@@ -244,7 +345,7 @@ final class PasteActionServiceTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func testCopyWritesURLType() {
|
func testCopyWritesURLType() {
|
||||||
let service = PasteActionService()
|
let service = makeService()
|
||||||
let item = ClipboardItem(
|
let item = ClipboardItem(
|
||||||
id: UUID(),
|
id: UUID(),
|
||||||
kind: .url,
|
kind: .url,
|
||||||
@@ -331,7 +432,7 @@ final class PasteActionServiceTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func testCopyLegacyRichTextWritesPlainPayloadWhenRTFCacheIsUnavailable() {
|
func testCopyLegacyRichTextWritesPlainPayloadWhenRTFCacheIsUnavailable() {
|
||||||
let service = PasteActionService()
|
let service = makeService()
|
||||||
let item = ClipboardItem(
|
let item = ClipboardItem(
|
||||||
id: UUID(),
|
id: UUID(),
|
||||||
kind: .richText,
|
kind: .richText,
|
||||||
@@ -353,7 +454,7 @@ final class PasteActionServiceTests: XCTestCase {
|
|||||||
|
|
||||||
func testCopyRichTextWithMissingCacheWritesDisplayTextInsteadOfPath() throws {
|
func testCopyRichTextWithMissingCacheWritesDisplayTextInsteadOfPath() throws {
|
||||||
let missingPath = try makeTempDirectory().appendingPathComponent("missing-rich-text.rtf").path
|
let missingPath = try makeTempDirectory().appendingPathComponent("missing-rich-text.rtf").path
|
||||||
let service = PasteActionService()
|
let service = makeService()
|
||||||
let item = ClipboardItem(
|
let item = ClipboardItem(
|
||||||
id: UUID(),
|
id: UUID(),
|
||||||
kind: .richText,
|
kind: .richText,
|
||||||
@@ -374,7 +475,7 @@ final class PasteActionServiceTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func testCopyPlainTextForURLOmitsURLPasteboardTypes() {
|
func testCopyPlainTextForURLOmitsURLPasteboardTypes() {
|
||||||
let service = PasteActionService()
|
let service = makeService()
|
||||||
let item = ClipboardItem(
|
let item = ClipboardItem(
|
||||||
id: UUID(),
|
id: UUID(),
|
||||||
kind: .url,
|
kind: .url,
|
||||||
@@ -397,7 +498,7 @@ final class PasteActionServiceTests: XCTestCase {
|
|||||||
|
|
||||||
func testCopyWritesFileReferenceType() throws {
|
func testCopyWritesFileReferenceType() throws {
|
||||||
let fileURL = try makeTempFile(contents: "file contents")
|
let fileURL = try makeTempFile(contents: "file contents")
|
||||||
let service = PasteActionService()
|
let service = makeService()
|
||||||
let item = ClipboardItem(
|
let item = ClipboardItem(
|
||||||
id: UUID(),
|
id: UUID(),
|
||||||
kind: .file,
|
kind: .file,
|
||||||
@@ -422,7 +523,7 @@ final class PasteActionServiceTests: XCTestCase {
|
|||||||
let firstURL = try makeTempFile(contents: "first file")
|
let firstURL = try makeTempFile(contents: "first file")
|
||||||
let secondURL = try makeTempFile(contents: "second file")
|
let secondURL = try makeTempFile(contents: "second file")
|
||||||
let payload = FilePayload.payload(from: [firstURL, secondURL])
|
let payload = FilePayload.payload(from: [firstURL, secondURL])
|
||||||
let service = PasteActionService()
|
let service = makeService()
|
||||||
let item = ClipboardItem(
|
let item = ClipboardItem(
|
||||||
id: UUID(),
|
id: UUID(),
|
||||||
kind: .file,
|
kind: .file,
|
||||||
@@ -446,7 +547,7 @@ final class PasteActionServiceTests: XCTestCase {
|
|||||||
func testCopyWritesPDFData() throws {
|
func testCopyWritesPDFData() throws {
|
||||||
let pdfData = Data("%PDF-1.4\nclipbored\n%%EOF".utf8)
|
let pdfData = Data("%PDF-1.4\nclipbored\n%%EOF".utf8)
|
||||||
let fileURL = try makeTempFile(contents: pdfData)
|
let fileURL = try makeTempFile(contents: pdfData)
|
||||||
let service = PasteActionService()
|
let service = makeService()
|
||||||
let item = ClipboardItem(
|
let item = ClipboardItem(
|
||||||
id: UUID(),
|
id: UUID(),
|
||||||
kind: .pdf,
|
kind: .pdf,
|
||||||
@@ -489,6 +590,32 @@ final class PasteActionServiceTests: XCTestCase {
|
|||||||
XCTAssertEqual(NSPasteboard.general.data(forType: .sound), audioData)
|
XCTAssertEqual(NSPasteboard.general.data(forType: .sound), audioData)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testCopyWritesVideoData() throws {
|
||||||
|
let directory = try makeTempDirectory()
|
||||||
|
let cacheService = ClipboardCacheService(baseURL: directory, encryptionService: fixedEncryptionService())
|
||||||
|
let videoData = Data([0, 0, 0, 24, 102, 116, 121, 112, 109, 112, 52, 50])
|
||||||
|
let path = try XCTUnwrap(cacheService.cacheVideo(videoData, id: UUID(), fileExtension: "mp4"))
|
||||||
|
let service = PasteActionService(cacheService: cacheService)
|
||||||
|
let item = ClipboardItem(
|
||||||
|
id: UUID(),
|
||||||
|
kind: .video,
|
||||||
|
displayText: "Video",
|
||||||
|
payload: path,
|
||||||
|
payloadHash: "hash",
|
||||||
|
createdAt: Date(),
|
||||||
|
lastUsedAt: Date(),
|
||||||
|
useCount: 0,
|
||||||
|
sourceApp: nil,
|
||||||
|
imagePath: nil,
|
||||||
|
thumbnailPath: nil
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertEqual(service.copy(item), .copied)
|
||||||
|
XCTAssertEqual(NSPasteboard.general.data(forType: VideoPayload.pasteboardTypes[0]), videoData)
|
||||||
|
XCTAssertEqual(service.copyPlainText(item), .copiedPlainText)
|
||||||
|
XCTAssertEqual(NSPasteboard.general.string(forType: .string), "Video")
|
||||||
|
}
|
||||||
|
|
||||||
func testCopyWritesEncryptedPDFData() throws {
|
func testCopyWritesEncryptedPDFData() throws {
|
||||||
let directory = try makeTempDirectory()
|
let directory = try makeTempDirectory()
|
||||||
let cacheService = ClipboardCacheService(baseURL: directory, encryptionService: fixedEncryptionService())
|
let cacheService = ClipboardCacheService(baseURL: directory, encryptionService: fixedEncryptionService())
|
||||||
@@ -514,6 +641,36 @@ final class PasteActionServiceTests: XCTestCase {
|
|||||||
XCTAssertEqual(NSPasteboard.general.data(forType: .pdf), pdfData)
|
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 {
|
private func makeTempFile(contents: String) throws -> URL {
|
||||||
try makeTempFile(contents: Data(contents.utf8))
|
try makeTempFile(contents: Data(contents.utf8))
|
||||||
}
|
}
|
||||||
@@ -562,4 +719,8 @@ final class PasteActionServiceTests: XCTestCase {
|
|||||||
let keyData = Data(repeating: byte, count: 32)
|
let keyData = Data(repeating: byte, count: 32)
|
||||||
return ClipboardEncryptionService(keyProvider: { SymmetricKey(data: keyData) })
|
return ClipboardEncryptionService(keyProvider: { SymmetricKey(data: keyData) })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func noOpEncryptionService() -> ClipboardEncryptionService {
|
||||||
|
ClipboardEncryptionService(keyProvider: { nil })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ final class SensitiveContentDetectorTests: XCTestCase {
|
|||||||
|
|
||||||
func testDetectsCreditCardWithLuhnCheck() {
|
func testDetectsCreditCardWithLuhnCheck() {
|
||||||
XCTAssertEqual(SensitiveContentDetector.detect("4242424242424242"), .creditCard)
|
XCTAssertEqual(SensitiveContentDetector.detect("4242424242424242"), .creditCard)
|
||||||
|
XCTAssertEqual(SensitiveContentDetector.detect("Card: 4242 4242 4242 4242"), .creditCard)
|
||||||
XCTAssertNil(SensitiveContentDetector.detect("4242424242424241"))
|
XCTAssertNil(SensitiveContentDetector.detect("4242424242424241"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,43 @@ import XCTest
|
|||||||
@testable import ClipBored
|
@testable import ClipBored
|
||||||
|
|
||||||
final class SettingsModelTests: XCTestCase {
|
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() {
|
func testShowDockIconPersistsAndNotifies() {
|
||||||
let suiteName = "com.clipbored.settingsmodel.\(UUID().uuidString)"
|
let suiteName = "com.clipbored.settingsmodel.\(UUID().uuidString)"
|
||||||
let defaults = UserDefaults(suiteName: suiteName)!
|
let defaults = UserDefaults(suiteName: suiteName)!
|
||||||
@@ -26,6 +63,338 @@ final class SettingsModelTests: XCTestCase {
|
|||||||
XCTAssertTrue(restored.showDockIcon)
|
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 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.panelSide, .right)
|
||||||
|
|
||||||
|
settings.panelSide = .left
|
||||||
|
|
||||||
|
XCTAssertEqual(defaults.integer(forKey: SettingsModel.Keys.panelSide), ClipboardPanelSide.left.rawValue)
|
||||||
|
XCTAssertEqual(changes, [.panelSide])
|
||||||
|
|
||||||
|
let restored = SettingsModel(defaults: defaults)
|
||||||
|
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 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() {
|
func testCustomCollectionsPersistWithNormalizedColors() {
|
||||||
let suiteName = "com.clipbored.settingsmodel.\(UUID().uuidString)"
|
let suiteName = "com.clipbored.settingsmodel.\(UUID().uuidString)"
|
||||||
let defaults = UserDefaults(suiteName: suiteName)!
|
let defaults = UserDefaults(suiteName: suiteName)!
|
||||||
@@ -77,4 +446,17 @@ final class SettingsModelTests: XCTestCase {
|
|||||||
XCTAssertEqual(restored.customCollectionNames, ["Product Research"])
|
XCTAssertEqual(restored.customCollectionNames, ["Product Research"])
|
||||||
XCTAssertEqual(restored.collectionColorHex(forCollectionNamed: "Product Research"), "#3366FF")
|
XCTAssertEqual(restored.collectionColorHex(forCollectionNamed: "Product Research"), "#3366FF")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static let visibleItemKinds: [ClipboardItemKind] = [
|
||||||
|
.text,
|
||||||
|
.code,
|
||||||
|
.url,
|
||||||
|
.image,
|
||||||
|
.color,
|
||||||
|
.audio,
|
||||||
|
.video,
|
||||||
|
.richText,
|
||||||
|
.pdf,
|
||||||
|
.file
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
117
tests/clipboredtests/SettingsPresentationTests.swift
Normal file
117
tests/clipboredtests/SettingsPresentationTests.swift
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import AppKit
|
||||||
|
import XCTest
|
||||||
|
@testable import ClipBored
|
||||||
|
|
||||||
|
final class SettingsPresentationTests: XCTestCase {
|
||||||
|
func testPasteAndDataStatusColors() {
|
||||||
|
let pasteCases: [(String, NSColor)] = [
|
||||||
|
("", .secondaryLabelColor),
|
||||||
|
("Pasted", .systemGreen),
|
||||||
|
("Copied. Grant Accessibility access to paste automatically.", .systemOrange),
|
||||||
|
("Could not write item to clipboard.", .systemRed)
|
||||||
|
]
|
||||||
|
for (message, color) in pasteCases {
|
||||||
|
XCTAssertEqual(SettingsWindowController.pasteStatusPresentation(storedStatus: message).textColor, color)
|
||||||
|
}
|
||||||
|
|
||||||
|
let dataCases: [(String, NSColor)] = [
|
||||||
|
("", .secondaryLabelColor),
|
||||||
|
("Exported 3 clips.", .systemGreen),
|
||||||
|
("Imported 3 clips. Skipped 1 clip.", .systemOrange),
|
||||||
|
("The archive couldn't be opened.", .systemRed)
|
||||||
|
]
|
||||||
|
for (message, color) in dataCases {
|
||||||
|
XCTAssertEqual(SettingsWindowController.dataStatusPresentation(storedStatus: message).textColor, color)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCaptureStatusColors() {
|
||||||
|
let cases: [(String, NSColor)] = [
|
||||||
|
("", .secondaryLabelColor),
|
||||||
|
("Captured text from Safari.", .systemGreen),
|
||||||
|
("Skipped: Audio items are ignored.", .systemOrange),
|
||||||
|
("At least one content type must stay enabled.", .systemOrange),
|
||||||
|
("Error: Clipboard read failed.", .systemRed)
|
||||||
|
]
|
||||||
|
for (message, color) in cases {
|
||||||
|
XCTAssertEqual(SettingsWindowController.captureStatusPresentation(storedStatus: message).textColor, color)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testShortcutPermissionAndLifecycleStatusColors() {
|
||||||
|
XCTAssertEqual(
|
||||||
|
SettingsWindowController.shortcutStatusPresentation(storedStatus: "").textColor,
|
||||||
|
.systemGreen
|
||||||
|
)
|
||||||
|
XCTAssertEqual(
|
||||||
|
SettingsWindowController.shortcutStatusPresentation(storedStatus: "Unsupported shortcut").textColor,
|
||||||
|
.systemRed
|
||||||
|
)
|
||||||
|
XCTAssertEqual(
|
||||||
|
SettingsWindowController.accessibilityPermissionStatusPresentation(
|
||||||
|
storedStatus: "",
|
||||||
|
isTrusted: true
|
||||||
|
).textColor,
|
||||||
|
.systemGreen
|
||||||
|
)
|
||||||
|
XCTAssertEqual(
|
||||||
|
SettingsWindowController.accessibilityPermissionStatusPresentation(
|
||||||
|
storedStatus: "Permission not granted",
|
||||||
|
isTrusted: true
|
||||||
|
).textColor,
|
||||||
|
.systemOrange
|
||||||
|
)
|
||||||
|
XCTAssertEqual(
|
||||||
|
SettingsWindowController.launchAtLoginStatusPresentation(storedStatus: "Service unavailable").textColor,
|
||||||
|
.systemRed
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCloudSyncStatusPrecedence() {
|
||||||
|
let ready = ClipboardCloudSyncStatus(
|
||||||
|
isAvailable: true,
|
||||||
|
archiveURL: nil,
|
||||||
|
lastModifiedAt: nil,
|
||||||
|
message: "iCloud is ready."
|
||||||
|
)
|
||||||
|
let unavailable = ClipboardCloudSyncStatus(
|
||||||
|
isAvailable: false,
|
||||||
|
archiveURL: nil,
|
||||||
|
lastModifiedAt: nil,
|
||||||
|
message: "iCloud is unavailable."
|
||||||
|
)
|
||||||
|
|
||||||
|
XCTAssertEqual(
|
||||||
|
SettingsWindowController.cloudSyncStatusPresentation(
|
||||||
|
storedStatus: "",
|
||||||
|
isSyncEnabled: false,
|
||||||
|
cloudStatus: ready
|
||||||
|
).message,
|
||||||
|
"iCloud Sync is off."
|
||||||
|
)
|
||||||
|
XCTAssertEqual(
|
||||||
|
SettingsWindowController.cloudSyncStatusPresentation(
|
||||||
|
storedStatus: "",
|
||||||
|
isSyncEnabled: true,
|
||||||
|
cloudStatus: unavailable
|
||||||
|
).textColor,
|
||||||
|
.systemOrange
|
||||||
|
)
|
||||||
|
XCTAssertEqual(
|
||||||
|
SettingsWindowController.cloudSyncStatusPresentation(
|
||||||
|
storedStatus: "Synced 3 clips.",
|
||||||
|
isSyncEnabled: true,
|
||||||
|
cloudStatus: ready
|
||||||
|
).textColor,
|
||||||
|
.systemGreen
|
||||||
|
)
|
||||||
|
XCTAssertEqual(
|
||||||
|
SettingsWindowController.cloudSyncStatusPresentation(
|
||||||
|
storedStatus: "iCloud Sync failed.",
|
||||||
|
isSyncEnabled: true,
|
||||||
|
cloudStatus: ready
|
||||||
|
).textColor,
|
||||||
|
.systemRed
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -36,35 +36,17 @@ final class ShortcutManagerTests: XCTestCase {
|
|||||||
manager.stop()
|
manager.stop()
|
||||||
}
|
}
|
||||||
|
|
||||||
func testRejectsUnsupportedSettingsShortcutBeforeRegistration() {
|
func testRejectsConfiguredShortcutConflictWithFixedStackCaptureShortcut() {
|
||||||
let manager = makeManager(
|
let manager = makeManager(openShortcut: ShortcutManager.stackCaptureShortcut)
|
||||||
openShortcut: AppConfiguration.defaultOpenShortcut,
|
|
||||||
settingsShortcut: ShortcutBinding(key: "space", modifierFlags: NSEvent.ModifierFlags.command.rawValue)
|
|
||||||
)
|
|
||||||
|
|
||||||
XCTAssertEqual(manager.start(), .unsupportedShortcut("⌘SPACE"))
|
XCTAssertEqual(manager.start(), .conflict(ShortcutManager.stackCaptureShortcut.displayText))
|
||||||
manager.stop()
|
manager.stop()
|
||||||
}
|
}
|
||||||
|
|
||||||
func testRejectsDuplicateShortcutBindingsBeforeRegistration() {
|
private func makeManager(openShortcut: ShortcutBinding) -> ShortcutManager {
|
||||||
let manager = makeManager(
|
|
||||||
openShortcut: AppConfiguration.defaultOpenShortcut,
|
|
||||||
settingsShortcut: AppConfiguration.defaultOpenShortcut
|
|
||||||
)
|
|
||||||
|
|
||||||
XCTAssertEqual(manager.start(), .conflict(AppConfiguration.defaultOpenShortcut.displayText))
|
|
||||||
manager.stop()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func makeManager(
|
|
||||||
openShortcut: ShortcutBinding,
|
|
||||||
settingsShortcut: ShortcutBinding = AppConfiguration.defaultSettingsShortcut
|
|
||||||
) -> ShortcutManager {
|
|
||||||
ShortcutManager(
|
ShortcutManager(
|
||||||
onOpenClipboardPanel: {},
|
onOpenClipboardPanel: {},
|
||||||
onOpenSettings: {},
|
openShortcut: openShortcut
|
||||||
openShortcut: openShortcut,
|
|
||||||
settingsShortcut: settingsShortcut
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user