This commit is contained in:
Akshay Kolli
2026-06-30 01:12:19 -07:00
commit 4c1c6b2f37
55 changed files with 13180 additions and 0 deletions

24
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,24 @@
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

30
.gitignore vendored Normal file
View File

@@ -0,0 +1,30 @@
# macOS
.DS_Store
# Swift Package Manager
.build/
.swiftpm/
# Xcode
DerivedData/
*.xcuserstate
xcuserdata/
# Local build output
build/
# Local editor and environment files
.env
.env.*
*.swp
*.swo
.vscode/
.idea/
# Logs and diagnostics
*.log
*.trace
*.xcresult/
# Local planning notes (not synced)
plan/

36
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,36 @@
# Contributing
Thanks for working on ClipBored. The project is optimized for a small binary, low idle power, local-only behavior, and native macOS ergonomics. Changes should protect those constraints.
## Local Setup
```bash
swift test
./scripts/build-macos-app.sh
open build/ClipBored.app
```
Run the full local check before opening a pull request:
```bash
./scripts/check.sh
```
## Engineering Guidelines
- Prefer AppKit, Foundation, Carbon, SQLite, and system frameworks over third-party dependencies.
- Keep the release app small. If a dependency or framework is needed, document the feature value and size impact.
- Preserve local-only behavior. Do not add networking, telemetry, analytics, crash uploaders, or remote sync without an explicit design discussion.
- Avoid storing new classes of sensitive data. If capture behavior expands, add tests and update `docs/SECURITY.md`.
- Keep idle work bounded. Polling, timers, file scans, and cache purges should have clear caps or backoff behavior.
- Add tests for persistence, pruning, sensitive filtering, shortcut parsing, pasteboard behavior, and search/sort changes.
- Keep UI native and compact. This is a utility, not a marketing surface.
## Pull Request Checklist
- `swift test -q` passes.
- `./scripts/build-macos-app.sh` passes.
- The release executable remains under the size gate printed by the build script.
- Security or privacy behavior is unchanged or documented.
- User-facing behavior is covered by tests or a manual verification note.
- Documentation is updated when commands, settings, storage, or permissions change.

343
LICENSE Normal file
View File

@@ -0,0 +1,343 @@
GNU GENERAL PUBLIC LICENSE
Version 2, June 1991
Copyright (C) 1989, 1991 Free Software Foundation, Inc.
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The licenses for most software are designed to take away your
freedom to share and change it. By contrast, the GNU General Public
License is intended to guarantee your freedom to share and change free
software--to make sure the software is free for all its users. This
General Public License applies to most of the Free Software
Foundation's software and to any other program whose authors commit to
using it. Some other Free Software Foundation software is covered by
the GNU Lesser General Public License instead. You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
this service if you wish), that you receive source code or can get it
if you want it, that you can change the software or use pieces of it
in new free programs; and that you know you can do these things.
To protect your rights, we need to make restrictions that forbid
anyone to deny you these rights or to ask you to surrender the rights.
These restrictions translate to certain responsibilities for you if you
distribute copies of the software, or if you modify it.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must give the recipients all the rights that
you have. You must make sure that they, too, receive or can get the
source code. And you must show them these terms so they know their
rights.
We protect your rights with two steps: (1) copyright the software, and
(2) offer you this license which gives you legal permission to copy,
distribute and/or modify the software.
Also, for each author's protection and ours, we want to make certain
that everyone understands that there is no warranty for this free
software. If the software is modified by someone else and passed on,
we want its recipients to know that what they have is not the original,
so that any problems introduced by others will not reflect on the
original authors' reputations.
Finally, any free program is threatened constantly by software
patents. We wish to avoid the danger that redistributors of a free
program will individually obtain patent licenses, in effect making the
program proprietary. To prevent this, we have made it clear that any
patent must be licensed for everyone's free use or not licensed at all.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
0. This License applies to any program or other work which contains a
notice placed by the copyright holder saying it may be distributed
under the terms of this General Public License. The "Program", below,
refers to any such program or work, and a "work based on the Program"
means either the Program or any derivative work under copyright law:
that is to say, a work containing the Program or a portion of it,
either verbatim or with modifications and/or translated into another
language. (Hereinafter, translation is included without limitation in
the term "modification".) Each licensee is addressed as "you".
Activities other than copying, distribution and modification are not
covered by this License; they are outside its scope. The act of
running the Program is not restricted, and the output from the Program
is covered only if its contents constitute a work based on the
Program (independent of having been made by running the Program).
Whether that is true depends on what the Program does.
1. You may copy and distribute verbatim copies of the Program's source
code as you receive it, in any medium, provided that you conspicuously
and appropriately publish on each copy an appropriate copyright notice
and disclaimer of warranty; keep intact all the notices that refer to
this License and to the absence of any warranty; and give any other
recipients of the Program a copy of this License along with the
Program.
You may charge a fee for the physical act of transferring a copy, and
you may at your option offer warranty protection in exchange for a fee.
2. You may modify your copy or copies of the Program or any portion of
it, thus forming a work based on the Program, and copy and distribute
such modifications or work under the terms of Section 1 above,
provided that you also meet all of these conditions:
a) You must cause the modified files to carry prominent notices
stating that you changed the files and the date of any change.
b) You must cause any work that you distribute or publish, that in
whole or in part contains or is derived from the Program or any
part thereof, to be licensed as a whole at no charge to all third
parties under the terms of this License.
c) If the modified program normally reads commands interactively
when run, you must cause it, when started running for such
interactive use in the most ordinary way, to print or display an
announcement including an appropriate copyright notice and a
notice that there is no warranty (or else, saying that you provide
a warranty) and that users may redistribute the program under
these conditions, and telling the user how to view a copy of this
License. (Exception: if the Program itself is interactive but
does not normally print such an announcement, your work based on
the Program is not required to print an announcement.)
These requirements apply to the modified work as a whole. If
identifiable sections of that work are not derived from the Program,
and can be reasonably considered independent and separate works in
themselves, then this License, and its terms, do not apply to those
sections when you distribute them as separate works. But when you
distribute the same sections as part of a whole which is a work based
on the Program, the distribution of the whole must be on the terms of
this License, whose permissions for other licensees extend to the
entire whole, and thus to each and every part regardless of who wrote it.
Thus, it is not the intent of this section to claim rights or contest
your rights to work written entirely by you; rather, the intent is to
exercise the right to control the distribution of derivative or
collective works based on the Program.
In addition, mere aggregation of another work not based on the Program
with the Program (or with a work based on the Program) on a volume of
a storage or distribution medium does not bring the other work under
the scope of this License.
3. You may copy and distribute the Program (or a work based on it,
under Section 2) in object code or executable form under the terms of
Sections 1 and 2 above provided that you also do one of the following:
a) Accompany it with the complete corresponding machine-readable
source code, which must be distributed under the terms of Sections
1 and 2 above on a medium customarily used for software interchange; or,
b) Accompany it with a written offer, valid for at least three
years, to give any third party, for a charge no more than your
cost of physically performing source distribution, a complete
machine-readable copy of the corresponding source code, to be
distributed under the terms of Sections 1 and 2 above on a medium
customarily used for software interchange; or,
c) Accompany it with the information you received as to the offer
to distribute corresponding source code. (This alternative is
allowed only for noncommercial distribution and only if you
received the program in object code or executable form with such
an offer, in accord with Subsection b above.)
The source code for a work means the preferred form of the work for
making modifications to it. For an executable work, complete source
code means all the source code for all modules it contains, plus any
associated interface definition files, plus the scripts used to
control compilation and installation of the executable. However, as a
special exception, the source code distributed need not include
anything that is normally distributed (in either source or binary form)
with the major components (compiler, kernel, and so on) of the
operating system on which the executable runs, unless that component
itself accompanies the executable.
If distribution of executable or object code is made by offering
access to copy from a designated place, then offering equivalent access
to copy the source code from the same place counts as distribution of
the source code, even though third parties are not compelled to copy
the source along with the object code.
4. You may not copy, modify, sublicense, or distribute the Program
except as expressly provided under this License. Any attempt
otherwise to copy, modify, sublicense or distribute the Program is
void, and will automatically terminate your rights under this License.
However, parties who have received copies, or rights, from you under
this License will not have their licenses terminated so long as such
parties remain in full compliance.
5. You are not required to accept this License, since you have not
signed it. However, nothing else grants you permission to modify or
distribute the Program or its derivative works. These actions are
prohibited by law if you do not accept this License. Therefore, by
modifying or distributing the Program (or any work based on the
Program), you indicate your acceptance of this License to do so, and
all its terms and conditions for copying, distributing or modifying
the Program or works based on it.
6. Each time you redistribute the Program (or any work based on the
Program), the recipient automatically receives a license from the
original licensor to copy, distribute or modify the Program subject to
these terms and conditions. You may not impose any further
restrictions on the recipients' exercise of the rights granted herein.
You are not responsible for enforcing compliance by third parties to
this License.
7. If, as a consequence of a court judgment or allegation of patent
infringement or for any other reason (not limited to patent issues),
conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot
distribute so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you
may not distribute the Program at all. For example, if a patent
license would not permit royalty-free redistribution of the Program by
all those who receive copies directly or indirectly through you, then
the only way you could satisfy both it and this License would be to
refrain entirely from distribution of the Program.
If any portion of this section is held invalid or unenforceable under
any particular circumstance, the balance of the section is intended to
apply and the section as a whole is intended to apply in other
circumstances.
It is not the purpose of this section to induce you to infringe any
patents or other property right claims or to contest validity of any
such claims; this section has the sole purpose of protecting the
integrity of the free software distribution system, which is
implemented by public license practices. Many people have made
generous contributions to the wide range of software distributed
through that system in reliance on consistent application of that
system; it is up to the author/donor to decide if he or she is willing
to distribute software through any other system and a licensee cannot
impose that choice.
This section is intended to make thoroughly clear what is believed to
be a consequence of the rest of this License.
8. If the distribution and/or use of the Program is restricted in
certain countries either by patents or by copyrighted interfaces, the
original copyright holder who places the Program under this License
may add an explicit geographical distribution limitation excluding
those countries, so that distribution is permitted only in or among
countries not thus excluded. In such case, this License incorporates
the limitation as if written in the body of this License.
9. The Free Software Foundation may publish revised and/or new versions
of the General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the Program
specifies a version number of this License which applies to it and "any
later version", you have the option of following the terms and
conditions either of that version or of any later version published by
the Free Software Foundation. If the Program does not specify a
version number of this License, you may choose any version ever
published by the Free Software Foundation.
10. If you wish to incorporate parts of the Program into other free
programs whose distribution conditions are different, write to the
author to ask for permission. For software which is copyrighted by the
Free Software Foundation, write to the Free Software Foundation; we
sometimes make exceptions for this. Our decision will be guided by the
two goals of preserving the free status of all derivatives of our free
software and of promoting the sharing and reuse of software generally.
NO WARRANTY
11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO
WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW.
EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR
OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY
KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE
PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME
THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN
WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY
AND/OR REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU
FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR
CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE
PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING
RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A
FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF
SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH
DAMAGES.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these
terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
convey the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
one line to give the program's name and an idea of what it does.
Copyright (C) yyyy name of author
This program is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation; either version 2 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License along
with this program; if not, write to the Free Software Foundation, Inc.,
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
Also add information on how to contact you by electronic and paper mail.
If the program is interactive, make it output a short notice like this
when it starts in an interactive mode:
Gnomovision version 69, Copyright (C) year name of author
Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the
appropriate parts of the General Public License. Of course, the
commands you use may be called something other than `show w' and
`show c'; they could even be mouse-clicks or menu items--whatever
suits your program.
You should also get your employer (if you work as a programmer) or
your school, if any, to sign a "copyright disclaimer" for the program,
if necessary. Here is a sample; alter the names:
Yoyodyne, Inc., hereby disclaims all copyright interest in the
program `Gnomovision' (which makes passes at compilers) written
by James Hacker.
signature of Ty Coon, 1 April 1989
Ty Coon, President of Vice
This General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library,
you may consider it more useful to permit linking proprietary
applications with the library. If this is what you want to do, use the
GNU Lesser General Public License instead of this License.

32
Package.swift Normal file
View File

@@ -0,0 +1,32 @@
// swift-tools-version: 5.9
import PackageDescription
let package = Package(
name: "ClipBored",
platforms: [
.macOS(.v13)
],
products: [
.executable(name: "ClipBored", targets: ["ClipBored"])
],
targets: [
.executableTarget(
name: "ClipBored",
path: "sources/clipbored",
exclude: ["resources"],
linkerSettings: [
.linkedFramework("AppKit"),
.linkedFramework("Carbon"),
.linkedFramework("LocalAuthentication"),
.linkedFramework("Security"),
.linkedFramework("Vision"),
.linkedLibrary("sqlite3")
]
),
.testTarget(
name: "ClipBoredTests",
dependencies: ["ClipBored"],
path: "tests/clipboredtests"
)
]
)

83
README.md Normal file
View File

@@ -0,0 +1,83 @@
# ClipBored
ClipBored is a small native macOS clipboard manager. It runs without a Dock icon, captures local clipboard history, and opens a keyboard-first bottom panel for search, sorting, copy, paste, pinning, and deletion.
The project is intentionally dependency-light: Swift Package Manager, AppKit, Carbon hotkeys, SQLite, and system frameworks only.
## Features
- Dockless menu-bar utility (`LSUIElement=true`)
- Right-click menu-bar status menu with capture state, history count, settings, pause/resume, and quit
- Global shortcuts:
- `Command + Option + V` toggles the clipboard panel
- `Command + ,` opens settings
- Clipboard history for text, URLs with local preview thumbnails when available, images, audio, RTF/HTML rich text, PDFs, and file references
- SQLite persistence with bounded history, pinned-item retention, and encrypted app-managed payloads
- Search with independent token matching, plus optional local OCR for copied images
- Sort modes for recent, most used, images, links, text, files, audio, and pinned items
- Custom named collections for organizing clips from the card context menu
- Copy and paste actions with Accessibility permission fallback
- Image thumbnail cache with byte and file-count pruning
- Configurable history length, cache limit, polling profile, ignored apps, content kinds, launch-at-login, and clear-on-quit behavior
- Local-only storage, with optional sensitive-content exclusion for common secrets
## Requirements
- macOS 13 or newer
- Xcode command line tools with Swift 5.9 or newer
## Build
```bash
swift test
./scripts/build-macos-app.sh
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.
## Development
Run the full local validation:
```bash
./scripts/check.sh
```
Useful commands:
```bash
swift test -q
./scripts/build-macos-app.sh
./scripts/release-macos-app.sh
./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 distribution builds, see [docs/RELEASE.md](docs/RELEASE.md).
Project layout:
- `sources/clipbored/app` - app entry point and service wiring
- `sources/clipbored/config` - defaults and power/size guardrails
- `sources/clipbored/extensions` - small AppKit/Foundation helpers
- `sources/clipbored/models` - clipboard item and settings models
- `sources/clipbored/resources` - app bundle metadata and icon assets
- `sources/clipbored/services` - clipboard capture, persistence, cache, shortcuts, paste, diagnostics, privacy filters
- `sources/clipbored/views` - panel and settings UI
- `tests/clipboredtests` - unit tests for persistence, filtering, shortcuts, pasteboard writes, diagnostics, and sensitive-content detection
- `docs` - architecture, security notes, and roadmap
## Privacy And Security
ClipBored does not use network APIs or telemetry. Clipboard history is stored locally under Application Support.
Textual SQLite fields, image cache files, audio clips, rich text sidecars, and PDF attachments are encrypted with AES-GCM using a Keychain-held key when Keychain access is available. If Keychain access blocks or fails, ClipBored uses an owner-only app-local fallback key so capture does not stall. Full history clears remove the local fallback key when present and reset cached key state for future captures. Temporary decrypted preview files may be created when opening or revealing encrypted media; stale previews are cleared on launch, cache/history clear, and quit. Use sensitive-content exclusion and ignored app settings for high-risk sources. See [docs/SECURITY.md](docs/SECURITY.md) for details and responsible disclosure.
## Contributing
See [CONTRIBUTING.md](CONTRIBUTING.md). The current roadmap is in [docs/ROADMAP.md](docs/ROADMAP.md).
## License
GPL-2.0-only. See [LICENSE](LICENSE).

31
SECURITY.md Normal file
View File

@@ -0,0 +1,31 @@
# Security Policy
## Supported Versions
The main branch is the supported development line until tagged releases exist.
## Reporting A Vulnerability
Please do not open a public issue with exploit details or sensitive clipboard examples.
Report privately by contacting the maintainer listed in the repository profile. Include:
- affected version or commit
- macOS version
- reproduction steps
- expected and actual behavior
- whether clipboard payloads, files, permissions, or persistence are involved
## Current Security Model
ClipBored is local-only:
- no network APIs
- no telemetry
- no remote sync
- no shell/process execution
- no Apple Events scripting
The app stores history, image cache files, rich text sidecars, audio clips, and PDF attachments in the user's Application Support directory under the same app-owned area. Textual SQLite fields, optional local image OCR text, and app-managed image/rich text/audio/PDF sidecars are encrypted with a Keychain-held key when Keychain access is 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 for future captures. SQLite metadata and short-lived temporary decrypted previews remain local files with owner-only filesystem permissions applied where supported. Users should enable sensitive-content exclusion and ignore high-risk source apps where appropriate.
See `docs/SECURITY.md` for design details and known limitations.

55
docs/ARCHITECTURE.md Normal file
View File

@@ -0,0 +1,55 @@
# Architecture
ClipBored is a single-process AppKit utility built with Swift Package Manager.
## Runtime Shape
- `ClipBoredApp` creates `NSApplication`, sets accessory activation, installs `AppDelegate`, and starts the run loop.
- `AppDelegate` wires shared services, status menu items, settings observers, and global shortcuts.
- `ClipboardMonitorService` polls `NSPasteboard.changeCount` on a utility queue with adaptive active/idle intervals.
- `ClipboardStore` keeps the in-memory item list and persists rows to SQLite on a serial queue.
- `ClipboardCacheService` stores bounded image previews under Application Support and keeps a small `NSCache`.
- `ShortcutManager` registers Carbon hotkeys for app-wide commands.
- `ClipboardPanelController` owns the bottom panel lifecycle and target-app tracking.
- `ClipboardPanelViewModel` filters, sorts, selects, copies, pastes, pins, organizes, deletes, opens, and reveals items.
- `SettingsWindowController` exposes native controls for capture, privacy, performance, shortcuts, and data management.
## Data Flow
1. The monitor notices a pasteboard change.
2. Source app metadata is checked against ignored apps.
3. Pasteboard content is normalized into a `ClipboardItem`.
4. Sensitive text is skipped when exclusion is enabled.
5. Copied images run local Vision OCR only when `Search in image labels` is enabled.
6. The store deduplicates, preserves pinned and collection-assigned items, enforces limits, and persists the mutation.
7. The panel view model receives store updates and recomputes the visible list.
## Persistence
History is stored in SQLite at:
```text
~/Library/Application Support/ClipBored/history.sqlite
```
Images are stored under:
```text
~/Library/Application Support/ClipBored/images/
```
Restorable non-image payloads such as audio clips, rich text, and PDFs are stored under:
```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
The release build intentionally avoids SwiftUI, Combine, Swift Concurrency, third-party packages, bundled media, and app resources beyond `Info.plist`.
The build script uses `-Osize`, whole-module optimization, disabled reflection metadata, linker dead stripping, symbol stripping, and hardened-runtime signing. The current public targets, enforced by `scripts/build-macos-app.sh`, are a 1 MiB executable and a 1.8 MB app bundle.

82
docs/RELEASE.md Normal file
View File

@@ -0,0 +1,82 @@
# Release Guide
This guide covers the local release build, optional Developer ID signing, and optional notarization flow.
## Local Validation
Run:
```bash
./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.
## Local Archive
Run:
```bash
./scripts/release-macos-app.sh
```
Without signing credentials, this creates:
```text
build/ClipBored.app
build/ClipBored.zip
```
The app remains ad-hoc signed and is suitable for local validation only.
## Developer ID Signing
Set a Developer ID Application identity:
```bash
export DEVELOPER_ID_APPLICATION="Developer ID Application: Example, Inc. (TEAMID)"
./scripts/release-macos-app.sh
```
The script rebuilds the app, re-signs it with hardened runtime and timestamping, verifies the signature, and writes `build/ClipBored.zip`.
## Notarization
Preferred: configure a notarytool keychain profile once:
```bash
xcrun notarytool store-credentials "clipbored-notary" \
--apple-id "developer@example.com" \
--team-id "TEAMID" \
--password "app-specific-password"
```
Then run:
```bash
export DEVELOPER_ID_APPLICATION="Developer ID Application: Example, Inc. (TEAMID)"
export NOTARYTOOL_PROFILE="clipbored-notary"
./scripts/release-macos-app.sh
```
Alternative environment-only notarization:
```bash
export DEVELOPER_ID_APPLICATION="Developer ID Application: Example, Inc. (TEAMID)"
export APPLE_ID="developer@example.com"
export APPLE_TEAM_ID="TEAMID"
export APPLE_APP_SPECIFIC_PASSWORD="app-specific-password"
./scripts/release-macos-app.sh
```
When notarization succeeds, the script staples the ticket to `build/ClipBored.app`, validates the staple, and recreates `build/ClipBored.zip`.
## Final Manual Checks
Before publishing, run the checklist in [SMOKE_TEST.md](SMOKE_TEST.md), then confirm:
```bash
codesign --verify --deep --strict --verbose=2 build/ClipBored.app
xcrun stapler validate build/ClipBored.app
spctl --assess --type execute --verbose=4 build/ClipBored.app
```

26
docs/ROADMAP.md Normal file
View File

@@ -0,0 +1,26 @@
# 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.
## Near Term
- Add an idle power measurement note using Instruments or Activity Monitor alongside `scripts/idle-soak-report.sh`.
- Add tagged release notes once a first public version is cut.
## Privacy And Security
- Keep improving secure cleanup semantics for cleared cache/history/key material where macOS storage behavior allows it.
- Keep the current no-network/no-telemetry posture unless the project explicitly changes direction.
## Product Polish
- Improve keyboard focus states and VoiceOver labels.
- Add a compact mode for narrower displays.
- Add import/export only if the storage and privacy story remains clear.
## Performance
- Keep measuring binary size after each feature.
- Avoid continuous background file scans.
- Revisit polling intervals only with measured idle wakeup evidence.
- Keep image decoding lazy and cache bounded.

54
docs/SECURITY.md Normal file
View File

@@ -0,0 +1,54 @@
# Security Notes
ClipBored is designed as a local macOS utility. Its primary privacy promise is that clipboard data stays on the machine.
## Current Protections
- No networking or telemetry in production source.
- No shell/process execution.
- No Apple Events scripting.
- Hardened runtime is applied by the local build script, and the release script supports Developer ID signing plus notarization when credentials are configured.
- 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.
- App-managed image cache files, audio 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.
- 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.
- ClipBored marks its own pasteboard writes so copy/paste actions from history are not re-captured as new clipboard events.
- Sensitive-content exclusion can skip common high-risk values:
- private key blocks
- bearer tokens
- GitHub tokens
- Slack tokens
- AWS access key IDs
- Stripe keys
- OpenAI-style API keys
- Google API keys
- JSON Web Tokens
- Luhn-valid credit-card-like values
- OTP-like values from known authenticator/password-manager sources
- long high-entropy token-like strings
- obvious password/secret keywords and common secret assignment forms
- Default ignored apps include common password managers and authenticators.
## Known Limitations
- 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.
- 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.
- Existing plaintext SQLite rows and legacy sidecar files are migrated when encryption becomes available, but system snapshots, backups, live temporary previews, or filesystem remnants may retain older plaintext copies.
- The local development build is ad-hoc signed; use `scripts/release-macos-app.sh` with Developer ID credentials for notarized distribution builds.
- Accessibility permission is required for automatic paste simulation.
- 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.
- 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
- Run `swift test -q`.
- Run `./scripts/build-macos-app.sh` or `./scripts/release-macos-app.sh`.
- Verify `codesign --verify --deep --strict --verbose=2 build/ClipBored.app`.
- Verify hardened runtime appears in `codesign -d --verbose=4 build/ClipBored.app`.
- For distribution, verify `xcrun stapler validate build/ClipBored.app` and `spctl --assess --type execute --verbose=4 build/ClipBored.app`.
- Confirm no new `URLSession`, process execution, Apple Events, telemetry, or remote sync APIs were introduced.
- Review any new persistence paths for unencrypted sensitive data.

86
docs/SMOKE_TEST.md Normal file
View File

@@ -0,0 +1,86 @@
# 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.
## Setup
1. Build the app:
```bash
./scripts/check.sh
```
2. Quit any running ClipBored copy.
3. Open `build/ClipBored.app`.
4. Confirm ClipBored appears in the menu bar when `Show ClipBored in the menu bar` is enabled.
## Capture
1. Copy plain text from TextEdit, Notes, or a browser.
2. Open the panel with `Command + Option + V`.
3. Confirm the copied text appears in Most Recent.
4. Copy a URL and confirm it appears as a Link; if the source provides a local preview image, confirm the Link card uses that preview.
5. Copy an image and confirm it appears as an Image with a thumbnail.
6. Enable `Search in image labels`, copy an image containing readable text, and confirm searching for that text finds the Image.
7. Copy a sound clip and confirm it appears as Audio.
8. Copy a PDF or PDF selection and confirm it appears as a PDF.
9. Copy one Finder file and confirm it appears as a File.
10. Copy multiple Finder files at once and confirm they appear as one grouped File item with the file count.
11. Copy formatted text from a browser or Mail message and confirm it appears as Rich Text rather than flattened plain text.
12. Disable Images, Audio, Rich Text, PDFs, or Files in Settings > Capture, copy that type again, and confirm it is not captured.
## Panel
1. Open the panel and confirm the search field is focused.
2. Type a query and confirm results filter immediately.
3. Use arrow keys to move selection while the search field is focused.
4. Press `Esc` once with a non-empty search field and confirm search clears.
5. Press `Esc` again and confirm the panel closes.
6. Reopen the panel, change sort segments, and confirm each segment updates results.
7. Right-click a card, choose Add to Collection > New Collection..., enter `Client Work`, and confirm a Client Work chip appears with the item count.
8. Right-click another card and confirm Add to Collection offers Client Work as a reusable destination.
9. Select the Client Work chip and confirm the rail filters to assigned items; quit and reopen ClipBored and confirm the assignment persists.
10. Double-click an item and confirm it attempts to paste or falls back to copy without creating a duplicate history entry.
## Copy And Paste
1. Select a text item and press the Copy button. Confirm the system clipboard contains that text.
2. Select a URL item and confirm the system clipboard contains both string and URL data by pasting into a browser address bar.
3. Select one-file and multi-file File items and paste into Finder or an app that accepts file references. Confirm all files are preserved for the multi-file item.
4. Select an audio item and paste into an app that accepts sound pasteboard data.
5. Select a PDF item and paste into Preview, Finder, or an app that accepts PDF pasteboard data.
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. Without Accessibility permission, confirm paste actions copy and show the permission fallback status.
8. With Accessibility permission granted, confirm paste returns focus to the previous app and inserts the selected item.
## Settings
1. Open Settings with `Command + ,`.
2. Change history length, default sort, polling profile, cache limit, ignored apps, and allowed content types; quit and reopen the app; confirm settings persist.
3. Change the open-panel shortcut and confirm the old shortcut no longer opens the panel and the new shortcut does.
4. Toggle `Pause clipboard capture`, copy text, and confirm paused capture does not record it.
5. Toggle `Exclude likely secrets`, copy a representative token, and confirm it is not recorded.
6. Use `Open Accessibility Settings` and confirm System Settings opens to the permission area or fallback settings app.
7. Use `Clear Clipboard History` and `Clear Thumbnail Cache`; confirm each shows a warning confirmation before deleting data.
## Storage And Privacy
1. Open the data folder from Settings > Data.
2. Confirm `history.sqlite` exists after capture.
3. Copy unique text and confirm `strings ~/Library/Application\ Support/ClipBored/history.sqlite | grep "unique text"` does not find it.
4. Copy uniquely identifiable rich text/audio/PDF data and confirm `strings ~/Library/Application\ Support/ClipBored/attachments/* | grep "unique text"` does not find it.
5. If `history-encryption.key` exists, confirm it is readable only by the current user.
6. Confirm image files are under `images/` and rich text/audio/PDF attachments are under `attachments/`.
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
1. Enable Launch at Login, log out and back in, and confirm ClipBored starts.
2. Disable Launch at Login and confirm it no longer starts after the next login.
3. Right-click the menu-bar icon and confirm the status menu opens with capture state, clip count, Show Clipboard, Settings, Pause/Resume Capture, and Quit.
4. Control-click the menu-bar icon and confirm the same status menu opens without toggling the panel.
5. Toggle Pause/Resume Capture from the status menu and confirm the status row changes.
6. Quit ClipBored from the menu bar and confirm no `ClipBored` process remains.

49
scripts/build-macos-app.sh Executable file
View File

@@ -0,0 +1,49 @@
#!/usr/bin/env bash
set -euo pipefail
APP_NAME="ClipBored"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
OUTPUT_ROOT="$REPO_ROOT/build"
APP_BUNDLE="$OUTPUT_ROOT/${APP_NAME}.app"
BIN_NAME="$APP_NAME"
BIN_PATH="$REPO_ROOT/.build/release/$BIN_NAME"
INFO_PLIST="$REPO_ROOT/sources/clipbored/resources/Info.plist"
ICON_FILE="$REPO_ROOT/sources/clipbored/resources/AppIcon.icns"
cd "$REPO_ROOT"
swift build -c release --product "$APP_NAME" \
-Xswiftc -Osize \
-Xswiftc -whole-module-optimization \
-Xswiftc -gnone \
-Xswiftc -Xfrontend \
-Xswiftc -disable-reflection-metadata \
-Xlinker -dead_strip \
-Xlinker -no_function_starts
rm -rf "$APP_BUNDLE"
mkdir -p "$APP_BUNDLE/Contents/MacOS" "$APP_BUNDLE/Contents/Resources"
cp "$INFO_PLIST" "$APP_BUNDLE/Contents/Info.plist"
cp "$ICON_FILE" "$APP_BUNDLE/Contents/Resources/AppIcon.icns"
cp "$BIN_PATH" "$APP_BUNDLE/Contents/MacOS/$APP_NAME"
chmod +x "$APP_BUNDLE/Contents/MacOS/$APP_NAME"
strip "$APP_BUNDLE/Contents/MacOS/$APP_NAME"
codesign --deep --force --options runtime --sign - "$APP_BUNDLE" >/dev/null 2>&1 || true
touch "$APP_BUNDLE"
APP_SIZE=$(stat -f%z "$APP_BUNDLE/Contents/MacOS/$APP_NAME")
APP_SIZE_LIMIT=$((1024 * 1024))
HUMAN_SIZE=$(du -h "$APP_BUNDLE/Contents/MacOS/$APP_NAME" | cut -f1)
APP_BUNDLE_SIZE=$(du -sh "$APP_BUNDLE" | cut -f1)
APP_BUNDLE_BYTES=$(du -sk "$APP_BUNDLE" | awk '{print $1*1024}')
echo "Built $APP_BUNDLE"
echo "Binary size: $HUMAN_SIZE ($APP_SIZE bytes)"
echo "Bundle size: $APP_BUNDLE_SIZE"
if [ "$APP_SIZE" -gt "$APP_SIZE_LIMIT" ]; then
echo "FAIL: executable exceeds 1MiB target ($APP_SIZE bytes)"
exit 1
fi
if [ "$APP_BUNDLE_BYTES" -gt 1800000 ]; then
echo "FAIL: bundle exceeds 1.8MB target ($APP_BUNDLE_BYTES bytes)"
exit 1
fi

12
scripts/check.sh Executable file
View File

@@ -0,0 +1,12 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
cd "$REPO_ROOT"
bash -n "$SCRIPT_DIR/release-macos-app.sh"
swift test -q
"$SCRIPT_DIR/build-macos-app.sh"
codesign --verify --deep --strict --verbose=2 "$REPO_ROOT/build/ClipBored.app"

22
scripts/idle-soak-report.sh Executable file
View File

@@ -0,0 +1,22 @@
#!/usr/bin/env bash
set -euo pipefail
APP_NAME="ClipBored"
DURATION_SECONDS="${1:-900}"
PID="$(pgrep -x "$APP_NAME" | head -n 1 || true)"
if [ -z "$PID" ]; then
echo "ClipBored is not running. Launch build/ClipBored.app first."
exit 1
fi
echo "Idle soak for $APP_NAME (pid $PID)"
echo "Duration: ${DURATION_SECONDS}s"
echo "Start:"
ps -o pid,pcpu,pmem,time,command -p "$PID"
sleep "$DURATION_SECONDS"
echo "End:"
ps -o pid,pcpu,pmem,time,command -p "$PID"
echo "Use Instruments or Activity Monitor Energy tab for wakeup/energy validation."

102
scripts/release-macos-app.sh Executable file
View File

@@ -0,0 +1,102 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
APP_NAME="ClipBored"
APP_BUNDLE="$REPO_ROOT/build/${APP_NAME}.app"
ZIP_PATH="$REPO_ROOT/build/${APP_NAME}.zip"
usage() {
cat <<'USAGE'
Usage: scripts/release-macos-app.sh
Builds build/ClipBored.app, optionally re-signs it with a Developer ID
Application certificate, optionally notarizes it, staples the ticket, and
creates build/ClipBored.zip.
Optional environment:
DEVELOPER_ID_APPLICATION codesign identity, e.g. "Developer ID Application: Example, Inc. (TEAMID)"
NOTARYTOOL_PROFILE preferred notarytool keychain profile
Alternative notarization credentials when NOTARYTOOL_PROFILE is not set:
APPLE_ID Apple ID email
APPLE_TEAM_ID Apple Developer Team ID
APPLE_APP_SPECIFIC_PASSWORD app-specific password
Without DEVELOPER_ID_APPLICATION, this script performs a local ad-hoc signed
release build and zip only. Notarization is skipped.
USAGE
}
create_zip_archive() {
rm -f "$ZIP_PATH"
(
cd "$REPO_ROOT/build"
/usr/bin/zip -qry --symlinks "$ZIP_PATH" "${APP_NAME}.app"
)
local archive_list
archive_list="$(/usr/bin/unzip -l "$ZIP_PATH")"
if [[ "$archive_list" == *"__MACOSX"* || "$archive_list" == *"/._"* ]]; then
echo "FAIL: release archive contains macOS metadata sidecar files."
exit 1
fi
}
if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
usage
exit 0
fi
cd "$REPO_ROOT"
"$SCRIPT_DIR/build-macos-app.sh"
if [[ -n "${DEVELOPER_ID_APPLICATION:-}" ]]; then
echo "Signing with Developer ID identity: $DEVELOPER_ID_APPLICATION"
codesign \
--deep \
--force \
--options runtime \
--timestamp \
--sign "$DEVELOPER_ID_APPLICATION" \
"$APP_BUNDLE"
else
echo "DEVELOPER_ID_APPLICATION is not set; keeping local ad-hoc signature."
fi
codesign --verify --deep --strict --verbose=2 "$APP_BUNDLE"
SIGNATURE_DETAILS="$(codesign -d --verbose=4 "$APP_BUNDLE" 2>&1)"
if [[ "$SIGNATURE_DETAILS" != *"runtime"* ]]; then
echo "FAIL: hardened runtime was not found in the code signature."
exit 1
fi
create_zip_archive
echo "Created $ZIP_PATH"
if [[ -z "${DEVELOPER_ID_APPLICATION:-}" ]]; then
echo "Skipping notarization because Developer ID signing is not configured."
exit 0
fi
if [[ -n "${NOTARYTOOL_PROFILE:-}" ]]; then
echo "Submitting notarization with keychain profile: $NOTARYTOOL_PROFILE"
xcrun notarytool submit "$ZIP_PATH" --keychain-profile "$NOTARYTOOL_PROFILE" --wait
elif [[ -n "${APPLE_ID:-}" && -n "${APPLE_TEAM_ID:-}" && -n "${APPLE_APP_SPECIFIC_PASSWORD:-}" ]]; then
echo "Submitting notarization with Apple ID credentials for team: $APPLE_TEAM_ID"
xcrun notarytool submit "$ZIP_PATH" \
--apple-id "$APPLE_ID" \
--team-id "$APPLE_TEAM_ID" \
--password "$APPLE_APP_SPECIFIC_PASSWORD" \
--wait
else
echo "Notarization credentials are not configured; signed zip is ready but not notarized."
exit 0
fi
xcrun stapler staple "$APP_BUNDLE"
xcrun stapler validate "$APP_BUNDLE"
create_zip_archive
echo "Created notarized release archive: $ZIP_PATH"

View File

@@ -0,0 +1,512 @@
import AppKit
final class AppDelegate: NSObject, NSApplicationDelegate {
struct StatusMenuPresentation: Equatable {
let summary: String
let detail: String?
}
private static let statusMenuTextLimit = 68
private var cacheService: ClipboardCacheService!
private var settings: SettingsModel!
private var store: ClipboardStore!
private var monitor: ClipboardMonitorService!
private var panelController: ClipboardPanelController!
private var settingsController: SettingsWindowController!
private var shortcutManager: ShortcutManager!
private var lifecycleService: AppLifecycleService!
private var statusItem: NSStatusItem?
private var statusMenu: NSMenu?
func applicationDidFinishLaunching(_ notification: Notification) {
settings = SettingsModel()
cacheService = ClipboardCacheService()
store = ClipboardStore(settings: settings, cacheService: cacheService)
monitor = ClipboardMonitorService(store: store, cacheService: cacheService, settings: settings)
panelController = ClipboardPanelController(
store: store,
settings: settings,
cacheService: cacheService,
preferredScreen: { [weak self] in
self?.statusItem?.button?.window?.screen
},
pollClipboardNow: { [weak monitor] in
monitor?.pollNowAndWait()
},
openSettings: { [weak self] in
self?.openSettings()
}
)
settingsController = SettingsWindowController(settings: settings, store: store, cacheService: cacheService)
lifecycleService = AppLifecycleService()
shortcutManager = ShortcutManager(
onOpenClipboardPanel: { [weak self] in
DispatchQueue.main.async {
self?.panelController.toggle()
}
},
onOpenSettings: { [weak self] in
DispatchQueue.main.async {
self?.refreshAccessibilityPermissionMessage()
self?.settingsController.show()
}
},
onStatusChange: { [weak self] status in
DispatchQueue.main.async {
self?.settings.setShortcutStatus(message: status.message)
}
},
openShortcut: settings.openShortcut,
settingsShortcut: settings.settingsShortcut
)
bindSettings()
monitor.setPaused(settings.pauseCapture)
monitor.start()
shortcutManager.start()
applyLaunchAtLoginSetting(settings.launchAtLogin)
refreshStatusItem()
configureMainMenu()
requestInitialAccessibilityPermissionIfNeeded()
}
func applicationDidBecomeActive(_ notification: Notification) {
refreshAccessibilityPermissionMessage()
}
func applicationWillTerminate(_ notification: Notification) {
monitor.stop()
shortcutManager.stop()
cacheService.clearTemporaryPreviews(wait: true)
if settings.clearHistoryOnQuit {
store.removeAll()
store.flushPersistenceForTesting()
cacheService.clearCache()
cacheService.flushForTesting()
}
}
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
false
}
@objc private func showClipboardPanel() {
panelController.toggle()
}
@objc private func statusItemClicked(_ sender: NSStatusBarButton) {
let event = NSApp.currentEvent
if shouldOpenStatusMenu(for: event) {
popStatusMenu(from: sender)
return
}
showClipboardPanel()
}
private func shouldOpenStatusMenu(for event: NSEvent?) -> Bool {
guard let event else { return false }
return Self.shouldOpenStatusMenu(eventType: event.type, modifierFlags: event.modifierFlags)
}
static func shouldOpenStatusMenu(eventType: NSEvent.EventType, modifierFlags: NSEvent.ModifierFlags) -> Bool {
switch eventType {
case .rightMouseDown, .rightMouseUp:
return true
case .leftMouseDown, .leftMouseUp:
return modifierFlags.intersection(.deviceIndependentFlagsMask).contains(.control)
case .otherMouseDown, .otherMouseUp:
return true
case .leftMouseDragged, .rightMouseDragged, .otherMouseDragged:
return false
default:
return false
}
}
private func statusMenuTemplate() -> NSMenu {
Self.makeStatusMenu(
presentation: Self.statusMenuPresentation(
historyCount: store.items.count,
isCapturePaused: settings.pauseCapture,
captureStatus: settings.captureStatusMessage,
pasteStatus: settings.pasteStatusMessage,
shortcutStatus: settings.shortcutStatusMessage,
accessibilityStatus: settings.accessibilityPermissionStatusMessage,
launchAtLoginStatus: settings.launchAtLoginErrorMessage
),
isCapturePaused: settings.pauseCapture,
openShortcut: settings.openShortcut,
settingsShortcut: settings.settingsShortcut,
target: self
)
}
private func refreshStatusMenu() {
statusMenu = statusMenuTemplate()
}
static func statusMenuPresentation(
historyCount: Int,
isCapturePaused: Bool,
captureStatus: String,
pasteStatus: String,
shortcutStatus: String,
accessibilityStatus: String,
launchAtLoginStatus: String
) -> StatusMenuPresentation {
let captureState = isCapturePaused ? "Capture Paused" : "Capture Running"
let summary = "\(captureState) - \(clipCountText(historyCount))"
let status = firstPresentStatus([
isCapturePaused ? "Capture is paused." : nil,
captureStatus,
pasteStatus,
shortcutStatus,
launchAtLoginStatus,
accessibilityStatus
])
return StatusMenuPresentation(
summary: boundedStatusText(summary),
detail: status.map { boundedStatusText($0) }
)
}
static func makeStatusMenu(
presentation: StatusMenuPresentation,
isCapturePaused: Bool,
openShortcut: ShortcutBinding,
settingsShortcut: ShortcutBinding,
target: AnyObject?
) -> NSMenu {
let menu = NSMenu(title: "ClipBored")
menu.autoenablesItems = false
addDisabledMenuItem("ClipBored", to: menu, symbolName: "doc.on.clipboard")
addDisabledMenuItem(presentation.summary, to: menu, symbolName: isCapturePaused ? "pause.circle" : "checkmark.circle")
if let detail = presentation.detail {
addDisabledMenuItem(detail, to: menu, symbolName: "info.circle")
}
menu.addItem(NSMenuItem.separator())
addActionMenuItem(
"Show Clipboard",
action: #selector(showClipboardPanel),
target: target,
keyEquivalent: openShortcut.key,
keyEquivalentModifierMask: modifierFlags(for: openShortcut),
symbolName: "rectangle.bottomthird.inset.filled",
to: menu
)
addActionMenuItem(
"Settings\u{2026}",
action: #selector(openSettings),
target: target,
keyEquivalent: settingsShortcut.key,
keyEquivalentModifierMask: modifierFlags(for: settingsShortcut),
symbolName: "gearshape",
to: menu
)
menu.addItem(NSMenuItem.separator())
let pause = addActionMenuItem(
isCapturePaused ? "Resume Capture" : "Pause Capture",
action: #selector(togglePauseCapture),
target: target,
symbolName: isCapturePaused ? "play.fill" : "pause.fill",
to: menu
)
pause.state = isCapturePaused ? .on : .off
menu.addItem(NSMenuItem.separator())
addActionMenuItem(
"Quit ClipBored",
action: #selector(quitApp),
target: target,
keyEquivalent: "q",
keyEquivalentModifierMask: .command,
symbolName: "power",
to: menu
)
return menu
}
@objc private func openSettings() {
refreshAccessibilityPermissionMessage()
settingsController.show()
}
@objc private func togglePauseCapture() {
settings.pauseCapture.toggle()
}
@objc private func quitApp() {
NSApp.terminate(nil)
}
private func refreshStatusItem() {
guard settings.showMenuBarIcon else {
if let statusItem {
NSStatusBar.system.removeStatusItem(statusItem)
self.statusItem = nil
}
return
}
if statusItem == nil {
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength)
}
if let button = statusItem?.button {
if let icon = appIconImage() {
button.image = icon
} else if let icon = NSImage(systemSymbolName: "doc.on.clipboard.fill", accessibilityDescription: "ClipBored") {
icon.isTemplate = true
button.image = icon
}
button.toolTip = "ClipBored"
button.sendAction(on: [
.leftMouseUp,
.rightMouseUp,
.otherMouseUp
])
button.target = self
button.action = #selector(statusItemClicked(_:))
}
refreshStatusMenu()
statusItem?.menu = nil
}
private func popStatusMenu(from button: NSStatusBarButton) {
refreshStatusMenu()
statusMenu?.popUp(positioning: nil, at: NSPoint(x: 0, y: button.bounds.maxY), in: button)
}
@discardableResult
private func addMenuItem(_ title: String, _ action: Selector, to menu: NSMenu) -> NSMenuItem {
let item = NSMenuItem(title: title, action: action, keyEquivalent: "")
item.target = self
item.isEnabled = true
menu.addItem(item)
return item
}
private static func firstPresentStatus(_ candidates: [String?]) -> String? {
for candidate in candidates {
guard let value = candidate?.clipboardTrimmed, !value.isEmpty else { continue }
return value
}
return nil
}
private static func clipCountText(_ count: Int) -> String {
if count <= 0 { return "No clips" }
if count == 1 { return "1 clip" }
return "\(count) clips"
}
private static func boundedStatusText(_ value: String) -> String {
let collapsed = value
.split { $0.isWhitespace || $0.isNewline }
.joined(separator: " ")
.clipboardTrimmed
guard collapsed.count > statusMenuTextLimit else { return collapsed }
return String(collapsed.prefix(statusMenuTextLimit - 3)).clipboardTrimmed + "..."
}
@discardableResult
private static func addDisabledMenuItem(_ title: String, to menu: NSMenu, symbolName: String) -> NSMenuItem {
let item = NSMenuItem(title: title, action: nil, keyEquivalent: "")
item.isEnabled = false
item.image = menuImage(symbolName)
menu.addItem(item)
return item
}
@discardableResult
private static func addActionMenuItem(
_ title: String,
action: Selector,
target: AnyObject?,
keyEquivalent: String = "",
keyEquivalentModifierMask: NSEvent.ModifierFlags = [],
symbolName: String,
to menu: NSMenu
) -> NSMenuItem {
let item = NSMenuItem(title: title, action: action, keyEquivalent: keyEquivalent)
item.keyEquivalentModifierMask = keyEquivalentModifierMask
item.target = target
item.isEnabled = true
item.image = menuImage(symbolName)
menu.addItem(item)
return item
}
private static func menuImage(_ symbolName: String) -> NSImage? {
guard let image = NSImage(systemSymbolName: symbolName, accessibilityDescription: nil) else {
return nil
}
image.size = NSSize(width: 15, height: 15)
image.isTemplate = true
return image
}
private static func modifierFlags(for binding: ShortcutBinding) -> NSEvent.ModifierFlags {
NSEvent.ModifierFlags(rawValue: binding.modifierFlags)
}
private func configureMainMenu() {
let appMenu = NSMenuItem()
let appSubMenu = NSMenu(title: "ClipBored")
let settingsShortcut = self.settings.settingsShortcut
let settings = NSMenuItem(
title: "Settings…",
action: #selector(openSettings),
keyEquivalent: settingsShortcut.key
)
settings.keyEquivalentModifierMask = menuModifierFlags(settingsShortcut)
settings.target = self
appSubMenu.addItem(settings)
appSubMenu.addItem(NSMenuItem.separator())
let quit = NSMenuItem(title: "Quit ClipBored", action: #selector(quitApp), keyEquivalent: "q")
quit.target = self
appSubMenu.addItem(quit)
appMenu.submenu = appSubMenu
let editMenu = NSMenuItem()
let editSubMenu = NSMenu(title: "Edit")
let openShortcut = self.settings.openShortcut
let showClipboard = NSMenuItem(
title: "Show Clipboard",
action: #selector(showClipboardPanel),
keyEquivalent: openShortcut.key
)
showClipboard.keyEquivalentModifierMask = menuModifierFlags(openShortcut)
showClipboard.target = self
editSubMenu.addItem(showClipboard)
editMenu.submenu = editSubMenu
let mainMenu = NSMenu()
mainMenu.addItem(appMenu)
mainMenu.addItem(editMenu)
NSApp.mainMenu = mainMenu
}
private func bindSettings() {
settings.observe { [weak self] change in
guard let self else { return }
DispatchQueue.main.async {
self.handleSettingsChange(change)
}
}
}
private func handleSettingsChange(_ change: SettingsModel.Change) {
switch change {
case .maxHistoryItems:
store.updateHistoryLimit(settings.maxHistoryItems)
case .imageCacheMaxBytes:
cacheService.purgeIfNeeded(maxBytes: settings.imageCacheMaxBytes)
case .openShortcut, .settingsShortcut:
let status = shortcutManager.reconfigure(openShortcut: settings.openShortcut, settingsShortcut: settings.settingsShortcut)
settings.setShortcutStatus(message: status.message)
refreshStatusItem()
configureMainMenu()
case .launchAtLogin:
applyLaunchAtLoginSetting(settings.launchAtLogin)
case .showMenuBarIcon:
refreshStatusItem()
case .pauseCapture:
monitor.setPaused(settings.pauseCapture)
if settings.showMenuBarIcon {
refreshStatusMenu()
}
case .pollProfile:
monitor.setPaused(settings.pauseCapture)
case .status, .other:
break
case .captureStatus:
break
}
}
private func applyLaunchAtLoginSetting(_ shouldLaunch: Bool) {
let result = lifecycleService.applyLaunchAtLogin(shouldLaunch)
switch result {
case .success:
settings.setLaunchAtLoginStatus(message: "")
if settings.launchAtLogin != shouldLaunch {
settings.launchAtLogin = shouldLaunch
}
case .noChange:
settings.setLaunchAtLoginStatus(message: "")
case .failure(let message):
settings.setLaunchAtLoginStatus(message: "Launch-at-login failed: \(message)")
let actualState = lifecycleService.isEnabled()
if settings.launchAtLogin != actualState {
settings.launchAtLogin = actualState
}
}
}
private func refreshAccessibilityPermissionMessage() {
if AccessibilityPermissionService.isTrusted {
settings.setAccessibilityPermissionStatus(message: "")
} else {
settings.setAccessibilityPermissionStatus(message: "Accessibility permission not granted. Capture still works; paste falls back to copy.")
}
refreshStatusItem()
}
private func requestInitialAccessibilityPermissionIfNeeded() {
refreshAccessibilityPermissionMessage()
if AccessibilityPermissionService.isTrusted {
return
}
if !settings.accessibilityNoticeShown {
settings.markAccessibilityNoticeShown()
DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { [weak self] in
self?.showAccessibilityPermissionNoticeIfNeeded()
}
}
}
private func showAccessibilityPermissionNoticeIfNeeded() {
guard !AccessibilityPermissionService.isTrusted else {
refreshAccessibilityPermissionMessage()
return
}
let alert = NSAlert()
alert.messageText = "Allow automatic paste?"
alert.informativeText = "ClipBored can capture clipboard history without extra permission. Grant Accessibility only if you want selected clips to paste directly into the previous app; otherwise paste actions will copy the clip for you."
alert.addButton(withTitle: "Open Accessibility Settings")
alert.addButton(withTitle: "Later")
alert.alertStyle = .warning
if alert.runModal() == .alertFirstButtonReturn {
_ = AccessibilityPermissionService.requestPromptIfNeeded()
if !AccessibilityPermissionService.isTrusted {
AccessibilityPermissionService.openSystemSettings()
}
}
refreshAccessibilityPermissionMessage()
}
private func menuModifierFlags(_ binding: ShortcutBinding) -> NSEvent.ModifierFlags {
NSEvent.ModifierFlags(rawValue: binding.modifierFlags)
}
private func appIconImage() -> NSImage? {
guard let url = Bundle.main.url(forResource: "AppIcon", withExtension: "icns"),
let icon = NSImage(contentsOf: url)
else {
return nil
}
icon.size = NSSize(width: 18, height: 18)
icon.isTemplate = false
return icon
}
}

View File

@@ -0,0 +1,14 @@
import AppKit
@main
struct ClipBoredApp {
private static let appDelegate = AppDelegate()
static func main() {
let application = NSApplication.shared
application.setActivationPolicy(.accessory)
application.delegate = appDelegate
application.activate(ignoringOtherApps: true)
application.run()
}
}

View File

@@ -0,0 +1,117 @@
import Foundation
import AppKit
enum AppConfiguration {
static let appName = "ClipBored"
static let storageDirectoryOverrideEnvironmentKey = "CLIPBORED_STORAGE_DIR"
static let defaultHistoryLength = 300
static let minHistoryLength = 50
static let maxHistoryLength = 2000
static let defaultCacheMaxBytes: Int64 = 120 * 1024 * 1024
static let maxPinnedItems = 250
static let maxFullImagePixelSize: CGFloat = 1600
static let maxRecognizedImageTextLength = 4096
static let maxImageCacheFiles = 1000
static let defaultOpenShortcut = ShortcutBinding(key: "v", modifierFlags: NSEvent.ModifierFlags.command.rawValue | NSEvent.ModifierFlags.option.rawValue)
static let defaultSettingsShortcut = ShortcutBinding(key: ",", modifierFlags: NSEvent.ModifierFlags.command.rawValue)
static let defaultIgnoredApps: [String] = [
"1password",
"bitwarden",
"lastpass",
"dashlane",
"keeper",
"keepass",
"authy"
]
static let defaultPollProfile: PollProfile = .balanced
static let minResponsiveActiveInterval: TimeInterval = 0.075
enum PollProfile: Int {
case battery = 0
case balanced = 1
case responsive = 2
static let allCases: [PollProfile] = [.battery, .balanced, .responsive]
var title: String {
switch self {
case .battery: return "Battery Saver"
case .balanced: return "Balanced"
case .responsive: return "Responsive"
}
}
var idleInterval: TimeInterval {
switch self {
case .battery: return 0.50
case .balanced: return 0.25
case .responsive: return 0.12
}
}
var activeInterval: TimeInterval {
switch self {
case .battery: return 0.12
case .balanced: return 0.075
case .responsive: return 0.075
}
}
var idleRecoveryWindow: TimeInterval {
switch self {
case .battery: return 3.5
case .balanced: return 2.0
case .responsive: return 1.2
}
}
}
}
struct ShortcutBinding: Equatable {
let key: String
let modifierFlags: UInt
init(key: String, modifierFlags: UInt) {
self.key = key.lowercased()
self.modifierFlags = modifierFlags
}
var displayText: String {
var text = ""
if modifierFlags & NSEvent.ModifierFlags.command.rawValue != 0 { text += "" }
if modifierFlags & NSEvent.ModifierFlags.option.rawValue != 0 { text += "" }
if modifierFlags & NSEvent.ModifierFlags.control.rawValue != 0 { text += "" }
if modifierFlags & NSEvent.ModifierFlags.shift.rawValue != 0 { text += "" }
return text + key.uppercased()
}
func matches(_ event: NSEvent) -> Bool {
guard let eventChar = event.charactersIgnoringModifiers?.lowercased(), eventChar.count == 1 else {
return false
}
let mask = event.modifierFlags.rawValue & (
NSEvent.ModifierFlags.command.rawValue |
NSEvent.ModifierFlags.option.rawValue |
NSEvent.ModifierFlags.control.rawValue |
NSEvent.ModifierFlags.shift.rawValue
)
return eventChar == key && mask == modifierFlags
}
func encoded() -> String {
"\(modifierFlags)|\(key)"
}
func has(_ flag: NSEvent.ModifierFlags) -> Bool {
modifierFlags & flag.rawValue != 0
}
init?(encoded value: String) {
let parts = value.split(separator: "|")
guard parts.count == 2, let flags = UInt(parts[0]), let key = parts.last else {
return nil
}
self.key = String(key).lowercased()
self.modifierFlags = flags
}
}

View File

@@ -0,0 +1,34 @@
import AppKit
extension NSImage {
func resized(to fitSize: CGSize) -> NSImage {
let target = NSSize(width: fitSize.width, height: fitSize.height)
let currentSize = size
let ratio = min(target.width / currentSize.width, target.height / currentSize.height, 1.0)
let newSize = NSSize(width: currentSize.width * ratio, height: currentSize.height * ratio)
let newImage = NSImage(size: newSize)
newImage.lockFocus()
draw(
in: NSRect(origin: .zero, size: newSize),
from: NSRect(origin: .zero, size: currentSize),
operation: .sourceOver,
fraction: 1.0
)
newImage.unlockFocus()
newImage.size = newSize
return newImage
}
func pngData() -> Data? {
guard let cgImage = cgImage(forProposedRect: nil, context: nil, hints: nil) else { return nil }
let rep = NSBitmapImageRep(cgImage: cgImage)
return rep.representation(using: .png, properties: [:])
}
}
extension NSView {
var isInAnyViewHierarchy: Bool {
return window != nil
}
}

View File

@@ -0,0 +1,19 @@
extension StringProtocol {
var clipboardTrimmed: String {
var start = startIndex
var end = endIndex
while start < end, self[start].isWhitespace {
formIndex(after: &start)
}
while end > start {
let previous = index(before: end)
if !self[previous].isWhitespace {
break
}
end = previous
}
return String(self[start..<end])
}
}

View File

@@ -0,0 +1,181 @@
import Foundation
enum ClipboardItemKind: Int {
case text = 0
case url
case image
case richText
case file
case unknown
case pdf
case audio
var displayName: String {
switch self {
case .text: return "text"
case .url: return "link"
case .image: return "image"
case .richText: return "rich text"
case .file: return "file"
case .unknown: return "item"
case .pdf: return "PDF"
case .audio: return "audio"
}
}
}
extension ClipboardItemKind {
var canOpen: Bool {
switch self {
case .url, .file, .image, .pdf, .audio:
return true
case .text, .richText, .unknown:
return false
}
}
var canReveal: Bool {
switch self {
case .file, .image, .pdf, .audio:
return true
case .text, .richText, .unknown, .url:
return false
}
}
var hasManagedCacheReference: Bool {
switch self {
case .url, .image, .pdf, .audio, .richText:
return true
case .text, .file, .unknown:
return false
}
}
}
enum ClipboardSortMode: Int {
case mostRecent = 0
case mostUsed
case images
case links
case text
case pinned
case files
case audio
static let allCases: [ClipboardSortMode] = [.mostRecent, .mostUsed, .text, .links, .images, .audio, .files, .pinned]
var title: String {
switch self {
case .mostRecent: return "Most Recent"
case .mostUsed: return "Most Used"
case .images: return "Images"
case .links: return "Links"
case .text: return "Text"
case .pinned: return "Pinned"
case .files: return "Files"
case .audio: return "Audio"
}
}
}
enum ClipboardCollectionDefaults {
static let names = [
"Useful Links",
"Important Notes",
"Code Snippets",
"Read Later"
]
static func normalizedName(_ value: String?) -> String? {
guard let value else { return nil }
let name = value
.split { $0.isWhitespace }
.joined(separator: " ")
.clipboardTrimmed
guard !name.isEmpty else { return nil }
return String(name.prefix(40))
}
}
struct ClipboardItem {
var id: UUID
var kind: ClipboardItemKind
var displayText: String
var payload: String
var payloadHash: String
var createdAt: Date
var lastUsedAt: Date
var useCount: Int
var sourceApp: String?
var imagePath: String?
var thumbnailPath: String?
var isPinned: Bool
var sourceAppBundleId: String?
var ocrText: String?
var collectionName: String?
var searchableText: String {
var text = kindLabel + " " + displayText.lowercased() + " " + payload.lowercased()
if let sourceApp {
text += " " + sourceApp.lowercased()
}
if let ocrText {
text += " " + ocrText.lowercased()
}
if let sourceAppBundleId {
text += " " + sourceAppBundleId.lowercased()
}
if let collectionName {
text += " " + collectionName.lowercased()
}
return text
}
private var kindLabel: String {
switch kind {
case .text: return "text"
case .url: return "link url"
case .image: return "image"
case .richText: return "richtext rtf"
case .file: return "file"
case .unknown: return "unknown"
case .pdf: return "pdf document"
case .audio: return "audio sound"
}
}
init(
id: UUID,
kind: ClipboardItemKind,
displayText: String,
payload: String,
payloadHash: String,
createdAt: Date,
lastUsedAt: Date,
useCount: Int,
sourceApp: String?,
imagePath: String?,
thumbnailPath: String?,
isPinned: Bool = false,
sourceAppBundleId: String? = nil,
ocrText: String? = nil,
collectionName: String? = nil
) {
self.id = id
self.kind = kind
self.displayText = displayText
self.payload = payload
self.payloadHash = payloadHash
self.createdAt = createdAt
self.lastUsedAt = lastUsedAt
self.useCount = useCount
self.sourceApp = sourceApp
self.imagePath = imagePath
self.thumbnailPath = thumbnailPath
self.isPinned = isPinned
self.sourceAppBundleId = sourceAppBundleId
self.ocrText = ocrText
self.collectionName = collectionName
}
}

View File

@@ -0,0 +1,26 @@
import Foundation
enum FilePayload {
static func paths(from payload: String) -> [String] {
payload
.split(separator: "\n", omittingEmptySubsequences: true)
.map { String($0).clipboardTrimmed }
.filter { !$0.isEmpty }
}
static func urls(from payload: String) -> [URL] {
paths(from: payload).map(fileURL(from:))
}
static func payload(from urls: [URL]) -> String {
urls.map(\.path).joined(separator: "\n")
}
static func fileURL(from value: String) -> URL {
let trimmed = value.clipboardTrimmed
if trimmed.lowercased().hasPrefix("file://"), let url = URL(string: trimmed) {
return url
}
return URL(fileURLWithPath: trimmed)
}
}

View File

@@ -0,0 +1,214 @@
import Foundation
final class SettingsModel {
enum Change {
case maxHistoryItems
case imageCacheMaxBytes
case openShortcut
case settingsShortcut
case launchAtLogin
case showMenuBarIcon
case pauseCapture
case pollProfile
case captureStatus
case status
case other
}
enum Keys {
static let maxHistoryItems = "maxHistoryItems"
static let defaultSortMode = "defaultSortMode"
static let imageCacheMaxBytes = "imageCacheMaxBytes"
static let includeImageTextInSearch = "includeImageTextInSearch"
static let pruneDuplicates = "pruneDuplicates"
static let launchAtLogin = "launchAtLogin"
static let showMenuBarIcon = "showMenuBarIcon"
static let openShortcut = "openShortcut"
static let settingsShortcut = "settingsShortcut"
static let ignoredApps = "ignoredApps"
static let ignoredItemKinds = "ignoredItemKinds"
static let pollProfile = "pollProfile"
static let keepFirstImage = "keepFirstImage"
static let excludeSensitive = "excludeSensitive"
static let pauseCapture = "pauseCapture"
static let clearHistoryOnQuit = "clearHistoryOnQuit"
static let accessibilityNoticeShown = "accessibilityNoticeShown"
}
var maxHistoryItems: Int {
didSet { if oldValue != maxHistoryItems { storeAndNotify(.maxHistoryItems) } }
}
var defaultSortMode: ClipboardSortMode {
didSet { if oldValue != defaultSortMode { storeAndNotify(.other) } }
}
var imageCacheMaxBytes: Int64 {
didSet { if oldValue != imageCacheMaxBytes { storeAndNotify(.imageCacheMaxBytes) } }
}
var includeImageTextInSearch: Bool {
didSet { if oldValue != includeImageTextInSearch { storeAndNotify(.other) } }
}
var pruneDuplicates: Bool {
didSet { if oldValue != pruneDuplicates { storeAndNotify(.other) } }
}
var launchAtLogin: Bool {
didSet { if oldValue != launchAtLogin { storeAndNotify(.launchAtLogin) } }
}
var showMenuBarIcon: Bool {
didSet { if oldValue != showMenuBarIcon { storeAndNotify(.showMenuBarIcon) } }
}
var openShortcut: ShortcutBinding {
didSet { if oldValue != openShortcut { storeAndNotify(.openShortcut) } }
}
var settingsShortcut: ShortcutBinding {
didSet { if oldValue != settingsShortcut { storeAndNotify(.settingsShortcut) } }
}
var ignoredApps: [String] {
didSet { if oldValue != ignoredApps { storeAndNotify(.other) } }
}
var ignoredItemKindsRaw: [Int] {
didSet { if oldValue != ignoredItemKindsRaw { storeAndNotify(.other) } }
}
var pollProfileRaw: AppConfiguration.PollProfile {
didSet { if oldValue != pollProfileRaw { storeAndNotify(.pollProfile) } }
}
var keepFirstImage: Bool {
didSet { if oldValue != keepFirstImage { storeAndNotify(.other) } }
}
var excludeSensitive: Bool {
didSet { if oldValue != excludeSensitive { storeAndNotify(.other) } }
}
var pauseCapture: Bool {
didSet { if oldValue != pauseCapture { storeAndNotify(.pauseCapture) } }
}
var clearHistoryOnQuit: Bool {
didSet { if oldValue != clearHistoryOnQuit { storeAndNotify(.other) } }
}
private(set) var launchAtLoginErrorMessage: String = ""
private(set) var accessibilityPermissionStatusMessage: String = ""
private(set) var captureStatusMessage: String = ""
private(set) var shortcutStatusMessage: String = ""
private(set) var pasteStatusMessage: String = ""
private(set) var accessibilityNoticeShown: Bool
private let defaults: UserDefaults
private var observers: [(Change) -> Void] = []
init(defaults: UserDefaults = .standard) {
self.defaults = defaults
let savedHistory = defaults.integer(forKey: Keys.maxHistoryItems)
let savedSort = defaults.integer(forKey: Keys.defaultSortMode)
let savedCache = defaults.integer(forKey: Keys.imageCacheMaxBytes)
maxHistoryItems = savedHistory > 0 ? savedHistory : AppConfiguration.defaultHistoryLength
defaultSortMode = ClipboardSortMode(rawValue: savedSort) ?? .mostRecent
imageCacheMaxBytes = savedCache > 0 ? Int64(savedCache) : AppConfiguration.defaultCacheMaxBytes
includeImageTextInSearch = defaults.object(forKey: Keys.includeImageTextInSearch) as? Bool ?? false
pruneDuplicates = defaults.object(forKey: Keys.pruneDuplicates) as? Bool ?? true
launchAtLogin = defaults.object(forKey: Keys.launchAtLogin) as? Bool ?? false
showMenuBarIcon = defaults.object(forKey: Keys.showMenuBarIcon) as? Bool ?? true
openShortcut = Self.readShortcut(from: defaults.string(forKey: Keys.openShortcut)) ?? AppConfiguration.defaultOpenShortcut
settingsShortcut = Self.readShortcut(from: defaults.string(forKey: Keys.settingsShortcut)) ?? AppConfiguration.defaultSettingsShortcut
ignoredApps = defaults.stringArray(forKey: Keys.ignoredApps) ?? AppConfiguration.defaultIgnoredApps
ignoredItemKindsRaw = defaults.object(forKey: Keys.ignoredItemKinds) as? [Int] ?? []
let profileValue = defaults.integer(forKey: Keys.pollProfile)
pollProfileRaw = AppConfiguration.PollProfile(rawValue: profileValue) ?? AppConfiguration.defaultPollProfile
keepFirstImage = defaults.object(forKey: Keys.keepFirstImage) as? Bool ?? true
excludeSensitive = defaults.object(forKey: Keys.excludeSensitive) as? Bool ?? false
pauseCapture = defaults.object(forKey: Keys.pauseCapture) as? Bool ?? false
clearHistoryOnQuit = defaults.object(forKey: Keys.clearHistoryOnQuit) as? Bool ?? false
accessibilityNoticeShown = defaults.object(forKey: Keys.accessibilityNoticeShown) as? Bool ?? false
maxHistoryItems = max(AppConfiguration.minHistoryLength, min(AppConfiguration.maxHistoryLength, maxHistoryItems))
imageCacheMaxBytes = max(4 * 1024 * 1024, imageCacheMaxBytes)
if defaults.object(forKey: Keys.maxHistoryItems) == nil {
store()
}
}
private func store() {
defaults.set(maxHistoryItems, forKey: Keys.maxHistoryItems)
defaults.set(defaultSortMode.rawValue, forKey: Keys.defaultSortMode)
defaults.set(imageCacheMaxBytes, forKey: Keys.imageCacheMaxBytes)
defaults.set(includeImageTextInSearch, forKey: Keys.includeImageTextInSearch)
defaults.set(pruneDuplicates, forKey: Keys.pruneDuplicates)
defaults.set(launchAtLogin, forKey: Keys.launchAtLogin)
defaults.set(showMenuBarIcon, forKey: Keys.showMenuBarIcon)
defaults.set(openShortcut.encoded(), forKey: Keys.openShortcut)
defaults.set(settingsShortcut.encoded(), forKey: Keys.settingsShortcut)
defaults.set(ignoredApps, forKey: Keys.ignoredApps)
defaults.set(ignoredItemKindsRaw, forKey: Keys.ignoredItemKinds)
defaults.set(pollProfileRaw.rawValue, forKey: Keys.pollProfile)
defaults.set(keepFirstImage, forKey: Keys.keepFirstImage)
defaults.set(excludeSensitive, forKey: Keys.excludeSensitive)
defaults.set(pauseCapture, forKey: Keys.pauseCapture)
defaults.set(clearHistoryOnQuit, forKey: Keys.clearHistoryOnQuit)
}
func observe(_ observer: @escaping (Change) -> Void) {
observers.append(observer)
}
private func storeAndNotify(_ change: Change) {
store()
notify(change)
}
private func notify(_ change: Change) {
for observer in observers {
observer(change)
}
}
func setLaunchAtLoginStatus(message: String) {
guard launchAtLoginErrorMessage != message else { return }
launchAtLoginErrorMessage = message
notify(.status)
}
func setAccessibilityPermissionStatus(message: String) {
guard accessibilityPermissionStatusMessage != message else { return }
accessibilityPermissionStatusMessage = message
notify(.status)
}
func setCaptureStatus(message: String) {
guard captureStatusMessage != message else { return }
captureStatusMessage = message
notify(.captureStatus)
}
func markAccessibilityNoticeShown() {
guard !accessibilityNoticeShown else { return }
accessibilityNoticeShown = true
defaults.set(true, forKey: Keys.accessibilityNoticeShown)
}
func setShortcutStatus(message: String) {
guard shortcutStatusMessage != message else { return }
shortcutStatusMessage = message
notify(.status)
}
func setPasteStatus(message: String) {
guard pasteStatusMessage != message else { return }
pasteStatusMessage = message
notify(.status)
}
private static func readShortcut(from value: String?) -> ShortcutBinding? {
guard let value else { return nil }
return ShortcutBinding(encoded: value)
}
var pollProfile: AppConfiguration.PollProfile {
get { pollProfileRaw }
set { pollProfileRaw = newValue }
}
func sanitizeLimits() {
maxHistoryItems = max(AppConfiguration.minHistoryLength, min(AppConfiguration.maxHistoryLength, maxHistoryItems))
imageCacheMaxBytes = max(4 * 1024 * 1024, imageCacheMaxBytes)
}
}

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

View File

@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDisplayName</key>
<string>ClipBored</string>
<key>CFBundleName</key>
<string>ClipBored</string>
<key>CFBundleIdentifier</key>
<string>com.local.clipbored</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>CFBundleShortVersionString</key>
<string>0.1.0</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleExecutable</key>
<string>ClipBored</string>
<key>CFBundleIconFile</key>
<string>AppIcon.icns</string>
<key>LSUIElement</key>
<true/>
<key>LSMinimumSystemVersion</key>
<string>13.0</string>
<key>NSHumanReadableCopyright</key>
<string>ClipBored</string>
</dict>
</plist>

View File

@@ -0,0 +1,32 @@
import ApplicationServices
import Foundation
import AppKit
enum AccessibilityPermissionService {
static var isTrusted: Bool {
AXIsProcessTrusted()
}
@discardableResult
static func requestPromptIfNeeded() -> Bool {
if isTrusted {
return true
}
guard let optionPrompt = kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String? else {
return false
}
let options: CFDictionary = [optionPrompt: true] as CFDictionary
return AXIsProcessTrustedWithOptions(options)
}
static func openSystemSettings() {
if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility"),
NSWorkspace.shared.open(url) {
return
}
let fallback = URL(fileURLWithPath: "/System/Applications/System Settings.app")
_ = NSWorkspace.shared.open(fallback)
}
}

View File

@@ -0,0 +1,40 @@
import Foundation
import ServiceManagement
final class AppLifecycleService {
enum LaunchAtLoginResult: Equatable {
case success
case noChange
case failure(String)
}
private let service = SMAppService.mainApp
func applyLaunchAtLogin(_ enabled: Bool) -> LaunchAtLoginResult {
do {
if enabled {
switch service.status {
case .notRegistered:
try service.register()
return .success
case .enabled:
return .noChange
default:
try service.register()
return .success
}
} else if service.status == .enabled {
try service.unregister()
return .success
} else {
return .noChange
}
} catch {
return .failure(error.localizedDescription)
}
}
func isEnabled() -> Bool {
service.status == .enabled
}
}

View File

@@ -0,0 +1,342 @@
import AppKit
import Foundation
final class ClipboardCacheService {
private let thumbnailCache = NSCache<NSString, NSImage>()
private let fileManager = FileManager.default
private let queue = DispatchQueue(label: "clipboard.cache.service", qos: .utility)
private let imageDirectory: URL
private let attachmentDirectory: URL
private let temporaryPreviewDirectory: URL
private let encryptionService: ClipboardEncryptionService
init(baseURL: URL? = nil, encryptionService: ClipboardEncryptionService = ClipboardEncryptionService()) {
let base = baseURL ?? ClipboardStore.storageDirectory()
imageDirectory = base.appendingPathComponent("images", isDirectory: true)
attachmentDirectory = base.appendingPathComponent("attachments", isDirectory: true)
temporaryPreviewDirectory = FileManager.default.temporaryDirectory
.appendingPathComponent(AppConfiguration.appName, isDirectory: true)
.appendingPathComponent("Previews", isDirectory: true)
self.encryptionService = encryptionService
thumbnailCache.countLimit = 128
try? fileManager.createDirectory(at: imageDirectory, withIntermediateDirectories: true)
try? fileManager.createDirectory(at: attachmentDirectory, withIntermediateDirectories: true)
hardenDirectory(imageDirectory)
hardenDirectory(attachmentDirectory)
clearTemporaryPreviews()
}
func cacheImage(_ image: NSImage, id: UUID) -> (full: String, thumb: String)? {
let fullURL = imageDirectory.appendingPathComponent("\(id.uuidString).png")
let thumbURL = imageDirectory.appendingPathComponent("thumb-\(id.uuidString).png")
let boundedFullImage = image.resized(to: .init(width: AppConfiguration.maxFullImagePixelSize, height: AppConfiguration.maxFullImagePixelSize))
guard let fullData = boundedFullImage.pngData() else { return nil }
let thumbnail = image.resized(to: .init(width: 320, height: 320))
guard let thumbData = thumbnail.pngData() else { return nil }
do {
try encrypted(fullData).write(to: fullURL, options: .atomic)
try encrypted(thumbData).write(to: thumbURL, options: .atomic)
hardenFile(fullURL)
hardenFile(thumbURL)
thumbnailCache.setObject(thumbImage(thumbData) ?? image, forKey: thumbURL.path as NSString)
return (full: fullURL.path, thumb: thumbURL.path)
} catch {
return nil
}
}
func cachePDF(_ data: Data, id: UUID) -> String? {
cacheAttachment(data, id: id, fileExtension: "pdf")
}
func cacheAudio(_ data: Data, id: UUID) -> String? {
cacheAttachment(data, id: id, fileExtension: "sound")
}
func cacheRichText(_ data: Data, id: UUID) -> String? {
cacheAttachment(data, id: id, fileExtension: "rtf")
}
private func cacheAttachment(_ data: Data, id: UUID, fileExtension: String) -> String? {
let url = attachmentDirectory.appendingPathComponent("\(id.uuidString).\(fileExtension)")
do {
try encrypted(data).write(to: url, options: .atomic)
hardenFile(url)
return url.path
} catch {
return nil
}
}
func image(for path: String) -> NSImage? {
let key = NSString(string: path)
if let cached = thumbnailCache.object(forKey: key) {
return cached
}
guard let data = data(for: path), let image = NSImage(data: data) else { return nil }
thumbnailCache.setObject(image, forKey: key)
return image
}
func previewThumbnail(for item: ClipboardItem) -> NSImage? {
switch item.kind {
case .url, .image:
guard let path = item.thumbnailPath else { return nil }
return image(for: path)
case .pdf:
let key = NSString(string: "pdf-preview:\(item.id.uuidString):\(item.payload)")
if let cached = thumbnailCache.object(forKey: key) {
return cached
}
if let data = data(for: item.payload),
let image = NSImage(data: data),
hasDrawableSize(image) {
let thumbnail = image.resized(to: CGSize(width: 260, height: 132))
thumbnailCache.setObject(thumbnail, forKey: key)
return thumbnail
}
return filePreviewThumbnail(for: item.payload)
case .file:
return filePreviewThumbnail(for: item.payload)
case .text, .unknown, .audio, .richText:
return nil
}
}
func data(for path: String) -> Data? {
let url = URL(fileURLWithPath: path)
guard let stored = try? Data(contentsOf: url) else {
return nil
}
if ClipboardEncryptionService.isProtected(stored) {
return encryptionService.unprotectData(stored)
}
if isManagedSidecar(path: path), encryptionService.isAvailable {
try? encrypted(stored).write(to: url, options: .atomic)
hardenFile(url)
}
return stored
}
private func filePreviewThumbnail(for path: String) -> NSImage? {
guard let url = fileURL(from: path), fileManager.fileExists(atPath: url.path) else {
return nil
}
let key = NSString(string: "file-preview:\(url.standardizedFileURL.path)")
if let cached = thumbnailCache.object(forKey: key) {
return cached
}
let image: NSImage
if let decoded = NSImage(contentsOf: url), decoded.isValid, hasDrawableSize(decoded) {
image = decoded.resized(to: CGSize(width: 260, height: 132))
} else {
let icon = NSWorkspace.shared.icon(forFile: url.path)
icon.size = NSSize(width: 96, height: 96)
image = icon
}
thumbnailCache.setObject(image, forKey: key)
return image
}
private func hasDrawableSize(_ image: NSImage) -> Bool {
image.size.width > 0 && image.size.height > 0
}
private func fileURL(from path: String) -> URL? {
let trimmed = path.clipboardTrimmed
guard !trimmed.isEmpty else { return nil }
if trimmed.lowercased().hasPrefix("file://"), let url = URL(string: trimmed) {
return url
}
return URL(fileURLWithPath: trimmed)
}
func temporaryReadableURL(for item: ClipboardItem) -> URL? {
switch item.kind {
case .image:
guard let path = item.imagePath, let data = data(for: path) else { return nil }
return writeTemporaryCopy(data: data, id: item.id, fileExtension: "png")
case .pdf:
guard let data = data(for: item.payload) else { return nil }
return writeTemporaryCopy(data: data, id: item.id, fileExtension: "pdf")
case .audio:
guard let data = data(for: item.payload) else { return nil }
return writeTemporaryCopy(data: data, id: item.id, fileExtension: "sound")
case .richText:
guard let data = data(for: item.payload) else { return nil }
return writeTemporaryCopy(data: data, id: item.id, fileExtension: "rtf")
default:
return nil
}
}
func encryptCachedReferencesIfNeeded(for items: [ClipboardItem]) {
queue.async { [weak self] in
guard let self else { return }
for item in items {
if let imagePath = item.imagePath {
_ = self.data(for: imagePath)
}
if let thumbnailPath = item.thumbnailPath {
_ = self.data(for: thumbnailPath)
}
if (item.kind == .pdf || item.kind == .audio || item.kind == .richText), self.isManagedAttachment(path: item.payload) {
_ = self.data(for: item.payload)
}
}
}
}
func removeCachedReferences(_ item: ClipboardItem) {
queue.async { [weak self] in
guard let self else { return }
if let path = item.imagePath {
try? self.fileManager.removeItem(atPath: path)
self.thumbnailCache.removeObject(forKey: NSString(string: path))
}
if let path = item.thumbnailPath {
try? self.fileManager.removeItem(atPath: path)
self.thumbnailCache.removeObject(forKey: NSString(string: path))
}
if (item.kind == .pdf || item.kind == .audio || item.kind == .richText), self.isManagedAttachment(path: item.payload) {
try? self.fileManager.removeItem(atPath: item.payload)
}
}
}
func purgeIfNeeded(maxBytes: Int64) {
queue.async {
DiagnosticsService.shared.incrementCachePurge()
let urls = (try? self.fileManager.contentsOfDirectory(at: self.imageDirectory, includingPropertiesForKeys: nil, options: [])) ?? []
var items: [(url: URL, size: Int64, date: Date)] = []
var totalSize: Int64 = 0
for url in urls {
guard
let attrs = try? self.fileManager.attributesOfItem(atPath: url.path),
let size = attrs[.size] as? NSNumber,
let mod = attrs[.modificationDate] as? Date
else { continue }
let bytes = Int64(size.int64Value)
totalSize += bytes
items.append((url, bytes, mod))
}
if totalSize <= maxBytes && items.count <= AppConfiguration.maxImageCacheFiles {
return
}
let ordered = items.sorted { $0.date < $1.date }
var remaining = totalSize
var pointer = 0
while (remaining > maxBytes || ordered.count - pointer > AppConfiguration.maxImageCacheFiles) && pointer < ordered.count {
let candidate = ordered[pointer]
try? self.fileManager.removeItem(at: candidate.url)
self.thumbnailCache.removeObject(forKey: NSString(string: candidate.url.path))
remaining -= candidate.size
pointer += 1
}
}
}
func clearCache() {
queue.async { [weak self] in
guard let self else { return }
thumbnailCache.removeAllObjects()
let contents = (try? self.fileManager.contentsOfDirectory(at: self.imageDirectory, includingPropertiesForKeys: nil, options: [])) ?? []
for url in contents {
try? self.fileManager.removeItem(at: url)
}
self.removeTemporaryPreviewFiles()
}
}
func clearTemporaryPreviews(wait: Bool = false) {
let work: () -> Void = { [weak self] in
guard let self else { return }
self.removeTemporaryPreviewFiles()
}
if wait {
queue.sync(execute: work)
} else {
queue.async(execute: work)
}
}
func flushForTesting() {
queue.sync {}
}
private func thumbImage(_ data: Data) -> NSImage? {
NSImage(data: data)
}
private func encrypted(_ data: Data) -> Data {
encryptionService.protectData(data)
}
private func hardenDirectory(_ url: URL) {
try? fileManager.setAttributes([.posixPermissions: 0o700], ofItemAtPath: url.path)
}
private func hardenFile(_ url: URL) {
try? fileManager.setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path)
}
private func isManagedAttachment(path: String) -> Bool {
URL(fileURLWithPath: path).deletingLastPathComponent().standardizedFileURL == attachmentDirectory.standardizedFileURL
}
private func isManagedSidecar(path: String) -> Bool {
let directory = URL(fileURLWithPath: path).deletingLastPathComponent().standardizedFileURL
return directory == imageDirectory.standardizedFileURL || directory == attachmentDirectory.standardizedFileURL
}
private func writeTemporaryCopy(data: Data, id: UUID, fileExtension: String) -> URL? {
let url = temporaryPreviewDirectory.appendingPathComponent("\(id.uuidString)-\(UUID().uuidString).\(fileExtension)")
do {
try fileManager.createDirectory(at: temporaryPreviewDirectory, withIntermediateDirectories: true)
try fileManager.setAttributes([.posixPermissions: 0o700], ofItemAtPath: temporaryPreviewDirectory.path)
try data.write(to: url, options: .atomic)
hardenFile(url)
return url
} catch {
return nil
}
}
private func removeTemporaryPreviewFiles() {
guard fileManager.fileExists(atPath: temporaryPreviewDirectory.path) else {
return
}
let contents = (try? fileManager.contentsOfDirectory(
at: temporaryPreviewDirectory,
includingPropertiesForKeys: nil,
options: []
)) ?? []
for url in contents {
try? fileManager.removeItem(at: url)
}
if ((try? fileManager.contentsOfDirectory(at: temporaryPreviewDirectory, includingPropertiesForKeys: nil)) ?? []).isEmpty {
try? fileManager.removeItem(at: temporaryPreviewDirectory)
}
}
}

View File

@@ -0,0 +1,284 @@
import CryptoKit
import Foundation
import LocalAuthentication
import Security
// Swift marks SecAccessCreate deprecated, but macOS still needs kSecAttrAccess
// here to avoid legacy keychain authorization UI during generic-password creation.
@_silgen_name("SecAccessCreate")
private func clipBoredSecAccessCreate(
_ descriptor: CFString,
_ trustedList: CFArray?,
_ accessRef: UnsafeMutablePointer<SecAccess?>
) -> OSStatus
final class ClipboardEncryptionService {
static let marker = "clipbored:v1:"
private static let markerData = Data(marker.utf8)
private let keyProvider: () -> SymmetricKey?
private let resetProvider: () -> Void
init() {
keyProvider = { ClipboardEncryptionKeychain.shared.symmetricKey() }
resetProvider = { ClipboardEncryptionKeychain.shared.resetStoredKey() }
}
init(keyProvider: @escaping () -> SymmetricKey?, resetProvider: @escaping () -> Void = {}) {
self.keyProvider = keyProvider
self.resetProvider = resetProvider
}
var isAvailable: Bool {
keyProvider() != nil
}
func protect(_ value: String?) -> String? {
guard let value else { return nil }
guard let key = keyProvider() else {
return value
}
guard let sealed = try? AES.GCM.seal(Data(value.utf8), using: key),
let combined = sealed.combined
else {
return value
}
return Self.marker + combined.base64EncodedString()
}
func unprotect(_ value: String?) -> String? {
guard let value else { return nil }
guard Self.isProtected(value) else { return value }
let encoded = String(value.dropFirst(Self.marker.count))
guard let data = Data(base64Encoded: encoded),
let sealed = try? AES.GCM.SealedBox(combined: data)
else {
return value
}
guard let key = keyProvider(),
let decrypted = try? AES.GCM.open(sealed, using: key)
else {
return nil
}
return String(data: decrypted, encoding: .utf8)
}
func protectData(_ data: Data) -> Data {
guard let key = keyProvider() else {
return data
}
guard let sealed = try? AES.GCM.seal(data, using: key),
let combined = sealed.combined
else {
return data
}
var output = Self.markerData
output.append(combined)
return output
}
func unprotectData(_ data: Data) -> Data? {
guard Self.isProtected(data) else {
return data
}
let encrypted = data.dropFirst(Self.markerData.count)
guard let sealed = try? AES.GCM.SealedBox(combined: encrypted),
let key = keyProvider(),
let decrypted = try? AES.GCM.open(sealed, using: key)
else {
return nil
}
return decrypted
}
static func isProtected(_ value: String) -> Bool {
value.hasPrefix(marker)
}
static func isProtected(_ data: Data) -> Bool {
data.starts(with: markerData)
}
func resetStoredKey() {
resetProvider()
}
}
private enum ClipboardEncryptionKeychain {
static let shared = KeychainBackedKeyProvider()
}
private final class KeychainBackedKeyProvider {
private let queue = DispatchQueue(label: "clipboard.encryption-keychain")
private let keychainTimeout: TimeInterval = 0.35
private var cachedKey: SymmetricKey?
func symmetricKey() -> SymmetricKey? {
queue.sync {
if let cachedKey {
return cachedKey
}
if let fallback = readFallbackKeyData() {
let key = SymmetricKey(data: fallback)
cachedKey = key
return key
}
if let existing = readKeyData() {
let key = SymmetricKey(data: existing)
cachedKey = key
return key
}
let generated = SymmetricKey(size: .bits256)
let data = generated.withUnsafeBytes { Data($0) }
if saveKeyData(data) {
cachedKey = generated
return generated
}
guard let fallback = loadOrCreateFallbackKeyData() else {
return nil
}
let fallbackKey = SymmetricKey(data: fallback)
cachedKey = fallbackKey
return fallbackKey
}
}
func resetStoredKey() {
queue.sync {
cachedKey = nil
_ = deleteKeyData()
deleteFallbackKeyData()
}
}
private func readKeyData() -> Data? {
runKeychainOperation {
var query = self.baseQuery()
query[kSecReturnData as String] = true
query[kSecMatchLimit as String] = kSecMatchLimitOne
query[kSecUseAuthenticationContext as String] = self.nonInteractiveContext()
var result: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess else {
return nil
}
return result as? Data
}
}
private func saveKeyData(_ data: Data) -> Bool {
runKeychainOperation {
var query = self.baseQuery()
query[kSecValueData as String] = data
query[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
query[kSecUseAuthenticationContext as String] = self.nonInteractiveContext()
if let access = self.keychainAccess() {
query[kSecAttrAccess as String] = access
}
let status = SecItemAdd(query as CFDictionary, nil)
if status == errSecSuccess {
return true
}
if status == errSecDuplicateItem {
return self.readKeyData() != nil
}
return false
} ?? false
}
private func runKeychainOperation<T>(_ operation: @escaping () -> T?) -> T? {
let lock = NSLock()
var result: T?
let semaphore = DispatchSemaphore(value: 0)
DispatchQueue.global(qos: .utility).async {
let value = operation()
lock.lock()
result = value
lock.unlock()
semaphore.signal()
}
guard semaphore.wait(timeout: .now() + keychainTimeout) == .success else {
return nil
}
lock.lock()
defer { lock.unlock() }
return result
}
private func baseQuery() -> [String: Any] {
[
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: "com.local.clipbored.encryption",
kSecAttrAccount as String: "history-v1"
]
}
private func nonInteractiveContext() -> LAContext {
let context = LAContext()
context.interactionNotAllowed = true
return context
}
private func keychainAccess() -> SecAccess? {
var access: SecAccess?
let status = clipBoredSecAccessCreate("ClipBored encryption key" as CFString, nil, &access)
guard status == errSecSuccess else {
return nil
}
return access
}
private func loadOrCreateFallbackKeyData() -> Data? {
if let existing = readFallbackKeyData() {
return existing
}
let generated = SymmetricKey(size: .bits256)
let data = generated.withUnsafeBytes { Data($0) }
guard saveFallbackKeyData(data) else {
return nil
}
return data
}
private func readFallbackKeyData() -> Data? {
let url = fallbackKeyURL()
guard let data = try? Data(contentsOf: url), data.count == 32 else {
return nil
}
return data
}
private func saveFallbackKeyData(_ data: Data) -> Bool {
let url = fallbackKeyURL()
let directory = url.deletingLastPathComponent()
do {
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: directory.path)
try data.write(to: url, options: [.atomic])
try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path)
return true
} catch {
return false
}
}
private func deleteKeyData() -> Bool {
runKeychainOperation {
let status = SecItemDelete(self.baseQuery() as CFDictionary)
return status == errSecSuccess || status == errSecItemNotFound
} ?? false
}
private func deleteFallbackKeyData() {
try? FileManager.default.removeItem(at: fallbackKeyURL())
}
private func fallbackKeyURL() -> URL {
let base = ClipboardStore.storageDirectory()
return base.appendingPathComponent("history-encryption.key")
}
}

View File

@@ -0,0 +1,761 @@
import AppKit
import Foundation
final class ClipboardMonitorService {
private let store: ClipboardStore
private let cacheService: ClipboardCacheService
private let settings: SettingsModel
private let imageTextExtractor: (NSImage) -> String?
private var timer: DispatchSourceTimer?
private let queue = DispatchQueue(label: "clipboard.monitor", qos: .utility)
private let queueKey = DispatchSpecificKey<Void>()
private var lastChangeCount: Int
private var lastActiveChange = Date.distantPast
private var scheduledInterval: TimeInterval = 0
private var didReportReadFailure = false
private(set) var isPaused = false
init(
store: ClipboardStore,
cacheService: ClipboardCacheService,
settings: SettingsModel,
imageTextExtractor: @escaping (NSImage) -> String? = ImageTextExtractor.recognizedText(in:)
) {
self.store = store
self.cacheService = cacheService
self.settings = settings
self.imageTextExtractor = imageTextExtractor
self.lastChangeCount = NSPasteboard.general.changeCount
queue.setSpecific(key: queueKey, value: ())
}
func start() {
if isPaused {
reportCaptureStatus("Capture is paused.")
scheduleTimer(interval: 1.0)
return
}
reportCaptureStatus("Capture is running. Waiting for clipboard changes.")
scheduleTimer(interval: effectiveProfile.idleInterval)
pollNow()
}
func pollNow() {
queue.async { [weak self] in
self?.pollPasteboard(rescheduleAfterCapture: false)
}
}
func pollNowAndWait() {
if DispatchQueue.getSpecific(key: queueKey) != nil {
pollPasteboard(rescheduleAfterCapture: false)
} else {
queue.sync {
pollPasteboard(rescheduleAfterCapture: false)
}
}
}
func setPaused(_ paused: Bool) {
isPaused = paused
if paused {
reportCaptureStatus("Capture is paused.")
scheduleTimer(interval: 1.0)
lastActiveChange = .distantPast
} else {
reportCaptureStatus("Capture resumed. Waiting for clipboard changes.")
scheduleTimer(interval: effectiveProfile.idleInterval)
lastActiveChange = .distantPast
}
}
func stop() {
timer?.cancel()
timer = nil
scheduledInterval = 0
}
#if DEBUG
var scheduledIntervalForTesting: TimeInterval {
scheduledInterval
}
#endif
private var effectiveProfile: AppConfiguration.PollProfile {
settings.pollProfile
}
private func scheduleTimer(interval: TimeInterval) {
let effective = clampedInterval(interval)
if timer != nil && scheduledInterval == effective {
return
}
timer?.cancel()
let newTimer = DispatchSource.makeTimerSource(queue: queue)
timer = nil
scheduledInterval = 0
newTimer.schedule(deadline: .now() + effective, repeating: effective, leeway: .milliseconds(12))
newTimer.setEventHandler { [weak self] in
self?.tick()
}
newTimer.resume()
timer = newTimer
scheduledInterval = effective
}
private func tick() {
DiagnosticsService.shared.incrementMonitorTick()
pollPasteboard(rescheduleAfterCapture: true)
}
private func pollPasteboard(rescheduleAfterCapture: Bool) {
if isPaused {
reportCaptureStatus("Capture is paused.")
return
}
let pasteboard = NSPasteboard.general
let changeCount = pasteboard.changeCount
if changeCount == lastChangeCount {
if Date().timeIntervalSince(lastActiveChange) > effectiveProfile.idleRecoveryWindow {
if rescheduleAfterCapture {
scheduleTimer(interval: effectiveProfile.idleInterval)
}
}
return
}
lastChangeCount = changeCount
lastActiveChange = Date()
if ClipboardSelfWriteTracker.consume(changeCount: changeCount) {
reportReadFailureStatus("Clipboard was updated by ClipBored; skipping capture.")
return
}
DiagnosticsService.shared.incrementPasteboardChange()
didReportReadFailure = false
if let item = readCurrentItem(from: pasteboard) {
reportCaptured(item)
DispatchQueue.main.async { [weak self] in
self?.store.upsert(item)
}
} else if !didReportReadFailure {
reportCaptureStatus("Clipboard changed, but ClipBored could not read a supported item.")
}
if rescheduleAfterCapture {
scheduleTimer(interval: effectiveProfile.activeInterval)
}
}
func clampedInterval(_ interval: TimeInterval) -> TimeInterval {
max(interval, AppConfiguration.minResponsiveActiveInterval)
}
private func readCurrentItem(from pasteboard: NSPasteboard) -> ClipboardItem? {
DiagnosticsService.shared.incrementExtractionAttempt()
let source = frontmostApp()
func isIgnored(_ kind: ClipboardItemKind) -> Bool {
return settings.ignoredItemKindsRaw.contains(kind.rawValue)
}
func ignoredKindMessage(_ kind: ClipboardItemKind) -> String {
return "\(displayNameForStatus(kind)) items are ignored in capture settings."
}
if isSourceIgnored(source) {
reportReadFailureStatus("Ignored clipboard change from \(sourceDescription(source)).")
return nil
}
if isIgnored(.file), hasFileItems(on: pasteboard) {
reportReadFailureStatus(ignoredKindMessage(.file))
return nil
}
if let filePayload = itemFromFiles(pasteboard, sourceApp: source.name, sourceBundleId: source.bundleId) {
return filePayload
}
let url = urlPayloadFromPasteboard(pasteboard)
if isIgnored(.url), url != nil {
reportReadFailureStatus(ignoredKindMessage(.url))
return nil
}
if let url, hasImage(on: pasteboard) {
return itemFromURL(url.url, title: url.title, sourceApp: source.name, sourceBundleId: source.bundleId, previewPasteboard: pasteboard)
}
if isIgnored(.image), hasImage(on: pasteboard) {
reportReadFailureStatus(ignoredKindMessage(.image))
return nil
}
if let imageItem = itemFromImage(pasteboard, sourceApp: source.name, sourceBundleId: source.bundleId) {
return imageItem
}
if isIgnored(.pdf), hasPDF(on: pasteboard) {
reportReadFailureStatus(ignoredKindMessage(.pdf))
return nil
}
if let pdfItem = itemFromPDF(pasteboard, sourceApp: source.name, sourceBundleId: source.bundleId) {
return pdfItem
}
if isIgnored(.audio), hasAudio(on: pasteboard) {
reportReadFailureStatus(ignoredKindMessage(.audio))
return nil
}
if let audioItem = itemFromAudio(pasteboard, sourceApp: source.name, sourceBundleId: source.bundleId) {
return audioItem
}
if isIgnored(.richText), hasRichText(on: pasteboard) {
reportReadFailureStatus(ignoredKindMessage(.richText))
return nil
}
if let rtfPayload = itemFromRichText(pasteboard, sourceApp: source.name, sourceBundleId: source.bundleId) {
return rtfPayload
}
if let url {
let item = itemFromURL(url.url, title: url.title, sourceApp: source.name, sourceBundleId: source.bundleId)
return item
}
if isIgnored(.richText), hasHTMLRichText(on: pasteboard) {
reportReadFailureStatus(ignoredKindMessage(.richText))
return nil
}
if let htmlPayload = itemFromHTMLRichText(pasteboard, sourceApp: source.name, sourceBundleId: source.bundleId) {
return htmlPayload
}
if isIgnored(.text), let string = pasteboard.string(forType: .string) {
let trimmed = string.clipboardTrimmed
if !trimmed.isEmpty {
reportReadFailureStatus(ignoredKindMessage(.text))
return nil
}
}
if let string = pasteboard.string(forType: .string),
let item = itemFromString(string, sourceApp: source.name, sourceBundleId: source.bundleId) {
if item.kind == .text, item.payload.isEmpty {
reportReadFailureStatus("Clipboard contains no readable text.")
return nil
}
return item
}
reportReadFailureStatus("Clipboard changed, but ClipBored could not read a supported item.")
return nil
}
private func itemFromString(_ value: String, sourceApp: String?, sourceBundleId: String?) -> ClipboardItem? {
let trimmed = value.clipboardTrimmed
if trimmed.isEmpty {
reportReadFailureStatus("Clipboard string is empty.")
return nil
}
if settings.excludeSensitive, SensitiveContentDetector.isLikelySensitive(trimmed, sourceBundleId: sourceBundleId, sourceApp: sourceApp) {
reportReadFailureStatus("Copy was ignored because it looks sensitive.")
return nil
}
if let url = detectURL(trimmed) {
return ClipboardItem(
id: UUID(),
kind: .url,
displayText: url.absoluteString,
payload: url.absoluteString,
payloadHash: store.hashString(url.absoluteString),
createdAt: Date(),
lastUsedAt: Date(),
useCount: 1,
sourceApp: sourceApp,
imagePath: nil,
thumbnailPath: nil,
isPinned: false,
sourceAppBundleId: sourceBundleId
)
}
return ClipboardItem(
id: UUID(),
kind: .text,
displayText: trimmed,
payload: trimmed,
payloadHash: store.hashString(trimmed),
createdAt: Date(),
lastUsedAt: Date(),
useCount: 1,
sourceApp: sourceApp,
imagePath: nil,
thumbnailPath: nil,
isPinned: false,
sourceAppBundleId: sourceBundleId
)
}
private func itemFromURL(
_ url: URL,
title: String?,
sourceApp: String?,
sourceBundleId: String?,
previewPasteboard: NSPasteboard? = nil
) -> ClipboardItem {
let displayText = urlDisplayText(url: url, title: title)
let id = UUID()
let previewPaths = previewPasteboard.flatMap { previewImagePaths(from: $0, id: id) }
return ClipboardItem(
id: id,
kind: .url,
displayText: displayText,
payload: url.absoluteString,
payloadHash: store.hashString(url.absoluteString),
createdAt: Date(),
lastUsedAt: Date(),
useCount: 1,
sourceApp: sourceApp,
imagePath: previewPaths?.full,
thumbnailPath: previewPaths?.thumb,
isPinned: false,
sourceAppBundleId: sourceBundleId
)
}
private func previewImagePaths(from pasteboard: NSPasteboard, id: UUID) -> (full: String, thumb: String)? {
guard let data = pasteboard.data(forType: .tiff) ?? pasteboard.data(forType: .png),
let image = NSImage(data: data) else {
return nil
}
return cacheService.cacheImage(image, id: id)
}
private func itemFromImage(_ pasteboard: NSPasteboard, sourceApp: String?, sourceBundleId: String?) -> ClipboardItem? {
let imageData = pasteboard.data(forType: .tiff) ?? pasteboard.data(forType: .png)
guard let data = imageData, let image = NSImage(data: data) else {
if imageData != nil {
reportReadFailureStatus("Clipboard image data is present but could not be decoded.")
}
return nil
}
let id = UUID()
guard let cachePaths = cacheService.cacheImage(image, id: id) else {
reportReadFailureStatus("Failed to cache image for clipboard history.")
return nil
}
let recognizedText = recognizedTextIfEnabled(for: image)
return ClipboardItem(
id: id,
kind: .image,
displayText: "Image",
payload: cachePaths.full,
payloadHash: store.hashString(data.base64EncodedString()),
createdAt: Date(),
lastUsedAt: Date(),
useCount: 1,
sourceApp: sourceApp,
imagePath: cachePaths.full,
thumbnailPath: cachePaths.thumb,
isPinned: false,
sourceAppBundleId: sourceBundleId,
ocrText: recognizedText
)
}
private func recognizedTextIfEnabled(for image: NSImage) -> String? {
guard settings.includeImageTextInSearch,
let text = imageTextExtractor(image)?.clipboardTrimmed,
!text.isEmpty else {
return nil
}
let normalized = text
.split(whereSeparator: \.isWhitespace)
.joined(separator: " ")
return String(normalized.prefix(AppConfiguration.maxRecognizedImageTextLength))
}
private func hasImage(on pasteboard: NSPasteboard) -> Bool {
pasteboard.data(forType: .tiff) != nil || pasteboard.data(forType: .png) != nil
}
private func hasPDF(on pasteboard: NSPasteboard) -> Bool {
pasteboard.data(forType: .pdf) != nil
}
private func hasAudio(on pasteboard: NSPasteboard) -> Bool {
pasteboard.data(forType: .sound) != nil
}
private func hasFileItems(on pasteboard: NSPasteboard) -> Bool {
guard let urls = pasteboard.readObjects(forClasses: [NSURL.self], options: nil) as? [URL], !urls.isEmpty else {
return false
}
return urls.contains(where: \.isFileURL)
}
private func hasRichText(on pasteboard: NSPasteboard) -> Bool {
pasteboard.data(forType: .rtf) != nil
}
private func hasHTMLRichText(on pasteboard: NSPasteboard) -> Bool {
htmlData(from: pasteboard) != nil
}
private func itemFromPDF(_ pasteboard: NSPasteboard, sourceApp: String?, sourceBundleId: String?) -> ClipboardItem? {
guard let data = pasteboard.data(forType: .pdf) else { return nil }
let id = UUID()
let hash = store.hashString(data.base64EncodedString())
guard let path = cacheService.cachePDF(data, id: id) else {
reportReadFailureStatus("Failed to cache PDF for clipboard history.")
return nil
}
return ClipboardItem(
id: id,
kind: .pdf,
displayText: "PDF (\(ByteCountFormatter.string(fromByteCount: Int64(data.count), countStyle: .file)))",
payload: path,
payloadHash: hash,
createdAt: Date(),
lastUsedAt: Date(),
useCount: 1,
sourceApp: sourceApp,
imagePath: nil,
thumbnailPath: nil,
isPinned: false,
sourceAppBundleId: sourceBundleId
)
}
private func itemFromAudio(_ pasteboard: NSPasteboard, sourceApp: String?, sourceBundleId: String?) -> ClipboardItem? {
guard let data = pasteboard.data(forType: .sound) else { return nil }
let id = UUID()
let hash = store.hashString(data.base64EncodedString())
guard let path = cacheService.cacheAudio(data, id: id) else {
reportReadFailureStatus("Failed to cache audio for clipboard history.")
return nil
}
return ClipboardItem(
id: id,
kind: .audio,
displayText: "Audio (\(ByteCountFormatter.string(fromByteCount: Int64(data.count), countStyle: .file)))",
payload: path,
payloadHash: hash,
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? {
guard let data = pasteboard.data(forType: .rtf),
let attributed = NSAttributedString(rtf: data, documentAttributes: nil)
else {
reportReadFailureStatus("Clipboard pasteboard reported RTF but text could not be read.")
return nil
}
let text = attributed.string.clipboardTrimmed
if text.isEmpty {
reportReadFailureStatus("Rich text from pasteboard is empty.")
return nil
}
if settings.excludeSensitive, SensitiveContentDetector.isLikelySensitive(text, sourceBundleId: sourceBundleId, sourceApp: sourceApp) {
reportReadFailureStatus("Rich text was ignored because it looks sensitive.")
return nil
}
let id = UUID()
guard let path = cacheService.cacheRichText(data, id: id) else {
reportReadFailureStatus("Failed to cache rich text for clipboard history.")
return nil
}
return ClipboardItem(
id: id,
kind: .richText,
displayText: text,
payload: path,
payloadHash: store.hashData(data),
createdAt: Date(),
lastUsedAt: Date(),
useCount: 1,
sourceApp: sourceApp,
imagePath: nil,
thumbnailPath: nil,
isPinned: false,
sourceAppBundleId: sourceBundleId
)
}
private func itemFromHTMLRichText(_ pasteboard: NSPasteboard, sourceApp: String?, sourceBundleId: String?) -> ClipboardItem? {
guard let htmlData = htmlData(from: pasteboard) else { return nil }
guard let attributed = attributedString(fromHTMLData: htmlData) else {
reportReadFailureStatus("Clipboard pasteboard reported HTML but text could not be read.")
return nil
}
let text = attributed.string.clipboardTrimmed
if text.isEmpty {
reportReadFailureStatus("HTML text from pasteboard is empty.")
return nil
}
if settings.excludeSensitive, SensitiveContentDetector.isLikelySensitive(text, sourceBundleId: sourceBundleId, sourceApp: sourceApp) {
reportReadFailureStatus("HTML text was ignored because it looks sensitive.")
return nil
}
guard let rtfData = attributed.rtf(from: NSRange(location: 0, length: attributed.length), documentAttributes: [:]) else {
reportReadFailureStatus("Failed to convert HTML clipboard data for clipboard history.")
return nil
}
let id = UUID()
guard let path = cacheService.cacheRichText(rtfData, id: id) else {
reportReadFailureStatus("Failed to cache HTML text for clipboard history.")
return nil
}
return ClipboardItem(
id: id,
kind: .richText,
displayText: text,
payload: path,
payloadHash: store.hashData(htmlData),
createdAt: Date(),
lastUsedAt: Date(),
useCount: 1,
sourceApp: sourceApp,
imagePath: nil,
thumbnailPath: nil,
isPinned: false,
sourceAppBundleId: sourceBundleId
)
}
private func itemFromFiles(_ pasteboard: NSPasteboard, sourceApp: String?, sourceBundleId: String?) -> ClipboardItem? {
guard let urls = pasteboard.readObjects(forClasses: [NSURL.self], options: nil) as? [URL],
!urls.isEmpty
else {
if let maybeURLs = pasteboard.readObjects(forClasses: [NSURL.self], options: nil) as? [URL],
!maybeURLs.isEmpty {
reportReadFailureStatus("Clipboard file list could not be read.")
}
return nil
}
let fileURLs = urls.filter(\.isFileURL)
guard !fileURLs.isEmpty else { return nil }
let text = FilePayload.payload(from: fileURLs)
let display = fileURLs.count == 1 ? text : "\(fileURLs.count) files"
return ClipboardItem(
id: UUID(),
kind: .file,
displayText: display,
payload: text,
payloadHash: store.hashString(text),
createdAt: Date(),
lastUsedAt: Date(),
useCount: 1,
sourceApp: sourceApp,
imagePath: nil,
thumbnailPath: nil,
isPinned: false,
sourceAppBundleId: sourceBundleId
)
}
private func detectURL(_ candidate: String) -> URL? {
if let direct = URL(string: candidate), let scheme = direct.scheme, !scheme.isEmpty {
return direct
}
if let detector = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue) {
let range = NSRange(location: 0, length: candidate.utf16.count)
if let match = detector.firstMatch(in: candidate, options: [], range: range),
match.resultType == .link,
match.range.location == range.location,
match.range.length == range.length,
let value = match.url {
return value
}
}
if let comps = URLComponents(string: "https://" + candidate), let host = comps.host, host.contains(".") {
return comps.url
}
return nil
}
private struct PasteboardURLPayload {
let url: URL
let title: String?
}
private func urlPayloadFromPasteboard(_ pasteboard: NSPasteboard) -> PasteboardURLPayload? {
guard let url = pasteboard.string(forType: .URL) else {
return nil
}
guard let detected = detectURL(url) else { return nil }
return PasteboardURLPayload(url: detected, title: urlTitle(from: pasteboard, url: detected))
}
private func urlDisplayText(url: URL, title: String?) -> String {
if let candidate = cleanURLTitle(title, url: url) {
return candidate
}
return url.absoluteString
}
private func urlTitle(from pasteboard: NSPasteboard, url: URL) -> String? {
let titleTypes = [
NSPasteboard.PasteboardType(rawValue: "public.url-name"),
NSPasteboard.PasteboardType(rawValue: "com.apple.pasteboard.promised-file-url-name")
]
for type in titleTypes {
if let title = cleanURLTitle(pasteboard.string(forType: type), url: url) {
return title
}
}
if let html = pasteboard.string(forType: .html),
let title = cleanURLTitle(plainText(fromHTML: html), url: url) {
return title
}
if let data = pasteboard.data(forType: .html),
let html = String(data: data, encoding: .utf8),
let title = cleanURLTitle(plainText(fromHTML: html), url: url) {
return title
}
return nil
}
private func cleanURLTitle(_ value: String?, url: URL) -> String? {
guard let value else { return nil }
let normalized = value.split { $0.isWhitespace }.joined(separator: " ").clipboardTrimmed
guard !normalized.isEmpty else { return nil }
guard normalized != url.absoluteString else { return nil }
guard detectURL(normalized) == nil else { return nil }
return normalized
}
private func plainText(fromHTML html: String) -> String? {
guard let data = html.data(using: .utf8) else { return nil }
return attributedString(fromHTMLData: data)?.string
}
private func htmlData(from pasteboard: NSPasteboard) -> Data? {
if let data = pasteboard.data(forType: .html), !data.isEmpty {
return data
}
if let html = pasteboard.string(forType: .html),
let data = html.data(using: .utf8),
!data.isEmpty {
return data
}
return nil
}
private func attributedString(fromHTMLData data: Data) -> NSAttributedString? {
try? NSAttributedString(
data: data,
options: [
.documentType: NSAttributedString.DocumentType.html,
.characterEncoding: String.Encoding.utf8.rawValue
],
documentAttributes: nil
)
}
private func isSourceIgnored(_ source: (name: String?, bundleId: String?)) -> Bool {
guard !settings.ignoredApps.isEmpty else { return false }
let lowerName = source.name?.lowercased() ?? ""
let lowerBundle = source.bundleId?.lowercased() ?? ""
return settings.ignoredApps.contains { ignored in
let candidate = ignored.clipboardTrimmed.lowercased()
if candidate.isEmpty { return false }
return !candidate.isEmpty && (lowerName.contains(candidate) || lowerBundle.contains(candidate))
}
}
private func frontmostApp() -> (name: String?, bundleId: String?) {
guard let app = NSWorkspace.shared.frontmostApplication else {
return (nil, nil)
}
return (app.localizedName, app.bundleIdentifier)
}
private func reportCaptured(_ item: ClipboardItem) {
let source = item.sourceApp ?? "unknown app"
reportCaptureStatus("Captured \(item.kind.displayName) from \(source).")
}
private func displayNameForStatus(_ kind: ClipboardItemKind) -> String {
let name = kind.displayName
if name == name.uppercased() {
return name
}
return name.capitalized
}
private func reportCaptureStatus(_ message: String) {
DispatchQueue.main.async { [weak self] in
self?.settings.setCaptureStatus(message: message)
}
}
private func reportReadFailureStatus(_ message: String) {
didReportReadFailure = true
reportCaptureStatus(captureFailureDisplayMessage(message))
}
private func captureFailureDisplayMessage(_ message: String) -> String {
let trimmed = message.clipboardTrimmed
guard !trimmed.isEmpty else { return "Skipped: Clipboard changed, but there was no readable content." }
let lower = trimmed.lowercased()
if lower.hasPrefix("skipped:") || lower.hasPrefix("error:") {
return trimmed
}
if lower.contains("failed") {
return "Error: \(trimmed)"
}
return "Skipped: \(trimmed)"
}
private func sourceDescription(_ source: (name: String?, bundleId: String?)) -> String {
source.name ?? source.bundleId ?? "ignored app"
}
}

View File

@@ -0,0 +1,25 @@
import Foundation
enum ClipboardSelfWriteTracker {
private static let queue = DispatchQueue(label: "clipboard.self-write-tracker")
private static var changeCounts: [Int] = []
static func mark(changeCount: Int) {
queue.sync {
changeCounts.append(changeCount)
if changeCounts.count > 16 {
changeCounts.removeFirst(changeCounts.count - 16)
}
}
}
static func consume(changeCount: Int) -> Bool {
queue.sync {
guard let index = changeCounts.firstIndex(of: changeCount) else {
return false
}
changeCounts.remove(at: index)
return true
}
}
}

View File

@@ -0,0 +1,841 @@
import AppKit
import Darwin
import Foundation
import CommonCrypto
import SQLite3
final class ClipboardStore {
private(set) var items: [ClipboardItem] = [] {
didSet { notifyItemsChanged() }
}
private let settings: SettingsModel
private let cacheService: ClipboardCacheService
private let encryptionService: ClipboardEncryptionService
private let dataQueue = DispatchQueue(label: "clipboard.store.persistence", qos: .utility)
private let baseURL: URL
private let historyURL: URL
private let dbURL: URL
private var db: OpaquePointer?
private var itemObservers: [([ClipboardItem]) -> Void] = []
init(
settings: SettingsModel,
cacheService: ClipboardCacheService,
baseURL: URL? = nil,
encryptionService: ClipboardEncryptionService = ClipboardEncryptionService()
) {
self.settings = settings
self.cacheService = cacheService
self.encryptionService = encryptionService
self.baseURL = baseURL ?? ClipboardStore.storageDirectory()
dbURL = self.baseURL.appendingPathComponent("history.sqlite")
historyURL = self.baseURL.appendingPathComponent("history.json")
settings.sanitizeLimits()
hardenStoragePermissions()
openDatabase()
configureDatabase()
createSchema()
hardenStoragePermissions()
migrateLegacyJSONIfNeeded()
load()
}
deinit {
if let db {
sqlite3_close(db)
}
}
static func storageDirectory() -> URL {
if let pointer = getenv(AppConfiguration.storageDirectoryOverrideEnvironmentKey), pointer.pointee != 0 {
let base = URL(fileURLWithPath: String(cString: pointer), isDirectory: true).standardizedFileURL
try? FileManager.default.createDirectory(at: base, withIntermediateDirectories: true)
try? FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: base.path)
return base
}
let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
let base = appSupport.appendingPathComponent(AppConfiguration.appName, isDirectory: true)
try? FileManager.default.createDirectory(at: base, withIntermediateDirectories: true)
try? FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: base.path)
return base
}
func upsert(_ incoming: ClipboardItem) {
guard let index = items.firstIndex(where: { settings.pruneDuplicates ? $0.payloadHash == incoming.payloadHash : false }) else {
insertNewItem(incoming)
return
}
if settings.keepFirstImage, incoming.kind == .image {
updateExistingKeepImage(incoming, at: index)
return
}
updateExistingItem(incoming, at: index)
}
func markUsed(_ id: UUID) {
guard let index = items.firstIndex(where: { $0.id == id }) else { return }
var used = items.remove(at: index)
used.lastUsedAt = Date()
used.useCount += 1
items.insert(used, at: 0)
persistAsync(.upsert(used))
}
func togglePin(_ id: UUID) {
guard let index = items.firstIndex(where: { $0.id == id }) else { return }
items[index].isPinned.toggle()
let updated = items[index]
normalizeHistoryLength()
if items.contains(where: { $0.id == updated.id }) {
persistAsync(.upsert(updated))
}
}
func setCollection(_ id: UUID, name: String?) {
guard let index = items.firstIndex(where: { $0.id == id }) else { return }
items[index].collectionName = ClipboardCollectionDefaults.normalizedName(name)
persistAsync(.upsert(items[index]))
}
func remove(_ id: UUID) {
guard let index = items.firstIndex(where: { $0.id == id }) else { return }
let removed = items.remove(at: index)
if removed.kind.hasManagedCacheReference {
cacheService.removeCachedReferences(removed)
}
persistAsync(.delete(id))
}
func removeAll() {
for item in items {
if item.kind.hasManagedCacheReference {
cacheService.removeCachedReferences(item)
}
}
items.removeAll()
persistAsync(.deleteAll)
}
func updateHistoryLimit(_ newLimit: Int) {
if settings.maxHistoryItems != newLimit {
settings.maxHistoryItems = newLimit
}
normalizeHistoryLength()
}
func observeItems(_ observer: @escaping ([ClipboardItem]) -> Void) {
itemObservers.append(observer)
observer(items)
}
func normalizeHistoryLength() {
var pinnedCount = 0
var unpinnedCount = 0
var kept: [ClipboardItem] = []
var overflow: [ClipboardItem] = []
kept.reserveCapacity(items.count)
for item in items {
if item.isPinned {
if pinnedCount < AppConfiguration.maxPinnedItems {
pinnedCount += 1
kept.append(item)
} else {
overflow.append(item)
}
} else if unpinnedCount < settings.maxHistoryItems {
unpinnedCount += 1
kept.append(item)
} else {
overflow.append(item)
}
}
guard !overflow.isEmpty else { return }
items = kept
var removedCachedPayload = false
var idsToDelete: [UUID] = []
idsToDelete.reserveCapacity(overflow.count)
for item in overflow {
idsToDelete.append(item.id)
if item.kind.hasManagedCacheReference {
removedCachedPayload = true
cacheService.removeCachedReferences(item)
}
}
persistAsync(.deleteMany(idsToDelete), purgeCache: removedCachedPayload)
}
func flushPersistenceForTesting() {
dataQueue.sync {}
}
private func insertNewItem(_ incoming: ClipboardItem) {
items.insert(incoming, at: 0)
normalizeHistoryLength()
persistAsync(.upsert(incoming), purgeCache: incoming.imagePath != nil)
}
private func updateExistingKeepImage(_ incoming: ClipboardItem, at index: Int) {
cacheService.removeCachedReferences(incoming)
var existing = items.remove(at: index)
existing.lastUsedAt = Date()
existing.useCount += 1
if !incoming.displayText.isEmpty {
existing.displayText = incoming.displayText
}
existing.sourceApp = incoming.sourceApp
existing.sourceAppBundleId = incoming.sourceAppBundleId
items.insert(existing, at: 0)
normalizeHistoryLength()
persistAsync(.upsert(existing), purgeCache: existing.kind == .image)
}
private func updateExistingItem(_ incoming: ClipboardItem, at index: Int) {
var existing = items.remove(at: index)
let previousCachedItem = existing
existing.lastUsedAt = Date()
existing.useCount += 1
if !incoming.displayText.isEmpty {
existing.displayText = incoming.displayText
}
existing.payload = incoming.payload
existing.payloadHash = incoming.payloadHash
existing.kind = incoming.kind
existing.sourceApp = incoming.sourceApp
existing.sourceAppBundleId = incoming.sourceAppBundleId
if incoming.kind == .image || incoming.kind == .url {
existing.imagePath = incoming.imagePath
existing.thumbnailPath = incoming.thumbnailPath
} else {
existing.imagePath = nil
existing.thumbnailPath = nil
}
if previousCachedItem.kind.hasManagedCacheReference {
cacheService.removeCachedReferences(previousCachedItem)
}
existing.ocrText = incoming.ocrText
items.insert(existing, at: 0)
normalizeHistoryLength()
persistAsync(.upsert(existing), purgeCache: existing.imagePath != nil)
}
private func persistAsync(_ mutation: PersistenceMutation, purgeCache: Bool = false) {
dataQueue.async {
self.applyPersistence(mutation)
if purgeCache {
self.cacheService.purgeIfNeeded(maxBytes: self.settings.imageCacheMaxBytes)
}
}
}
private func load() {
loadFromDatabase()
}
private func notifyItemsChanged() {
for observer in itemObservers {
observer(items)
}
}
func hashString(_ value: String) -> String {
hashData(Data(value.utf8))
}
func hashData(_ data: Data) -> String {
var digest = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH))
data.withUnsafeBytes { bytes in
_ = CC_SHA256(bytes.baseAddress, CC_LONG(bytes.count), &digest)
}
return hexString(digest)
}
private func hexString(_ bytes: [UInt8]) -> String {
var output: [UInt8] = []
output.reserveCapacity(bytes.count * 2)
for byte in bytes {
output.append(hexDigit(byte >> 4))
output.append(hexDigit(byte & 0x0f))
}
return String(decoding: output, as: UTF8.self)
}
private func hexDigit(_ value: UInt8) -> UInt8 {
value < 10 ? value + 48 : value + 87
}
private func openDatabase() {
if sqlite3_open(dbURL.path, &db) != SQLITE_OK {
db = nil
}
}
private func configureDatabase() {
_ = execute("PRAGMA secure_delete = ON;")
_ = execute("PRAGMA journal_mode = DELETE;")
}
private func hardenStoragePermissions() {
try? FileManager.default.createDirectory(at: baseURL, withIntermediateDirectories: true)
try? FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: baseURL.path)
if FileManager.default.fileExists(atPath: dbURL.path) {
try? FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: dbURL.path)
}
if FileManager.default.fileExists(atPath: historyURL.path) {
try? FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: historyURL.path)
}
}
private func createSchema() {
if db == nil { return }
let createTable = """
CREATE TABLE IF NOT EXISTS clipboard_items (
id TEXT PRIMARY KEY NOT NULL,
kind INTEGER NOT NULL,
display_text TEXT NOT NULL,
payload TEXT NOT NULL,
payload_hash TEXT NOT NULL,
created_at REAL NOT NULL,
last_used_at REAL NOT NULL,
use_count INTEGER NOT NULL DEFAULT 0,
source_app TEXT,
source_app_bundle_id TEXT,
image_path TEXT,
thumbnail_path TEXT,
is_pinned INTEGER NOT NULL DEFAULT 0,
ocr_text TEXT,
collection_name TEXT
);
"""
let createIndexes = """
CREATE INDEX IF NOT EXISTS idx_created_at ON clipboard_items (created_at DESC);
CREATE INDEX IF NOT EXISTS idx_last_used_at ON clipboard_items (last_used_at DESC);
CREATE INDEX IF NOT EXISTS idx_use_count ON clipboard_items (use_count DESC);
CREATE INDEX IF NOT EXISTS idx_kind ON clipboard_items (kind);
CREATE INDEX IF NOT EXISTS idx_hash ON clipboard_items (payload_hash);
CREATE INDEX IF NOT EXISTS idx_collection_name ON clipboard_items (collection_name);
"""
_ = execute(createTable)
_ = execute("ALTER TABLE clipboard_items ADD COLUMN collection_name TEXT;")
_ = execute(createIndexes)
}
private func migrateLegacyJSONIfNeeded() {
guard isDatabaseEmpty(), let data = try? Data(contentsOf: historyURL), !data.isEmpty else {
return
}
if let decoded = decodeLegacyJSONItems(from: data) {
items = decoded
normalizeHistoryLength()
if saveAll(items) {
try? FileManager.default.removeItem(at: historyURL)
hardenStoragePermissions()
}
}
}
private func decodeLegacyJSONItems(from data: Data) -> [ClipboardItem]? {
guard let rows = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] else {
return nil
}
var items: [ClipboardItem] = []
items.reserveCapacity(rows.count)
for row in rows {
if let item = decodeLegacyJSONItem(row) {
items.append(item)
}
}
return items
}
private func decodeLegacyJSONItem(_ row: [String: Any]) -> ClipboardItem? {
guard
let kindValue = row["kind"] as? Int,
let kind = ClipboardItemKind(rawValue: kindValue),
let displayText = row["displayText"] as? String,
let payload = row["payload"] as? String,
let payloadHash = row["payloadHash"] as? String,
let createdAt = legacyDate(row["createdAt"]),
let lastUsedAt = legacyDate(row["lastUsedAt"])
else {
return nil
}
let id = (row["id"] as? String).flatMap(UUID.init(uuidString:)) ?? UUID()
let useCount = row["useCount"] as? Int ?? 0
return ClipboardItem(
id: id,
kind: kind,
displayText: displayText,
payload: payload,
payloadHash: payloadHash,
createdAt: createdAt,
lastUsedAt: lastUsedAt,
useCount: useCount,
sourceApp: row["sourceApp"] as? String,
imagePath: row["imagePath"] as? String,
thumbnailPath: row["thumbnailPath"] as? String,
isPinned: row["isPinned"] as? Bool ?? false,
sourceAppBundleId: row["sourceAppBundleId"] as? String,
ocrText: row["ocrText"] as? String,
collectionName: row["collectionName"] as? String
)
}
private func legacyDate(_ value: Any?) -> Date? {
if let seconds = value as? Double {
return Date(timeIntervalSince1970: seconds)
}
if let number = value as? NSNumber {
return Date(timeIntervalSince1970: number.doubleValue)
}
guard let string = value as? String else {
return nil
}
return legacyISO8601Date(string)
}
private func legacyISO8601Date(_ string: String) -> Date? {
string.withCString { pointer -> Date? in
let byteCount = strlen(pointer)
guard byteCount >= 20,
byte(pointer, 4) == 45,
byte(pointer, 7) == 45,
byte(pointer, 10) == 84 || byte(pointer, 10) == 32,
byte(pointer, 13) == 58,
byte(pointer, 16) == 58,
let year = decimal(pointer, byteCount, 0, 4),
let month = decimal(pointer, byteCount, 5, 2),
let day = decimal(pointer, byteCount, 8, 2),
let hour = decimal(pointer, byteCount, 11, 2),
let minute = decimal(pointer, byteCount, 14, 2),
let second = decimal(pointer, byteCount, 17, 2)
else {
return nil
}
var cursor = 19
var fraction = 0.0
if cursor < byteCount, byte(pointer, cursor) == 46 {
cursor += 1
var scale = 0.1
while cursor < byteCount {
let digit = byte(pointer, cursor)
guard digit >= 48, digit <= 57 else { break }
fraction += Double(digit - 48) * scale
scale /= 10
cursor += 1
}
}
var offset = 0
if cursor < byteCount, byte(pointer, cursor) == 90 {
offset = 0
} else if cursor + 5 < byteCount, byte(pointer, cursor) == 43 || byte(pointer, cursor) == 45 {
let sign = byte(pointer, cursor) == 43 ? 1 : -1
guard let offsetHour = decimal(pointer, byteCount, cursor + 1, 2),
let offsetMinute = decimal(pointer, byteCount, cursor + 4, 2)
else { return nil }
offset = sign * ((offsetHour * 3600) + (offsetMinute * 60))
}
var components = tm()
components.tm_year = Int32(year - 1900)
components.tm_mon = Int32(month - 1)
components.tm_mday = Int32(day)
components.tm_hour = Int32(hour)
components.tm_min = Int32(minute)
components.tm_sec = Int32(second)
components.tm_isdst = 0
let epoch = timegm(&components)
guard epoch >= 0 else { return nil }
return Date(timeIntervalSince1970: TimeInterval(epoch - time_t(offset)) + fraction)
}
}
private func decimal(_ pointer: UnsafePointer<CChar>, _ byteCount: Int, _ start: Int, _ length: Int) -> Int? {
guard start + length <= byteCount else { return nil }
var result = 0
for index in start..<(start + length) {
let digit = byte(pointer, index)
guard digit >= 48, digit <= 57 else { return nil }
result = (result * 10) + Int(digit - 48)
}
return result
}
private func byte(_ pointer: UnsafePointer<CChar>, _ index: Int) -> UInt8 {
UInt8(bitPattern: pointer[index])
}
private func isDatabaseEmpty() -> Bool {
guard let db else { return true }
let query = "SELECT COUNT(*) FROM clipboard_items;"
var statement: OpaquePointer?
defer { sqlite3_finalize(statement) }
guard sqlite3_prepare_v2(db, query, -1, &statement, nil) == SQLITE_OK else {
return true
}
guard sqlite3_step(statement) == SQLITE_ROW else { return true }
let count = sqlite3_column_int64(statement, 0)
return count == 0
}
private func loadFromDatabase() {
guard let db else { return }
let query = """
SELECT
id, kind, display_text, payload, payload_hash, created_at,
last_used_at, use_count, source_app, source_app_bundle_id,
image_path, thumbnail_path, is_pinned, ocr_text, collection_name
FROM clipboard_items
ORDER BY created_at DESC, last_used_at DESC
"""
var statement: OpaquePointer?
defer { sqlite3_finalize(statement) }
guard sqlite3_prepare_v2(db, query, -1, &statement, nil) == SQLITE_OK else { return }
var canEncryptCache: Bool?
func canEncryptForMigration() -> Bool {
if let canEncryptCache {
return canEncryptCache
}
let canEncrypt = encryptionService.isAvailable
canEncryptCache = canEncrypt
return canEncrypt
}
var loaded: [ClipboardItem] = []
var needsEncryptionMigration = false
var hadDecodeFailure = false
while sqlite3_step(statement) == SQLITE_ROW {
guard
let idText = sqlite3_column_text(statement, 0),
let kindValue = Int(exactly: sqlite3_column_int(statement, 1)),
let kind = ClipboardItemKind(rawValue: kindValue)
else {
continue
}
let id = UUID(uuidString: String(cString: idText)) ?? UUID()
func stringValue(_ index: Int32) -> (value: String?, migrationNeeded: Bool, decodeFailed: Bool) {
guard let raw = sqlite3_column_text(statement, index) else {
return (nil, false, false)
}
let value = String(cString: raw)
if ClipboardEncryptionService.isProtected(value) {
guard let decoded = encryptionService.unprotect(value) else {
return (nil, false, true)
}
return (decoded, decoded == value && canEncryptForMigration(), false)
}
return (value, canEncryptForMigration(), false)
}
let displayTextValue = stringValue(2)
let payloadValue = stringValue(3)
let payloadHashValue = stringValue(4)
guard
let displayText = displayTextValue.value,
let payload = payloadValue.value,
let payloadHash = payloadHashValue.value
else {
hadDecodeFailure = hadDecodeFailure
|| displayTextValue.decodeFailed
|| payloadValue.decodeFailed
|| payloadHashValue.decodeFailed
continue
}
needsEncryptionMigration = needsEncryptionMigration
|| displayTextValue.migrationNeeded
|| payloadValue.migrationNeeded
|| payloadHashValue.migrationNeeded
hadDecodeFailure = hadDecodeFailure
|| displayTextValue.decodeFailed
|| payloadValue.decodeFailed
|| payloadHashValue.decodeFailed
let createdAt = Date(timeIntervalSince1970: sqlite3_column_double(statement, 5))
let lastUsedAt = Date(timeIntervalSince1970: sqlite3_column_double(statement, 6))
let useCount = Int(sqlite3_column_int(statement, 7))
let sourceAppValue = stringValue(8)
let sourceAppBundleIdValue = stringValue(9)
let imagePathValue = stringValue(10)
let thumbnailPathValue = stringValue(11)
let isPinned = sqlite3_column_int(statement, 12) != 0
let ocrTextValue = stringValue(13)
let collectionNameValue = stringValue(14)
needsEncryptionMigration = needsEncryptionMigration
|| sourceAppValue.migrationNeeded
|| sourceAppBundleIdValue.migrationNeeded
|| imagePathValue.migrationNeeded
|| thumbnailPathValue.migrationNeeded
|| ocrTextValue.migrationNeeded
|| collectionNameValue.migrationNeeded
hadDecodeFailure = hadDecodeFailure
|| sourceAppValue.decodeFailed
|| sourceAppBundleIdValue.decodeFailed
|| imagePathValue.decodeFailed
|| thumbnailPathValue.decodeFailed
|| ocrTextValue.decodeFailed
|| collectionNameValue.decodeFailed
loaded.append(
ClipboardItem(
id: id,
kind: kind,
displayText: displayText,
payload: payload,
payloadHash: payloadHash,
createdAt: createdAt,
lastUsedAt: lastUsedAt,
useCount: useCount,
sourceApp: sourceAppValue.value,
imagePath: imagePathValue.value,
thumbnailPath: thumbnailPathValue.value,
isPinned: isPinned,
sourceAppBundleId: sourceAppBundleIdValue.value,
ocrText: ocrTextValue.value,
collectionName: collectionNameValue.value
)
)
}
sqlite3_finalize(statement)
statement = nil
items = loaded
normalizeHistoryLength()
cacheService.encryptCachedReferencesIfNeeded(for: items)
if needsEncryptionMigration, !hadDecodeFailure, saveAll(items) {
vacuumDatabase()
hardenStoragePermissions()
}
}
private enum PersistenceMutation {
case upsert(ClipboardItem)
case delete(UUID)
case deleteMany([UUID])
case deleteAll
}
private func applyPersistence(_ mutation: PersistenceMutation) {
guard let db else { return }
DiagnosticsService.shared.incrementDatabaseMutation()
let insertSQL = """
INSERT OR REPLACE INTO clipboard_items (
id, kind, display_text, payload, payload_hash,
created_at, last_used_at, use_count, source_app,
source_app_bundle_id, image_path, thumbnail_path, is_pinned, ocr_text,
collection_name
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);
"""
switch mutation {
case .upsert(let item):
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
}
bindItem(item, to: statement)
let stepResult = sqlite3_step(statement)
if stepResult != SQLITE_DONE {
shouldRollback = true
return
}
if !execute("COMMIT;") {
shouldRollback = true
}
case .delete(let id):
guard execute("BEGIN IMMEDIATE TRANSACTION;") else { return }
let query = "DELETE FROM clipboard_items WHERE id = ?;"
var statement: OpaquePointer?
guard sqlite3_prepare_v2(db, query, -1, &statement, nil) == SQLITE_OK else {
_ = execute("ROLLBACK;")
return
}
bindText(statement, 1, id.uuidString)
let stepResult = sqlite3_step(statement)
sqlite3_finalize(statement)
if stepResult != SQLITE_DONE {
_ = execute("ROLLBACK;")
return
}
_ = execute("COMMIT;")
case .deleteMany(let ids):
guard !ids.isEmpty else { return }
var placeholders = "?"
if ids.count > 1 {
for _ in 1..<ids.count {
placeholders += ",?"
}
}
let query = "DELETE FROM clipboard_items WHERE id IN (\(placeholders));"
var statement: OpaquePointer?
guard execute("BEGIN IMMEDIATE TRANSACTION;") else { return }
guard sqlite3_prepare_v2(db, query, -1, &statement, nil) == SQLITE_OK else {
_ = execute("ROLLBACK;")
return
}
for (offset, id) in ids.enumerated() {
bindText(statement, Int32(offset + 1), id.uuidString)
}
let stepResult = sqlite3_step(statement)
sqlite3_finalize(statement)
if stepResult != SQLITE_DONE {
_ = execute("ROLLBACK;")
return
}
_ = execute("COMMIT;")
case .deleteAll:
if execute("DELETE FROM clipboard_items;") {
vacuumDatabase()
hardenStoragePermissions()
encryptionService.resetStoredKey()
}
}
}
@discardableResult
private func saveAll(_ items: [ClipboardItem]) -> Bool {
guard let db else { return false }
let deleteSQL = "DELETE FROM clipboard_items;"
let insertSQL = """
INSERT OR REPLACE INTO clipboard_items (
id, kind, display_text, payload, payload_hash,
created_at, last_used_at, use_count, source_app,
source_app_bundle_id, image_path, thumbnail_path, is_pinned, ocr_text,
collection_name
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);
"""
guard execute("BEGIN IMMEDIATE TRANSACTION;") else {
return false
}
defer {
if sqlite3_get_autocommit(db) == 0 {
_ = execute("ROLLBACK;")
}
}
guard execute(deleteSQL) else {
_ = execute("ROLLBACK;")
return false
}
var statement: OpaquePointer?
defer { sqlite3_finalize(statement) }
guard sqlite3_prepare_v2(db, insertSQL, -1, &statement, nil) == SQLITE_OK else {
_ = execute("ROLLBACK;")
return false
}
for item in items {
bindItem(item, to: statement)
let stepResult = sqlite3_step(statement)
if stepResult != SQLITE_DONE {
_ = execute("ROLLBACK;")
return false
}
sqlite3_reset(statement)
sqlite3_clear_bindings(statement)
}
if !execute("COMMIT;") {
_ = execute("ROLLBACK;")
return false
}
return true
}
@discardableResult
private func execute(_ sql: String) -> Bool {
guard let db else { return false }
return sqlite3_exec(db, sql, nil, nil, nil) == SQLITE_OK
}
private func vacuumDatabase() {
_ = execute("VACUUM;")
}
private func bindText(_ statement: OpaquePointer?, _ index: Int32, _ value: String?) {
guard let value else {
sqlite3_bind_null(statement, index)
return
}
let destructor: Optional<@convention(c) (UnsafeMutableRawPointer?) -> ()> = unsafeBitCast(-1, to: Optional<@convention(c) (UnsafeMutableRawPointer?) -> ()>.self)
_ = value.withCString { ptr in
sqlite3_bind_text(statement, index, ptr, -1, destructor)
}
}
private func bindItem(_ item: ClipboardItem, to statement: OpaquePointer?) {
bindText(statement, 1, item.id.uuidString)
sqlite3_bind_int(statement, 2, Int32(item.kind.rawValue))
bindText(statement, 3, encryptionService.protect(item.displayText))
bindText(statement, 4, encryptionService.protect(item.payload))
bindText(statement, 5, encryptionService.protect(item.payloadHash))
sqlite3_bind_double(statement, 6, item.createdAt.timeIntervalSince1970)
sqlite3_bind_double(statement, 7, item.lastUsedAt.timeIntervalSince1970)
sqlite3_bind_int(statement, 8, Int32(item.useCount))
bindText(statement, 9, encryptionService.protect(item.sourceApp))
bindText(statement, 10, encryptionService.protect(item.sourceAppBundleId))
bindText(statement, 11, encryptionService.protect(item.imagePath))
bindText(statement, 12, encryptionService.protect(item.thumbnailPath))
sqlite3_bind_int(statement, 13, item.isPinned ? 1 : 0)
bindText(statement, 14, encryptionService.protect(item.ocrText))
bindText(statement, 15, encryptionService.protect(item.collectionName))
}
}

View File

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

View File

@@ -0,0 +1,40 @@
import AppKit
import Vision
enum ImageTextExtractor {
static func recognizedText(in image: NSImage) -> String? {
let boundedImage = image.resized(to: CGSize(
width: AppConfiguration.maxFullImagePixelSize,
height: AppConfiguration.maxFullImagePixelSize
))
guard let cgImage = boundedImage.cgImage(forProposedRect: nil, context: nil, hints: nil) else {
return nil
}
let request = VNRecognizeTextRequest()
request.recognitionLevel = .accurate
request.usesLanguageCorrection = true
request.minimumTextHeight = 0.015
let handler = VNImageRequestHandler(cgImage: cgImage, options: [:])
do {
try handler.perform([request])
} catch {
return nil
}
let lines = request.results?.compactMap { observation in
observation.topCandidates(1).first?.string.clipboardTrimmed
} ?? []
return normalized(lines)
}
private static func normalized(_ lines: [String]) -> String? {
let text = lines
.filter { !$0.isEmpty }
.joined(separator: " ")
.split(whereSeparator: \.isWhitespace)
.joined(separator: " ")
return text.isEmpty ? nil : text
}
}

View File

@@ -0,0 +1,196 @@
import AppKit
import Foundation
final class PasteActionService {
private let cacheService: ClipboardCacheService
private let accessibilityPermissionProvider: () -> Bool
private let targetActivator: (NSRunningApplication) -> Bool
private let keyboardPasteScheduler: (@escaping () -> Void) -> Void
init(
cacheService: ClipboardCacheService = ClipboardCacheService(),
accessibilityPermissionProvider: @escaping () -> Bool = AccessibilityPermissionService.requestPromptIfNeeded,
targetActivator: @escaping (NSRunningApplication) -> Bool = PasteActionService.activateForAutomaticPaste,
keyboardPasteScheduler: @escaping (@escaping () -> Void) -> Void = { action in
DispatchQueue.main.asyncAfter(deadline: .now() + 0.08) {
action()
}
}
) {
self.cacheService = cacheService
self.accessibilityPermissionProvider = accessibilityPermissionProvider
self.targetActivator = targetActivator
self.keyboardPasteScheduler = keyboardPasteScheduler
}
enum PasteActionResult: Equatable {
case pasted
case copied
case copiedNeedsPermission
case failed(String)
var message: String {
switch self {
case .pasted:
return "Pasted"
case .copied:
return "Copied"
case .copiedNeedsPermission:
return "Copied. Grant Accessibility access to paste automatically."
case .failed(let message):
return message
}
}
}
func paste(_ item: ClipboardItem, targetApp: NSRunningApplication?) -> PasteActionResult {
guard writeToPasteboard(item) else {
return .failed("Could not write item to clipboard.")
}
guard let targetApp,
!targetApp.isTerminated else {
return .copied
}
guard accessibilityPermissionProvider() else {
return .copiedNeedsPermission
}
guard targetActivator(targetApp) else {
return .copied
}
keyboardPasteScheduler { [weak self] in
self?.pasteViaKeyboard()
}
return .pasted
}
@discardableResult
func copy(_ item: ClipboardItem) -> PasteActionResult {
writeToPasteboard(item) ? .copied : .failed("Could not write item to clipboard.")
}
@discardableResult
func writeToPasteboard(_ item: ClipboardItem) -> Bool {
let board = NSPasteboard.general
let didWrite: Bool
switch item.kind {
case .image:
guard let imagePath = item.imagePath, let image = cacheService.image(for: imagePath) else { return false }
board.clearContents()
didWrite = board.writeObjects([image])
case .pdf:
guard let data = cacheService.data(for: item.payload) else { return false }
board.clearContents()
didWrite = board.setData(data, forType: .pdf)
case .audio:
guard let data = cacheService.data(for: item.payload) else { return false }
board.clearContents()
didWrite = board.setData(data, forType: .sound)
case .richText:
if let data = cacheService.data(for: item.payload) {
board.clearContents()
let text = richTextPlainString(from: data) ?? item.displayText.clipboardTrimmed
let wroteRTF = board.setData(data, forType: .rtf)
if !text.isEmpty {
_ = board.setString(text, forType: .string)
}
didWrite = wroteRTF
} else {
let fallbackText = richTextFallbackPlainString(for: item)
guard !fallbackText.isEmpty else { return false }
board.clearContents()
didWrite = board.setString(fallbackText, forType: .string)
}
case .file:
let urls = FilePayload.urls(from: item.payload)
guard !urls.isEmpty, urls.allSatisfy({ FileManager.default.fileExists(atPath: $0.path) }) else { return false }
board.clearContents()
didWrite = board.writeObjects(urls.map { $0 as NSURL })
if didWrite {
board.setString(urls.map(\.path).joined(separator: "\n"), forType: .string)
}
case .url:
guard !item.payload.isEmpty else { return false }
board.clearContents()
didWrite = writeURL(item.payload, title: item.displayText, to: board)
case .text, .unknown:
guard !item.payload.isEmpty else { return false }
board.clearContents()
didWrite = board.setString(item.payload, forType: .string)
}
if didWrite {
ClipboardSelfWriteTracker.mark(changeCount: board.changeCount)
}
return didWrite
}
private func writeURL(_ payload: String, title: String?, to board: NSPasteboard) -> Bool {
guard !payload.isEmpty else { return false }
let wroteString = board.setString(payload, forType: .string)
board.setString(payload, forType: .URL)
if let url = URL(string: payload) {
_ = board.writeObjects([url as NSURL])
}
if let title = urlTitleForPasteboard(title, payload: payload) {
board.setString(title, forType: NSPasteboard.PasteboardType(rawValue: "public.url-name"))
}
return wroteString
}
private func urlTitleForPasteboard(_ title: String?, payload: String) -> String? {
guard let title else { return nil }
let normalized = title.split { $0.isWhitespace }.joined(separator: " ").clipboardTrimmed
guard !normalized.isEmpty, normalized != payload else { return nil }
guard !normalized.contains("://") else { return nil }
return normalized
}
private func richTextPlainString(from data: Data) -> String? {
guard let attributed = NSAttributedString(rtf: data, documentAttributes: nil) else {
return nil
}
let text = attributed.string.clipboardTrimmed
return text.isEmpty ? nil : text
}
private func richTextFallbackPlainString(for item: ClipboardItem) -> String {
let payload = item.payload.clipboardTrimmed
if looksLikeRichTextCachePath(payload) {
return item.displayText.clipboardTrimmed
}
if !payload.isEmpty {
return payload
}
return item.displayText.clipboardTrimmed
}
private func looksLikeRichTextCachePath(_ payload: String) -> Bool {
let url = URL(fileURLWithPath: payload)
return payload.contains("/") && url.pathExtension.lowercased() == "rtf"
}
private static func activateForAutomaticPaste(_ targetApp: NSRunningApplication) -> Bool {
if #available(macOS 14, *) {
return targetApp.activate()
} else {
return targetApp.activate(options: [.activateIgnoringOtherApps])
}
}
private func pasteViaKeyboard() {
let keyCode: UInt16 = 9
guard
let srcDown = CGEvent(keyboardEventSource: nil, virtualKey: keyCode, keyDown: true),
let srcUp = CGEvent(keyboardEventSource: nil, virtualKey: keyCode, keyDown: false)
else { return }
srcDown.flags = .maskCommand
srcDown.post(tap: .cghidEventTap)
srcUp.flags = .maskCommand
srcUp.post(tap: .cghidEventTap)
}
}

View File

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

View File

@@ -0,0 +1,266 @@
import AppKit
import Carbon
final class ShortcutManager {
enum RegistrationStatus: Equatable {
case registered
case unsupportedShortcut(String)
case conflict(String)
case registrationFailed(String)
var message: String {
switch self {
case .registered:
return ""
case .unsupportedShortcut(let shortcut):
return "Unsupported shortcut: \(shortcut)"
case .conflict(let shortcut):
return "Shortcut is already in use: \(shortcut)"
case .registrationFailed(let message):
return "Shortcut registration failed: \(message)"
}
}
}
private enum HotKeyID: UInt32 {
case openPanel = 1
case openSettings = 2
}
private let onOpenClipboardPanel: () -> Void
private let onOpenSettings: () -> Void
private let onStatusChange: (RegistrationStatus) -> Void
private var openBinding: ShortcutBinding
private var settingsBinding: ShortcutBinding
private var openHotKey: EventHotKeyRef?
private var settingsHotKey: EventHotKeyRef?
private var eventHandler: EventHandlerRef?
init(
onOpenClipboardPanel: @escaping () -> Void,
onOpenSettings: @escaping () -> Void,
onStatusChange: @escaping (RegistrationStatus) -> Void = { _ in },
openShortcut: ShortcutBinding,
settingsShortcut: ShortcutBinding
) {
self.onOpenClipboardPanel = onOpenClipboardPanel
self.onOpenSettings = onOpenSettings
self.onStatusChange = onStatusChange
self.openBinding = openShortcut
self.settingsBinding = settingsShortcut
}
deinit {
stop()
}
@discardableResult
func start() -> RegistrationStatus {
stop()
if let status = validationFailure(for: openBinding) ?? validationFailure(for: settingsBinding) {
onStatusChange(status)
return status
}
if openBinding == settingsBinding {
let status = RegistrationStatus.conflict(openBinding.displayText)
onStatusChange(status)
return status
}
var eventType = EventTypeSpec(eventClass: OSType(kEventClassKeyboard), eventKind: UInt32(kEventHotKeyPressed))
let installStatus = InstallEventHandler(
GetApplicationEventTarget(),
{ _, event, userData in
guard let userData else { return noErr }
let manager = Unmanaged<ShortcutManager>.fromOpaque(userData).takeUnretainedValue()
manager.handle(event: event)
return noErr
},
1,
&eventType,
Unmanaged.passUnretained(self).toOpaque(),
&eventHandler
)
guard installStatus == noErr else {
let status = RegistrationStatus.registrationFailed(osStatusMessage(installStatus))
onStatusChange(status)
return status
}
let openStatus = register(binding: openBinding, id: .openPanel, target: &openHotKey)
guard openStatus == .registered else {
stop()
onStatusChange(openStatus)
return openStatus
}
let settingsStatus = register(binding: settingsBinding, id: .openSettings, target: &settingsHotKey)
guard settingsStatus == .registered else {
stop()
onStatusChange(settingsStatus)
return settingsStatus
}
onStatusChange(.registered)
return .registered
}
@discardableResult
func reconfigure(openShortcut: ShortcutBinding, settingsShortcut: ShortcutBinding) -> RegistrationStatus {
openBinding = openShortcut
settingsBinding = settingsShortcut
return start()
}
func stop() {
if let openHotKey {
UnregisterEventHotKey(openHotKey)
}
if let settingsHotKey {
UnregisterEventHotKey(settingsHotKey)
}
if let eventHandler {
RemoveEventHandler(eventHandler)
}
openHotKey = nil
settingsHotKey = nil
eventHandler = nil
}
private func register(binding: ShortcutBinding, id: HotKeyID, target: inout EventHotKeyRef?) -> RegistrationStatus {
guard let keyCode = Self.virtualKeyCode(for: binding.key) else {
return .unsupportedShortcut(binding.displayText)
}
let hotKeyID = EventHotKeyID(signature: Self.hotKeySignature, id: id.rawValue)
let status = RegisterEventHotKey(
UInt32(keyCode),
Self.carbonModifiers(for: binding.modifierFlags),
hotKeyID,
GetApplicationEventTarget(),
0,
&target
)
if status == noErr {
return .registered
}
if status == eventHotKeyExistsErr {
return .conflict(binding.displayText)
}
return .registrationFailed(osStatusMessage(status))
}
private func validationFailure(for binding: ShortcutBinding) -> RegistrationStatus? {
guard Self.virtualKeyCode(for: binding.key) != nil else {
return .unsupportedShortcut(binding.displayText)
}
let required = NSEvent.ModifierFlags.command.rawValue
| NSEvent.ModifierFlags.option.rawValue
| NSEvent.ModifierFlags.control.rawValue
return binding.modifierFlags & required == 0 ? .unsupportedShortcut(binding.displayText) : nil
}
private func handle(event: EventRef?) {
guard let event else { return }
var hotKeyID = EventHotKeyID()
let status = GetEventParameter(
event,
EventParamName(kEventParamDirectObject),
EventParamType(typeEventHotKeyID),
nil,
MemoryLayout<EventHotKeyID>.size,
nil,
&hotKeyID
)
guard status == noErr, hotKeyID.signature == Self.hotKeySignature else { return }
switch HotKeyID(rawValue: hotKeyID.id) {
case .openPanel:
onOpenClipboardPanel()
case .openSettings:
onOpenSettings()
case nil:
break
}
}
static func virtualKeyCode(for key: String) -> UInt16? {
guard key.utf8.count == 1, var byte = key.utf8.first else { return nil }
if byte >= 65, byte <= 90 {
byte += 32
}
switch byte {
case 97: return UInt16(kVK_ANSI_A)
case 98: return UInt16(kVK_ANSI_B)
case 99: return UInt16(kVK_ANSI_C)
case 100: return UInt16(kVK_ANSI_D)
case 101: return UInt16(kVK_ANSI_E)
case 102: return UInt16(kVK_ANSI_F)
case 103: return UInt16(kVK_ANSI_G)
case 104: return UInt16(kVK_ANSI_H)
case 105: return UInt16(kVK_ANSI_I)
case 106: return UInt16(kVK_ANSI_J)
case 107: return UInt16(kVK_ANSI_K)
case 108: return UInt16(kVK_ANSI_L)
case 109: return UInt16(kVK_ANSI_M)
case 110: return UInt16(kVK_ANSI_N)
case 111: return UInt16(kVK_ANSI_O)
case 112: return UInt16(kVK_ANSI_P)
case 113: return UInt16(kVK_ANSI_Q)
case 114: return UInt16(kVK_ANSI_R)
case 115: return UInt16(kVK_ANSI_S)
case 116: return UInt16(kVK_ANSI_T)
case 117: return UInt16(kVK_ANSI_U)
case 118: return UInt16(kVK_ANSI_V)
case 119: return UInt16(kVK_ANSI_W)
case 120: return UInt16(kVK_ANSI_X)
case 121: return UInt16(kVK_ANSI_Y)
case 122: return UInt16(kVK_ANSI_Z)
case 48: return UInt16(kVK_ANSI_0)
case 49: return UInt16(kVK_ANSI_1)
case 50: return UInt16(kVK_ANSI_2)
case 51: return UInt16(kVK_ANSI_3)
case 52: return UInt16(kVK_ANSI_4)
case 53: return UInt16(kVK_ANSI_5)
case 54: return UInt16(kVK_ANSI_6)
case 55: return UInt16(kVK_ANSI_7)
case 56: return UInt16(kVK_ANSI_8)
case 57: return UInt16(kVK_ANSI_9)
case 44: return UInt16(kVK_ANSI_Comma)
case 46: return UInt16(kVK_ANSI_Period)
case 47: return UInt16(kVK_ANSI_Slash)
case 59: return UInt16(kVK_ANSI_Semicolon)
case 39: return UInt16(kVK_ANSI_Quote)
case 91: return UInt16(kVK_ANSI_LeftBracket)
case 93: return UInt16(kVK_ANSI_RightBracket)
case 45: return UInt16(kVK_ANSI_Minus)
case 61: return UInt16(kVK_ANSI_Equal)
case 96: return UInt16(kVK_ANSI_Grave)
default: return nil
}
}
static func carbonModifiers(for modifierFlags: UInt) -> UInt32 {
var carbonFlags: UInt32 = 0
if modifierFlags & NSEvent.ModifierFlags.command.rawValue != 0 { carbonFlags |= UInt32(cmdKey) }
if modifierFlags & NSEvent.ModifierFlags.option.rawValue != 0 { carbonFlags |= UInt32(optionKey) }
if modifierFlags & NSEvent.ModifierFlags.control.rawValue != 0 { carbonFlags |= UInt32(controlKey) }
if modifierFlags & NSEvent.ModifierFlags.shift.rawValue != 0 { carbonFlags |= UInt32(shiftKey) }
return carbonFlags
}
private static let hotKeySignature: OSType = 0x436C7042
private func osStatusMessage(_ status: OSStatus) -> String {
"OSStatus \(status)"
}
}

View File

@@ -0,0 +1,457 @@
import AppKit
struct ClipboardPanelAnimationProfile {
let showDuration: TimeInterval
let hideDuration: TimeInterval
let reflowDuration: TimeInterval
let easing: CAMediaTimingFunctionName
}
struct ClipboardPanelReflowPlan {
let frame: NSRect
let bottomSafeInset: CGFloat
}
final class ClipboardPanelController: NSObject, NSWindowDelegate {
private enum Animation {
static let showDuration: TimeInterval = 0.16
static let hideDuration: TimeInterval = 0.12
static let reflowDuration: TimeInterval = 0.10
static let easing: CAMediaTimingFunctionName = .easeInEaseOut
}
private enum Metrics {
static let shelfHeightRatio: CGFloat = 0.42
static let minimumShelfHeight: CGFloat = 408
static let maximumShelfHeight: CGFloat = 430
static let minimumBottomInset: CGFloat = 18
static let maximumBottomInset: CGFloat = 20
}
private var panel: NSPanel!
private var panelView: ClipboardPanelView!
private(set) var isVisible = false
private var clickMonitor: Any?
private var keyMonitor: Any?
private var targetApplication: NSRunningApplication?
private var activeScreenSnapshot: (screenFrame: CGRect, visibleFrame: CGRect)?
private let pollClipboardNow: () -> Void
private let preferredScreenProvider: () -> NSScreen?
private let openSettings: () -> Void
private var isAnimating = false
private var screenParametersObserver: NSObjectProtocol?
private static let collectionShortcuts: [UInt16: ClipboardSortMode] = [
18: .mostRecent,
19: .mostUsed,
20: .text,
21: .links,
23: .images,
22: .files,
26: .pinned,
28: .audio
]
private let viewModel: ClipboardPanelViewModel
init(
store: ClipboardStore,
settings: SettingsModel,
cacheService: ClipboardCacheService,
preferredScreen: @escaping () -> NSScreen? = { nil },
pollClipboardNow: @escaping () -> Void = {},
openSettings: @escaping () -> Void = {}
) {
self.viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: cacheService)
self.pollClipboardNow = pollClipboardNow
self.openSettings = openSettings
self.preferredScreenProvider = preferredScreen
super.init()
viewModel.targetApplicationProvider = { [weak self] in
self?.targetApplication
}
viewModel.willPasteToTarget = { [weak self] in
self?.hide(immediate: true)
}
panelView = ClipboardPanelView(
viewModel: viewModel,
onClose: { [weak self] in self?.hide() },
onSettings: { [weak self] in self?.openSettings() }
)
let contentSize = NSSize(width: 1200, height: 420)
panel = KeyablePanel(
contentRect: NSRect(x: 0, y: 0, width: contentSize.width, height: contentSize.height),
styleMask: [.nonactivatingPanel, .fullSizeContentView],
backing: .buffered,
defer: false
)
panel.contentView = panelView
panel.hasShadow = false
panel.level = .statusBar
panel.isFloatingPanel = true
panel.hidesOnDeactivate = true
panel.delegate = self
panel.isOpaque = false
panel.backgroundColor = NSColor.clear
panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
panel.becomesKeyOnlyIfNeeded = false
panel.titlebarAppearsTransparent = true
panel.titleVisibility = .hidden
panel.standardWindowButton(.miniaturizeButton)?.isHidden = true
panel.standardWindowButton(.zoomButton)?.isHidden = true
panel.standardWindowButton(.closeButton)?.isHidden = true
screenParametersObserver = NotificationCenter.default.addObserver(
forName: NSApplication.didChangeScreenParametersNotification,
object: nil,
queue: .main
) { [weak self] _ in
self?.reflowPanelForScreenChange()
}
}
deinit {
removeClickMonitor()
removeKeyMonitor()
if let screenParametersObserver {
NotificationCenter.default.removeObserver(screenParametersObserver)
}
}
func toggle() {
if isVisible {
hide()
} else {
show()
}
}
func show() {
if isVisible || isAnimating { return }
isAnimating = true
isVisible = true
rememberTargetApplication()
pollClipboardNow()
guard let screen = preferredScreen() else {
isVisible = false
isAnimating = false
return
}
activeScreenSnapshot = (screen.frame, screen.visibleFrame)
let frames = Self.panelFrames(
forScreenFrame: screen.frame,
visibleFrame: screen.visibleFrame
)
panelView.setBottomSafeInset(Self.contentBottomInset(forScreenFrame: screen.frame, visibleFrame: screen.visibleFrame))
panel.setFrame(frames.hidden, display: false)
panel.alphaValue = 0.0
NSApp.activate(ignoringOtherApps: true)
panel.makeKeyAndOrderFront(nil)
panelView.prepareForShow()
viewModel.selectFirstItem()
panelView.beginOpeningTransition()
NSAnimationContext.runAnimationGroup { context in
context.duration = Animation.showDuration
context.allowsImplicitAnimation = true
context.timingFunction = CAMediaTimingFunction(name: Animation.easing)
panel.animator().setFrame(frames.shown, display: true)
panel.animator().alphaValue = 1.0
} completionHandler: { [weak self] in
guard let self else { return }
self.isAnimating = false
self.panelView.finishOpeningTransition()
guard self.isVisible else { return }
self.installClickMonitor()
self.panelView.focusSearchField()
}
installKeyMonitor()
}
func hide() {
hide(immediate: false)
}
func hide(immediate: Bool) {
guard isVisible || isAnimating else { return }
if immediate {
isAnimating = false
panelView.finishOpeningTransition()
panel.orderOut(nil)
isVisible = false
removeClickMonitor()
removeKeyMonitor()
activeScreenSnapshot = nil
return
}
let screenFrames = activeScreenSnapshot ?? activeScreenFrames()
let hidden = Self.panelFrames(
forScreenFrame: screenFrames.screenFrame,
visibleFrame: screenFrames.visibleFrame
).hidden
isAnimating = true
NSAnimationContext.runAnimationGroup { context in
context.duration = Animation.hideDuration
context.allowsImplicitAnimation = true
context.timingFunction = CAMediaTimingFunction(name: Animation.easing)
panel.animator().alphaValue = 0.0
panel.animator().setFrame(hidden, display: true)
} completionHandler: { [weak self] in
self?.panel.orderOut(nil)
self?.panel.alphaValue = 1.0
self?.isVisible = false
self?.isAnimating = false
self?.activeScreenSnapshot = nil
self?.panelView.finishOpeningTransition()
self?.removeClickMonitor()
self?.removeKeyMonitor()
}
}
func windowDidResignKey(_ notification: Notification) {
hide()
}
func windowDidBecomeKey(_ notification: Notification) {
panelView.focusSearchField()
}
private func preferredScreen() -> NSScreen? {
if let menuBarScreen = preferredScreenProvider() {
return menuBarScreen
}
let point = NSEvent.mouseLocation
return NSScreen.screens.first { NSMouseInRect(point, $0.frame, false) } ?? NSScreen.screens.first
}
static func panelFrames(forScreenFrame screenFrame: CGRect) -> (shown: NSRect, hidden: NSRect) {
return panelFrames(forScreenFrame: screenFrame, visibleFrame: screenFrame)
}
static func panelFrames(forScreenFrame screenFrame: CGRect, visibleFrame: CGRect) -> (shown: NSRect, hidden: NSRect) {
let intersectedFrame = visibleFrame.intersection(screenFrame)
let effectiveFrame = intersectedFrame.width > 0 && intersectedFrame.height > 0 ? intersectedFrame : screenFrame
let frameHeight = effectiveFrame.height > 0 ? effectiveFrame.height : max(1, screenFrame.height)
let height = panelHeight(within: frameHeight)
let targetWidth = max(1, floor(effectiveFrame.width))
let shownMinX = effectiveFrame.minX
let shownMinY = max(screenFrame.minY, visibleFrame.minY)
let shown = NSRect(
x: shownMinX,
y: shownMinY,
width: targetWidth,
height: height
)
let hidden = NSRect(
x: shown.minX,
y: shown.minY - height - 1,
width: shown.width,
height: height
)
return (shown, hidden)
}
private static func panelHeight(within visibleHeight: CGFloat) -> CGFloat {
let available = max(1, visibleHeight)
let preferred = floor(available * Metrics.shelfHeightRatio)
let clamped = min(max(preferred, Metrics.minimumShelfHeight), Metrics.maximumShelfHeight)
return min(available, clamped)
}
static func contentBottomInset(forScreenFrame screenFrame: CGRect, visibleFrame: CGRect) -> CGFloat {
let dockInset = max(0, visibleFrame.minY - screenFrame.minY)
return max(Metrics.minimumBottomInset, min(Metrics.maximumBottomInset, dockInset + 2))
}
static var animationProfile: ClipboardPanelAnimationProfile {
ClipboardPanelAnimationProfile(
showDuration: Animation.showDuration,
hideDuration: Animation.hideDuration,
reflowDuration: Animation.reflowDuration,
easing: Animation.easing
)
}
static func reflowPlan(forScreenFrame screenFrame: CGRect, visibleFrame: CGRect) -> ClipboardPanelReflowPlan {
let frames = panelFrames(forScreenFrame: screenFrame, visibleFrame: visibleFrame)
return ClipboardPanelReflowPlan(
frame: frames.shown,
bottomSafeInset: contentBottomInset(forScreenFrame: screenFrame, visibleFrame: visibleFrame)
)
}
private func rememberTargetApplication() {
guard let frontmost = NSWorkspace.shared.frontmostApplication else {
targetApplication = nil
return
}
if frontmost.processIdentifier == NSRunningApplication.current.processIdentifier {
targetApplication = nil
return
}
targetApplication = frontmost
}
private func removeClickMonitor() {
if let clickMonitor {
NSEvent.removeMonitor(clickMonitor)
self.clickMonitor = nil
}
}
private func installKeyMonitor() {
removeKeyMonitor()
keyMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in
guard let self else { return event }
if self.shouldHandlePanelKeyEvent(event, allowSearchFieldEditing: true),
let mode = Self.collectionShortcutMode(forKeyCode: event.keyCode, modifiers: event.modifierFlags) {
self.viewModel.sortMode = mode
return nil
}
guard self.shouldHandlePanelKeyEvent(event) else { return event }
switch event.keyCode {
case 53:
self.hide()
return nil
case 36:
self.viewModel.pasteSelected()
return nil
case 51, 117:
self.viewModel.deleteSelected()
return nil
case 123:
self.viewModel.moveSelection(-1)
return nil
case 124:
self.viewModel.moveSelection(1)
return nil
case 35:
self.viewModel.togglePinSelected()
return nil
default:
return event
}
}
}
private func shouldHandlePanelKeyEvent(_ event: NSEvent) -> Bool {
shouldHandlePanelKeyEvent(event, allowSearchFieldEditing: false)
}
private func shouldHandlePanelKeyEvent(_ event: NSEvent, allowSearchFieldEditing: Bool) -> Bool {
if !allowSearchFieldEditing, self.panelView.isSearchFieldEditing {
return false
}
guard let keyWindow = NSApp.keyWindow,
keyWindow == panel else {
return false
}
// If a key event belongs to this panel while it has lost key status temporarily
// during opening/animations, still allow keyboard shortcuts.
return event.windowNumber == panel.windowNumber
|| NSApp.window(withWindowNumber: event.windowNumber) === panel
}
static func collectionShortcutMode(forKeyCode keyCode: UInt16, modifiers: NSEvent.ModifierFlags) -> ClipboardSortMode? {
let relevantModifiers = modifiers.intersection(.deviceIndependentFlagsMask)
guard relevantModifiers == .command else { return nil }
return collectionShortcuts[keyCode]
}
#if DEBUG
var debugPanelFrame: NSRect {
panel.frame
}
var debugPanelAlpha: CGFloat {
panel.alphaValue
}
var debugIsAnimating: Bool {
isAnimating
}
#endif
private func installClickMonitor() {
removeClickMonitor()
guard isVisible else { return }
let panelWindowNumber = panel.windowNumber
clickMonitor = NSEvent.addGlobalMonitorForEvents(matching: [.leftMouseDown, .rightMouseDown]) { [weak self] event in
guard let self else { return }
guard self.isVisible else { return }
if event.windowNumber == panelWindowNumber {
return
}
if NSApp.window(withWindowNumber: event.windowNumber) === self.panel {
return
}
let point = NSEvent.mouseLocation
if !self.panel.frame.contains(point) {
self.hide()
}
}
}
private func reflowPanelForScreenChange() {
guard isVisible else { return }
guard !isAnimating else { return }
guard let screen = preferredScreen() ?? panel.screen ?? NSScreen.screens.first else { return }
activeScreenSnapshot = (screen.frame, screen.visibleFrame)
let plan = Self.reflowPlan(forScreenFrame: screen.frame, visibleFrame: screen.visibleFrame)
panelView.setBottomSafeInset(plan.bottomSafeInset)
isAnimating = true
NSAnimationContext.runAnimationGroup { context in
context.duration = Animation.reflowDuration
context.allowsImplicitAnimation = true
context.timingFunction = CAMediaTimingFunction(name: Animation.easing)
panel.animator().setFrame(plan.frame, display: true)
} completionHandler: { [weak self] in
self?.isAnimating = false
self?.installClickMonitor()
}
}
private func removeKeyMonitor() {
if let keyMonitor {
NSEvent.removeMonitor(keyMonitor)
self.keyMonitor = nil
}
}
private func activeScreenFrames() -> (screenFrame: CGRect, visibleFrame: CGRect) {
let pointer = NSEvent.mouseLocation
if let screen = NSScreen.screens.first(where: { NSMouseInRect(pointer, $0.frame, false) }) {
return (screen.frame, screen.visibleFrame)
}
let fallback = preferredScreen() ?? NSScreen.screens.first
return (fallback?.frame ?? .zero, fallback?.visibleFrame ?? .zero)
}
}
private final class KeyablePanel: NSPanel {
override var canBecomeKey: Bool { true }
override var canBecomeMain: Bool { true }
}
private extension CGRect {
var center: NSPoint {
NSPoint(x: midX, y: midY)
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,406 @@
import Foundation
import AppKit
final class ClipboardPanelViewModel {
private(set) var visibleItems: [ClipboardItem] = [] {
didSet { notifyMain { self.onVisibleItemsChanged?(self.visibleItems) } }
}
var searchText: String = "" {
didSet {
guard oldValue != searchText else { return }
selectedItemID = selectedItem?.id
recomputeVisibleItems()
}
}
var sortMode: ClipboardSortMode {
didSet {
guard oldValue != sortMode else { return }
selectedCollectionName = nil
settings.defaultSortMode = sortMode
recomputeVisibleItems()
onSortModeChanged?(sortMode)
}
}
private(set) var selectedCollectionName: String? {
didSet {
guard oldValue != selectedCollectionName else { return }
recomputeVisibleItems()
onCollectionsChanged?()
}
}
var selectedIndex: Int = 0 {
didSet {
guard oldValue != selectedIndex else { return }
notifyMain { self.onSelectedIndexChanged?(self.selectedIndex) }
}
}
private(set) var statusMessage: String = "" {
didSet { notifyMain { self.onStatusMessageChanged?(self.statusMessage) } }
}
private var items: [ClipboardItem] = []
private let store: ClipboardStore
private let settings: SettingsModel
private let cacheService: ClipboardCacheService
private let pasteService: PasteActionService
private var selectedItemID: UUID?
var targetApplicationProvider: () -> NSRunningApplication? = { nil }
var willPasteToTarget: () -> Void = {}
var onVisibleItemsChanged: (([ClipboardItem]) -> Void)?
var onSelectedIndexChanged: ((Int) -> Void)?
var onStatusMessageChanged: ((String) -> Void)?
var onSortModeChanged: ((ClipboardSortMode) -> Void)?
var onCollectionsChanged: (() -> Void)?
var onCaptureStatusChanged: (() -> Void)?
init(store: ClipboardStore, settings: SettingsModel, cacheService: ClipboardCacheService) {
self.store = store
self.settings = settings
self.cacheService = cacheService
self.sortMode = settings.defaultSortMode
self.pasteService = PasteActionService(cacheService: cacheService)
store.observeItems { [weak self] list in
self?.notifyMain {
self?.items = list
self?.recomputeVisibleItems()
}
}
settings.observe { [weak self] change in
guard case .captureStatus = change else { return }
self?.notifyMain {
self?.statusMessage = ""
self?.onStatusMessageChanged?("")
self?.onCaptureStatusChanged?()
}
}
}
var selectedItem: ClipboardItem? {
guard selectedIndex >= 0, selectedIndex < visibleItems.count else { return nil }
return visibleItems[selectedIndex]
}
var totalItemCount: Int {
items.count
}
var collectionNames: [String] {
let assignedNames = Set(
items.compactMap { item -> String? in
ClipboardCollectionDefaults.normalizedName(item.collectionName)
}
)
let defaultNames = ClipboardCollectionDefaults.names.filter { assignedNames.contains($0) }
let customNames = assignedNames
.filter { !ClipboardCollectionDefaults.names.contains($0) }
.sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending }
return defaultNames + customNames
}
func collectionCount(for sortMode: ClipboardSortMode) -> Int {
let query = searchText.clipboardTrimmed.lowercased()
return computeVisibleItems(from: items, query: query, sortMode: sortMode).count
}
func collectionCount(named name: String) -> Int {
let query = searchText.clipboardTrimmed.lowercased()
return computeVisibleItems(from: items, query: query, sortMode: sortMode, collectionName: name).count
}
var captureStatusMessage: String {
settings.captureStatusMessage
}
func thumbnail(for item: ClipboardItem) -> NSImage? {
cacheService.previewThumbnail(for: item)
}
func selectItem(at index: Int) {
guard index >= 0 && index < visibleItems.count else { return }
selectedIndex = index
}
func selectFirstItem() {
guard !visibleItems.isEmpty else { return }
selectedItemID = nil
if selectedIndex == 0 {
notifyMain { self.onSelectedIndexChanged?(self.selectedIndex) }
} else {
selectedIndex = 0
}
}
func moveSelection(_ delta: Int) {
let count = visibleItems.count
guard count > 0 else { return }
let target = max(0, min(count - 1, selectedIndex + delta))
selectedIndex = target
}
func pasteSelected() {
guard let item = selectedItem else { return }
let result = pasteService.paste(item, targetApp: targetApplicationProvider())
if case .pasted = result {
willPasteToTarget()
}
if case .failed = result {} else {
store.markUsed(item.id)
selectedItemID = item.id
}
statusMessage = result.message
settings.setPasteStatus(message: result.message)
}
func copySelected() {
guard let item = selectedItem else { return }
let result = pasteService.copy(item)
if case .failed = result {} else {
store.markUsed(item.id)
selectedItemID = item.id
}
statusMessage = result.message
settings.setPasteStatus(message: result.message)
}
func openSelected() {
guard let item = selectedItem else { return }
switch item.kind {
case .url:
guard let url = URL(string: item.payload) else { return }
NSWorkspace.shared.open(url)
case .file:
let urls = FilePayload.urls(from: item.payload)
guard !urls.isEmpty, urls.allSatisfy({ FileManager.default.fileExists(atPath: $0.path) }) else { return }
NSWorkspace.shared.activateFileViewerSelecting(urls)
case .image:
guard let url = cacheService.temporaryReadableURL(for: item) else { return }
NSWorkspace.shared.open(url)
case .pdf:
guard let url = cacheService.temporaryReadableURL(for: item) else { return }
NSWorkspace.shared.open(url)
case .audio:
guard let url = cacheService.temporaryReadableURL(for: item) else { return }
NSWorkspace.shared.open(url)
default:
break
}
}
func revealSelected() {
guard let item = selectedItem else { return }
switch item.kind {
case .image:
guard let url = cacheService.temporaryReadableURL(for: item) else { return }
NSWorkspace.shared.activateFileViewerSelecting([url])
case .file:
let urls = FilePayload.urls(from: item.payload)
guard !urls.isEmpty, urls.allSatisfy({ FileManager.default.fileExists(atPath: $0.path) }) else { return }
NSWorkspace.shared.activateFileViewerSelecting(urls)
case .pdf:
guard let url = cacheService.temporaryReadableURL(for: item) else { return }
NSWorkspace.shared.activateFileViewerSelecting([url])
case .audio:
guard let url = cacheService.temporaryReadableURL(for: item) else { return }
NSWorkspace.shared.activateFileViewerSelecting([url])
default:
break
}
}
func deleteSelected() {
guard let item = selectedItem else { return }
store.remove(item.id)
let next = max(0, min(visibleItems.count - 2, selectedIndex))
selectedIndex = next
}
func togglePinSelected() {
guard let item = selectedItem else { return }
store.togglePin(item.id)
}
func assignSelected(to collectionName: String?) {
guard let item = selectedItem else { return }
selectedItemID = item.id
let normalizedName = ClipboardCollectionDefaults.normalizedName(collectionName)
store.setCollection(item.id, name: normalizedName)
if let normalizedName {
statusMessage = "Added to \(normalizedName)"
} else {
statusMessage = "Removed from collection"
}
}
func selectCollection(named name: String) {
guard let normalizedName = ClipboardCollectionDefaults.normalizedName(name) else { return }
selectedCollectionName = normalizedName
}
func clearSearch() {
searchText = ""
}
func recomputeVisibleItems() {
let previousSelection = selectedItemID
let query = searchText.clipboardTrimmed.lowercased()
visibleItems = computeVisibleItems(from: items, query: query, sortMode: sortMode, collectionName: selectedCollectionName)
if let selectedID = previousSelection, let index = visibleItems.firstIndex(where: { $0.id == selectedID }) {
selectedIndex = index
} else if selectedIndex >= visibleItems.count {
selectedIndex = max(0, visibleItems.count - 1)
} else if selectedIndex < 0 {
selectedIndex = 0
}
if visibleItems.isEmpty {
selectedIndex = 0
}
selectedItemID = selectedItem?.id
onCollectionsChanged?()
}
internal func computeVisibleItems(
from items: [ClipboardItem],
query: String,
sortMode: ClipboardSortMode,
collectionName: String? = nil
) -> [ClipboardItem] {
let tokens = searchTokens(from: query.lowercased())
let filtered = tokens.isEmpty
? items.enumerated().map { ($0.offset, $0.element) }
: items.enumerated().compactMap { index, item in
let text = searchableText(for: item)
return tokens.allSatisfy { text.contains($0) } ? (index, item) : nil
}
let collectionFiltered: [(Int, ClipboardItem)]
if let collectionName = ClipboardCollectionDefaults.normalizedName(collectionName) {
collectionFiltered = filtered.filter {
$0.1.collectionName?.caseInsensitiveCompare(collectionName) == .orderedSame
}
} else {
collectionFiltered = filtered
}
func fallback(_ lhs: (Int, ClipboardItem), _ rhs: (Int, ClipboardItem)) -> Bool {
return lhs.0 < rhs.0
}
func sortByUsage(_ lhs: (Int, ClipboardItem), _ rhs: (Int, ClipboardItem)) -> Bool {
if lhs.1.lastUsedAt == rhs.1.lastUsedAt {
return fallback(lhs, rhs)
}
return lhs.1.lastUsedAt > rhs.1.lastUsedAt
}
if ClipboardCollectionDefaults.normalizedName(collectionName) != nil {
return collectionFiltered
.sorted {
if $0.1.lastUsedAt == $1.1.lastUsedAt {
if $0.1.createdAt == $1.1.createdAt { return fallback($0, $1) }
return $0.1.createdAt > $1.1.createdAt
}
return $0.1.lastUsedAt > $1.1.lastUsedAt
}
.map(\.1)
}
switch sortMode {
case .mostRecent:
return collectionFiltered
.sorted {
if $0.1.lastUsedAt == $1.1.lastUsedAt {
if $0.1.createdAt == $1.1.createdAt { return fallback($0, $1) }
return $0.1.createdAt > $1.1.createdAt
}
return $0.1.lastUsedAt > $1.1.lastUsedAt
}
.map(\.1)
case .mostUsed:
return collectionFiltered
.sorted {
if $0.1.useCount == $1.1.useCount { return sortByUsage($0, $1) }
return $0.1.useCount > $1.1.useCount
}
.map(\.1)
case .images:
return collectionFiltered
.filter { $0.1.kind == .image }
.sorted(by: sortByUsage)
.map(\.1)
case .links:
return collectionFiltered
.filter { $0.1.kind == .url }
.sorted(by: sortByUsage)
.map(\.1)
case .text:
return collectionFiltered
.filter { $0.1.kind == .text || $0.1.kind == .richText }
.sorted(by: sortByUsage)
.map(\.1)
case .files:
return collectionFiltered
.filter { $0.1.kind == .file || $0.1.kind == .pdf }
.sorted(by: sortByUsage)
.map(\.1)
case .audio:
return collectionFiltered
.filter { $0.1.kind == .audio }
.sorted(by: sortByUsage)
.map(\.1)
case .pinned:
return collectionFiltered
.filter { $0.1.isPinned }
.sorted {
if $0.1.lastUsedAt == $1.1.lastUsedAt {
if $0.1.createdAt == $1.1.createdAt {
return fallback($0, $1)
}
return $0.1.createdAt > $1.1.createdAt
}
return $0.1.lastUsedAt > $1.1.lastUsedAt
}
.map(\.1)
@unknown default:
return collectionFiltered
.sorted(by: { lhs, rhs in
return fallback(lhs, rhs)
})
.map(\.1)
}
}
private func searchableText(for item: ClipboardItem) -> String {
var base = item.searchableText
if settings.includeImageTextInSearch, let ocrText = item.ocrText {
base += " \(ocrText.lowercased())"
}
return base
}
private func searchTokens(from query: String) -> [String] {
query
.split { character in
character.isWhitespace || character.isPunctuation
}
.map(String.init)
}
private func notifyMain(_ block: @escaping () -> Void) {
if Thread.isMainThread {
block()
} else {
DispatchQueue.main.async(execute: block)
}
}
}

View File

@@ -0,0 +1,642 @@
import AppKit
final class SettingsWindowController: NSObject, NSTextFieldDelegate, NSTextViewDelegate {
private let settings: SettingsModel
private let store: ClipboardStore
private let cacheService: ClipboardCacheService
private var window: NSWindow?
private let historyLabel = NSTextField(labelWithString: "")
private let historyStepper = NSStepper()
private let pruneDuplicatesButton = NSButton()
private let keepFirstImageButton = NSButton()
private let defaultSortPopup = NSPopUpButton()
private let launchAtLoginButton = NSButton()
private let showMenuBarIconButton = NSButton()
private let launchStatusLabel = NSTextField(labelWithString: "")
private var openShortcutControls: ShortcutControlSet?
private var settingsShortcutControls: ShortcutControlSet?
private let shortcutStatusLabel = NSTextField(labelWithString: "")
private let pauseCaptureButton = NSButton()
private let captureStatusLabel = NSTextField(labelWithString: "")
private let excludeSensitiveButton = NSButton()
private let includeImageTextButton = NSButton()
private var allowedKindButtons: [(ClipboardItemKind, NSButton)] = []
private let ignoredAppsTextView = NSTextView()
private let clearHistoryOnQuitButton = NSButton()
private let accessibilityStatusLabel = NSTextField(labelWithString: "")
private let pasteStatusLabel = NSTextField(labelWithString: "")
private let pollProfilePopup = NSPopUpButton()
private let cacheSlider = NSSlider()
private let cacheLabel = NSTextField(labelWithString: "")
init(settings: SettingsModel, store: ClipboardStore, cacheService: ClipboardCacheService) {
self.settings = settings
self.store = store
self.cacheService = cacheService
super.init()
let windowRect = NSRect(x: 0, y: 0, width: 620, height: 560)
let window = NSWindow(
contentRect: windowRect,
styleMask: [.titled, .closable, .miniaturizable],
backing: .buffered,
defer: false
)
window.title = "ClipBored Settings"
window.contentView = makeContentView()
window.isReleasedWhenClosed = false
window.center()
self.window = window
settings.observe { [weak self] _ in
DispatchQueue.main.async {
self?.refreshFromSettings()
}
}
refreshFromSettings()
}
func show() {
guard let window else { return }
refreshFromSettings()
window.makeKeyAndOrderFront(nil)
NSApp.activate(ignoringOtherApps: true)
}
private func makeContentView() -> NSView {
let tabView = NSTabView()
tabView.translatesAutoresizingMaskIntoConstraints = false
tabView.addTabViewItem(tab("General", generalSettingsView()))
tabView.addTabViewItem(tab("Shortcuts", shortcutSettingsView()))
tabView.addTabViewItem(tab("Capture", captureSettingsView()))
tabView.addTabViewItem(tab("Privacy", privacySettingsView()))
tabView.addTabViewItem(tab("Performance", performanceSettingsView()))
tabView.addTabViewItem(tab("Data", dataSettingsView()))
let container = NSView()
container.addSubview(tabView)
NSLayoutConstraint.activate([
tabView.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 12),
tabView.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -12),
tabView.topAnchor.constraint(equalTo: container.topAnchor, constant: 12),
tabView.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: -12)
])
return container
}
private func tab(_ title: String, _ view: NSView) -> NSTabViewItem {
let item = NSTabViewItem(identifier: title)
item.label = title
item.view = scrollContainer(for: view)
return item
}
private func scrollContainer(for content: NSView) -> NSView {
let scrollView = NSScrollView()
scrollView.hasVerticalScroller = true
scrollView.drawsBackground = false
scrollView.documentView = content
content.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
content.widthAnchor.constraint(equalTo: scrollView.contentView.widthAnchor)
])
return scrollView
}
private func generalSettingsView() -> NSView {
historyStepper.minValue = Double(AppConfiguration.minHistoryLength)
historyStepper.maxValue = Double(AppConfiguration.maxHistoryLength)
historyStepper.increment = 25
historyStepper.target = self
historyStepper.action = #selector(historyLengthChanged)
historyStepper.setAccessibilityLabel("History length")
configureCheckbox(pruneDuplicatesButton, title: "Ignore duplicate items", action: #selector(pruneDuplicatesChanged))
configureCheckbox(keepFirstImageButton, title: "Keep first image copy", action: #selector(keepFirstImageChanged))
configurePopup(defaultSortPopup, action: #selector(defaultSortChanged))
defaultSortPopup.setAccessibilityLabel("Default sort")
for mode in ClipboardSortMode.allCases {
addPopupItem(mode.title, mode.rawValue, to: defaultSortPopup)
}
configureCheckbox(launchAtLoginButton, title: "Launch at login", action: #selector(launchAtLoginChanged))
configureCheckbox(showMenuBarIconButton, title: "Show ClipBored in the menu bar", action: #selector(showMenuBarIconChanged))
configureStatusLabel(launchStatusLabel)
return page([
section("History", [
row([historyLabel, historyStepper]),
pruneDuplicatesButton,
keepFirstImageButton
]),
section("Sort", [
labeledRow("Default sort", defaultSortPopup)
]),
section("Lifecycle", [
launchAtLoginButton,
showMenuBarIconButton,
launchStatusLabel
])
])
}
private func shortcutSettingsView() -> NSView {
let openRow = shortcutRow("Open Clipboard", binding: settings.openShortcut, baseTag: 100)
let settingsRow = shortcutRow("Open Settings", binding: settings.settingsShortcut, baseTag: 200)
configureStatusLabel(shortcutStatusLabel)
return page([
section("Shortcuts", [
openRow,
settingsRow,
shortcutStatusLabel
])
])
}
private func captureSettingsView() -> NSView {
configureCheckbox(pauseCaptureButton, title: "Pause clipboard capture", action: #selector(pauseCaptureChanged))
configureCheckbox(excludeSensitiveButton, title: "Exclude likely secrets", action: #selector(excludeSensitiveChanged))
configureCheckbox(includeImageTextButton, title: "Search in image labels", action: #selector(includeImageTextChanged))
configureStatusLabel(captureStatusLabel)
let allowedRows = [
kindCheckbox("Text", .text),
kindCheckbox("Links", .url),
kindCheckbox("Images", .image),
kindCheckbox("Audio", .audio),
kindCheckbox("Rich text", .richText),
kindCheckbox("PDFs", .pdf),
kindCheckbox("Files", .file)
]
ignoredAppsTextView.delegate = self
ignoredAppsTextView.font = .systemFont(ofSize: NSFont.systemFontSize)
ignoredAppsTextView.setAccessibilityLabel("Ignored source apps")
let ignoredScroll = NSScrollView()
ignoredScroll.hasVerticalScroller = true
ignoredScroll.borderType = .bezelBorder
ignoredScroll.documentView = ignoredAppsTextView
ignoredScroll.heightAnchor.constraint(equalToConstant: 96).isActive = true
return page([
section("Capture", [
pauseCaptureButton,
excludeSensitiveButton,
includeImageTextButton,
captureStatusLabel
]),
section("Allowed content types", allowedRows),
section("Ignored source apps", [
ignoredScroll
])
])
}
private func privacySettingsView() -> NSView {
let storageLabel = caption("Clipboard history is stored locally in Application Support. Text, image cache files, audio clips, and PDF attachments are encrypted with Keychain when available, or an owner-only local fallback key if needed.")
let permissionHelpLabel = caption("Clipboard history capture works without this permission. Grant Accessibility to paste selected items into the previous app.")
configureCheckbox(clearHistoryOnQuitButton, title: "Clear history on quit", action: #selector(clearHistoryOnQuitChanged))
configureStatusLabel(accessibilityStatusLabel)
let requestButton = button("Open Accessibility Settings", #selector(requestAccessibilityAccess))
let refreshButton = button("Refresh Permission Status", #selector(refreshAccessibilityPermissionStatus))
configureStatusLabel(pasteStatusLabel)
return page([
section("Local storage", [
storageLabel,
clearHistoryOnQuitButton
]),
section("Paste permission", [
permissionHelpLabel,
row([NSTextField(labelWithString: "Accessibility"), accessibilityStatusLabel]),
row([requestButton, refreshButton])
]),
section("Paste status", [
pasteStatusLabel
])
])
}
private func performanceSettingsView() -> NSView {
configurePopup(pollProfilePopup, action: #selector(pollProfileChanged))
pollProfilePopup.setAccessibilityLabel("Polling profile")
for profile in AppConfiguration.PollProfile.allCases {
addPopupItem(profile.title, profile.rawValue, to: pollProfilePopup)
}
cacheSlider.minValue = 4
cacheSlider.maxValue = 512
cacheSlider.numberOfTickMarks = 9
cacheSlider.allowsTickMarkValuesOnly = true
cacheSlider.target = self
cacheSlider.action = #selector(cacheLimitChanged)
cacheSlider.setAccessibilityLabel("Image cache cap in megabytes")
configureStatusLabel(cacheLabel)
return page([
section("Polling", [
labeledRow("Polling profile", pollProfilePopup)
]),
section("Cache", [
labeledRow("Image cache cap (MB)", cacheSlider),
cacheLabel
])
])
}
private func dataSettingsView() -> NSView {
page([
section("Data", [
button("Open History Folder", #selector(openHistoryFolder)),
button("Clear Clipboard History", #selector(clearClipboardHistory)),
button("Clear Thumbnail Cache", #selector(clearThumbnailCache))
])
])
}
private func page(_ views: [NSView]) -> NSView {
let stack = NSStackView(views: views)
stack.orientation = .vertical
stack.alignment = .leading
stack.spacing = 18
stack.edgeInsets = NSEdgeInsets(top: 18, left: 18, bottom: 18, right: 18)
return stack
}
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: 150).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 configureCheckbox(_ control: NSButton, title: String, action: Selector) {
control.setButtonType(.switch)
control.title = title
control.target = self
control.action = action
control.setAccessibilityLabel(title)
}
private func configureStatusLabel(_ label: NSTextField) {
label.textColor = .secondaryLabelColor
label.font = .systemFont(ofSize: NSFont.smallSystemFontSize)
label.lineBreakMode = .byTruncatingTail
}
private func configurePopup(_ popup: NSPopUpButton, action: Selector) {
popup.removeAllItems()
popup.target = self
popup.action = action
}
private func addPopupItem(_ title: String, _ rawValue: Int, to popup: NSPopUpButton) {
popup.addItem(withTitle: title)
popup.lastItem?.representedObject = rawValue
}
private func kindCheckbox(_ title: String, _ kind: ClipboardItemKind) -> NSButton {
let control = NSButton()
configureCheckbox(control, title: title, action: #selector(allowedKindChanged(_:)))
control.tag = kind.rawValue
allowedKindButtons.append((kind, control))
return control
}
private func shortcutRow(_ title: String, binding: ShortcutBinding, baseTag: Int) -> NSView {
let label = NSTextField(labelWithString: title)
label.widthAnchor.constraint(equalToConstant: 120).isActive = true
let keyField = NSTextField(string: binding.key.uppercased())
keyField.placeholderString = "Key"
keyField.alignment = .center
keyField.delegate = self
keyField.target = self
keyField.action = #selector(shortcutChanged(_:))
keyField.tag = baseTag
keyField.setAccessibilityLabel("\(title) shortcut key")
keyField.widthAnchor.constraint(equalToConstant: 56).isActive = true
let command = modifierButton("", baseTag + 1)
let option = modifierButton("", baseTag + 2)
let control = modifierButton("", baseTag + 3)
let shift = modifierButton("", baseTag + 4)
let controls = ShortcutControlSet(keyField: keyField, command: command, option: option, control: control, shift: shift)
if baseTag == 100 {
openShortcutControls = controls
} else {
settingsShortcutControls = controls
}
return row([label, keyField, command, option, control, shift])
}
private func modifierButton(_ title: String, _ tag: Int) -> NSButton {
let control = NSButton()
configureCheckbox(control, title: title, action: #selector(shortcutChanged(_:)))
control.toolTip = modifierTooltip(title)
control.tag = tag
return control
}
private func modifierTooltip(_ title: String) -> String {
switch title {
case "": return "Command"
case "": return "Option"
case "": return "Control"
case "": return "Shift"
default: return title
}
}
private func refreshFromSettings() {
historyLabel.stringValue = "History length: \(settings.maxHistoryItems)"
historyStepper.integerValue = settings.maxHistoryItems
pruneDuplicatesButton.state = settings.pruneDuplicates ? .on : .off
keepFirstImageButton.state = settings.keepFirstImage ? .on : .off
select(defaultSortPopup, rawValue: settings.defaultSortMode.rawValue)
launchAtLoginButton.state = settings.launchAtLogin ? .on : .off
showMenuBarIconButton.state = settings.showMenuBarIcon ? .on : .off
launchStatusLabel.stringValue = settings.launchAtLoginErrorMessage
refreshShortcutControls(openShortcutControls, binding: settings.openShortcut)
refreshShortcutControls(settingsShortcutControls, binding: settings.settingsShortcut)
shortcutStatusLabel.stringValue = settings.shortcutStatusMessage.isEmpty ? "Registered" : settings.shortcutStatusMessage
pauseCaptureButton.state = settings.pauseCapture ? .on : .off
captureStatusLabel.stringValue = settings.captureStatusMessage.isEmpty ? "Capture status will appear after the app sees clipboard activity." : settings.captureStatusMessage
excludeSensitiveButton.state = settings.excludeSensitive ? .on : .off
includeImageTextButton.state = settings.includeImageTextInSearch ? .on : .off
for (kind, button) in allowedKindButtons {
button.state = settings.ignoredItemKindsRaw.contains(kind.rawValue) ? .off : .on
}
let ignoredAppsText = settings.ignoredApps.joined(separator: ", ")
if ignoredAppsTextView.string != ignoredAppsText {
ignoredAppsTextView.string = ignoredAppsText
}
clearHistoryOnQuitButton.state = settings.clearHistoryOnQuit ? .on : .off
let hasAccessibilityPermission = AccessibilityPermissionService.isTrusted
let permissionStatus = hasAccessibilityPermission
? "Granted"
: "Not granted (clipboard capture still works; paste falls back to copy)"
accessibilityStatusLabel.stringValue = permissionStatus
accessibilityStatusLabel.textColor = hasAccessibilityPermission ? .systemGreen : .systemOrange
pasteStatusLabel.stringValue = settings.pasteStatusMessage.isEmpty ? "No paste action yet." : settings.pasteStatusMessage
select(pollProfilePopup, rawValue: settings.pollProfileRaw.rawValue)
cacheSlider.doubleValue = Double(settings.imageCacheMaxBytes) / 1024 / 1024
cacheLabel.stringValue = "Current cache cap: \(Int(cacheSlider.doubleValue)) MB"
}
private func refreshShortcutControls(_ controls: ShortcutControlSet?, binding: ShortcutBinding) {
guard let controls else { return }
controls.keyField.stringValue = binding.key.uppercased()
controls.command.state = binding.has(.command) ? .on : .off
controls.option.state = binding.has(.option) ? .on : .off
controls.control.state = binding.has(.control) ? .on : .off
controls.shift.state = binding.has(.shift) ? .on : .off
}
private func select(_ popup: NSPopUpButton, rawValue: Int) {
for item in popup.itemArray where item.representedObject as? Int == rawValue {
popup.select(item)
return
}
}
@objc private func historyLengthChanged() {
settings.maxHistoryItems = historyStepper.integerValue
historyLabel.stringValue = "History length: \(settings.maxHistoryItems)"
}
@objc private func pruneDuplicatesChanged() {
settings.pruneDuplicates = pruneDuplicatesButton.state == .on
}
@objc private func keepFirstImageChanged() {
settings.keepFirstImage = keepFirstImageButton.state == .on
}
@objc private func defaultSortChanged() {
if let rawValue = defaultSortPopup.selectedItem?.representedObject as? Int,
let mode = ClipboardSortMode(rawValue: rawValue) {
settings.defaultSortMode = mode
}
}
@objc private func launchAtLoginChanged() {
settings.launchAtLogin = launchAtLoginButton.state == .on
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in
self?.refreshFromSettings()
}
}
@objc private func showMenuBarIconChanged() {
settings.showMenuBarIcon = showMenuBarIconButton.state == .on
}
@objc private func shortcutChanged(_ sender: NSControl) {
let isOpenShortcut = sender.tag < 200
let controls = isOpenShortcut ? openShortcutControls : settingsShortcutControls
guard let controls else { return }
let current = isOpenShortcut ? settings.openShortcut : settings.settingsShortcut
let parsed = parseShortcut(controls.keyField.stringValue)
var flags = NSEvent.ModifierFlags(rawValue: parsed?.modifierFlags ?? current.modifierFlags)
setFlag(&flags, .command, controls.command.state == .on || parsed?.has(.command) == true)
setFlag(&flags, .option, controls.option.state == .on || parsed?.has(.option) == true)
setFlag(&flags, .control, controls.control.state == .on || parsed?.has(.control) == true)
setFlag(&flags, .shift, controls.shift.state == .on || parsed?.has(.shift) == true)
let key = parsed?.key ?? String(controls.keyField.stringValue.clipboardTrimmed.prefix(1)).lowercased()
guard !key.isEmpty else {
refreshFromSettings()
return
}
let updated = ShortcutBinding(key: key, modifierFlags: flags.rawValue)
if isOpenShortcut {
settings.openShortcut = updated
} else {
settings.settingsShortcut = updated
}
refreshFromSettings()
}
func controlTextDidEndEditing(_ notification: Notification) {
guard let field = notification.object as? NSTextField, field.tag == 100 || field.tag == 200 else { return }
shortcutChanged(field)
}
@objc private func pauseCaptureChanged() {
settings.pauseCapture = pauseCaptureButton.state == .on
}
@objc private func excludeSensitiveChanged() {
settings.excludeSensitive = excludeSensitiveButton.state == .on
}
@objc private func includeImageTextChanged() {
settings.includeImageTextInSearch = includeImageTextButton.state == .on
}
@objc private func allowedKindChanged(_ sender: NSButton) {
guard let kind = ClipboardItemKind(rawValue: sender.tag) else { return }
var ignored = settings.ignoredItemKindsRaw
if sender.state == .on {
ignored.removeAll { $0 == kind.rawValue }
} else if !ignored.contains(kind.rawValue) {
ignored.append(kind.rawValue)
}
settings.ignoredItemKindsRaw = ignored
}
func textDidChange(_ notification: Notification) {
guard notification.object as? NSTextView === ignoredAppsTextView else { return }
settings.ignoredApps = ignoredAppsTextView.string
.split(whereSeparator: { $0 == "," || $0 == "\n" })
.map { $0.clipboardTrimmed }
.filter { !$0.isEmpty }
}
@objc private func clearHistoryOnQuitChanged() {
settings.clearHistoryOnQuit = clearHistoryOnQuitButton.state == .on
}
@objc private func requestAccessibilityAccess() {
_ = AccessibilityPermissionService.requestPromptIfNeeded()
if !AccessibilityPermissionService.isTrusted {
AccessibilityPermissionService.openSystemSettings()
}
settings.setAccessibilityPermissionStatus(
message: AccessibilityPermissionService.isTrusted ? "" : "Accessibility permission not granted."
)
refreshAccessibilityPermissionStatus()
}
@objc private func refreshAccessibilityPermissionStatus() {
settings.setAccessibilityPermissionStatus(
message: AccessibilityPermissionService.isTrusted
? ""
: "Accessibility permission not granted."
)
refreshFromSettings()
}
@objc private func pollProfileChanged() {
if let rawValue = pollProfilePopup.selectedItem?.representedObject as? Int,
let profile = AppConfiguration.PollProfile(rawValue: rawValue) {
settings.pollProfileRaw = profile
}
}
@objc private func cacheLimitChanged() {
settings.imageCacheMaxBytes = Int64(cacheSlider.doubleValue * 1024 * 1024)
cacheLabel.stringValue = "Current cache cap: \(Int(cacheSlider.doubleValue)) MB"
}
@objc private func openHistoryFolder() {
NSWorkspace.shared.open(ClipboardStore.storageDirectory())
}
@objc private func clearClipboardHistory() {
guard confirmDestructiveAction(
title: "Clear Clipboard History?",
message: "This permanently removes saved clipboard items, app-managed attachments, temporary decrypted previews, and the local fallback encryption key when present. The current system clipboard is not changed.",
buttonTitle: "Clear History"
) else { return }
store.removeAll()
cacheService.clearTemporaryPreviews()
}
@objc private func clearThumbnailCache() {
guard confirmDestructiveAction(
title: "Clear Thumbnail Cache?",
message: "This removes cached image previews and temporary decrypted previews. ClipBored will recreate previews as needed.",
buttonTitle: "Clear Cache"
) else { return }
cacheService.clearCache()
}
private func confirmDestructiveAction(title: String, message: String, buttonTitle: String) -> Bool {
let alert = NSAlert()
alert.messageText = title
alert.informativeText = message
alert.alertStyle = .warning
alert.addButton(withTitle: buttonTitle)
alert.addButton(withTitle: "Cancel")
return alert.runModal() == .alertFirstButtonReturn
}
private func setFlag(_ flags: inout NSEvent.ModifierFlags, _ flag: NSEvent.ModifierFlags, _ enabled: Bool) {
if enabled {
flags.insert(flag)
} else {
flags.remove(flag)
}
}
private func parseShortcut(_ text: String) -> ShortcutBinding? {
let cleaned = text.clipboardTrimmed
if cleaned.isEmpty { return nil }
var flags = NSEvent.ModifierFlags()
if cleaned.contains("") { flags.insert(.command) }
if cleaned.contains("") { flags.insert(.option) }
if cleaned.contains("") { flags.insert(.control) }
if cleaned.contains("") { flags.insert(.shift) }
let plain = cleaned.replacingOccurrences(of: "", with: "")
.replacingOccurrences(of: "", with: "")
.replacingOccurrences(of: "", with: "")
.replacingOccurrences(of: "", with: "")
.clipboardTrimmed
guard let key = plain.first else { return nil }
return ShortcutBinding(key: String(key), modifierFlags: flags.rawValue)
}
}
private struct ShortcutControlSet {
let keyField: NSTextField
let command: NSButton
let option: NSButton
let control: NSButton
let shift: NSButton
}

View File

@@ -0,0 +1,100 @@
import AppKit
import XCTest
@testable import ClipBored
final class AppDelegateTests: XCTestCase {
func testStatusItemMenuRoutingSeparatesLeftAndRightClick() {
XCTAssertFalse(AppDelegate.shouldOpenStatusMenu(eventType: .leftMouseUp, modifierFlags: []))
XCTAssertTrue(AppDelegate.shouldOpenStatusMenu(eventType: .rightMouseUp, modifierFlags: []))
XCTAssertTrue(AppDelegate.shouldOpenStatusMenu(eventType: .leftMouseUp, modifierFlags: .control))
XCTAssertTrue(AppDelegate.shouldOpenStatusMenu(eventType: .otherMouseUp, modifierFlags: []))
XCTAssertFalse(AppDelegate.shouldOpenStatusMenu(eventType: .leftMouseDragged, modifierFlags: .control))
}
func testStatusMenuIncludesStateRowsAndBoundedActions() {
let settingsTitle = "Settings\u{2026}"
let presentation = AppDelegate.statusMenuPresentation(
historyCount: 42,
isCapturePaused: false,
captureStatus: "Captured text from Safari.",
pasteStatus: "",
shortcutStatus: "",
accessibilityStatus: "",
launchAtLoginStatus: ""
)
let menu = AppDelegate.makeStatusMenu(
presentation: presentation,
isCapturePaused: false,
openShortcut: AppConfiguration.defaultOpenShortcut,
settingsShortcut: AppConfiguration.defaultSettingsShortcut,
target: nil
)
XCTAssertEqual(
menu.items.map { $0.isSeparatorItem ? "-" : $0.title },
[
"ClipBored",
"Capture Running - 42 clips",
"Captured text from Safari.",
"-",
"Show Clipboard",
settingsTitle,
"-",
"Pause Capture",
"-",
"Quit ClipBored"
]
)
let showClipboard = menu.items.first { $0.title == "Show Clipboard" }
XCTAssertEqual(showClipboard?.keyEquivalent, "v")
XCTAssertTrue(showClipboard?.keyEquivalentModifierMask.contains(.command) == true)
XCTAssertTrue(showClipboard?.keyEquivalentModifierMask.contains(.option) == true)
let settings = menu.items.first { $0.title == settingsTitle }
XCTAssertEqual(settings?.keyEquivalent, ",")
XCTAssertTrue(settings?.keyEquivalentModifierMask.contains(.command) == true)
}
func testStatusMenuPausedStateTakesPriorityOverOlderCaptureStatus() {
let presentation = AppDelegate.statusMenuPresentation(
historyCount: 1,
isCapturePaused: true,
captureStatus: "Captured link from Safari.",
pasteStatus: "Copied",
shortcutStatus: "",
accessibilityStatus: "",
launchAtLoginStatus: ""
)
let menu = AppDelegate.makeStatusMenu(
presentation: presentation,
isCapturePaused: true,
openShortcut: AppConfiguration.defaultOpenShortcut,
settingsShortcut: AppConfiguration.defaultSettingsShortcut,
target: nil
)
XCTAssertEqual(presentation.summary, "Capture Paused - 1 clip")
XCTAssertEqual(presentation.detail, "Capture is paused.")
XCTAssertEqual(menu.items.first { $0.title == "Resume Capture" }?.state, .on)
XCTAssertNil(menu.items.first { $0.title == "Pause Capture" })
}
func testStatusMenuPresentationTruncatesLongStatusText() {
let presentation = AppDelegate.statusMenuPresentation(
historyCount: 2000,
isCapturePaused: false,
captureStatus: "Skipped:\n" + String(repeating: "A very long ignored source application name ", count: 4),
pasteStatus: "",
shortcutStatus: "",
accessibilityStatus: "",
launchAtLoginStatus: ""
)
XCTAssertEqual(presentation.summary, "Capture Running - 2000 clips")
XCTAssertNotNil(presentation.detail)
XCTAssertLessThanOrEqual(presentation.detail?.count ?? 0, 68)
XCTAssertTrue(presentation.detail?.hasSuffix("...") == true)
XCTAssertFalse(presentation.detail?.contains("\n") == true)
}
}

View File

@@ -0,0 +1,319 @@
import AppKit
import CryptoKit
import XCTest
@testable import ClipBored
final class ClipboardCacheServiceTests: XCTestCase {
private var tempURLs: [URL] = []
override func tearDown() {
tempURLs.forEach { try? FileManager.default.removeItem(at: $0) }
tempURLs.removeAll()
try? FileManager.default.removeItem(at: temporaryPreviewRoot())
super.tearDown()
}
func testPurgeRemovesImageCacheFilesOverByteLimit() throws {
let baseURL = try makeTempDirectory()
let cacheService = ClipboardCacheService(baseURL: baseURL, encryptionService: noOpEncryptionService())
let first = try XCTUnwrap(cacheService.cacheImage(makeImage(color: .systemRed), id: UUID()))
let second = try XCTUnwrap(cacheService.cacheImage(makeImage(color: .systemBlue), id: UUID()))
XCTAssertTrue(FileManager.default.fileExists(atPath: first.full))
XCTAssertTrue(FileManager.default.fileExists(atPath: first.thumb))
XCTAssertTrue(FileManager.default.fileExists(atPath: second.full))
XCTAssertTrue(FileManager.default.fileExists(atPath: second.thumb))
cacheService.purgeIfNeeded(maxBytes: 1)
cacheService.flushForTesting()
let remaining = try imageCacheFileURLs(in: baseURL)
XCTAssertTrue(remaining.isEmpty)
}
func testClearCacheRemovesOnlyImageCacheFiles() throws {
let baseURL = try makeTempDirectory()
let cacheService = ClipboardCacheService(baseURL: baseURL, encryptionService: noOpEncryptionService())
let image = try XCTUnwrap(cacheService.cacheImage(makeImage(color: .systemGreen), id: UUID()))
let pdfPath = try XCTUnwrap(cacheService.cachePDF(Data("%PDF-1.4\n%%EOF".utf8), id: UUID()))
let pdfItem = pdfItem(path: pdfPath)
let previewURL = try XCTUnwrap(cacheService.temporaryReadableURL(for: pdfItem))
XCTAssertTrue(FileManager.default.fileExists(atPath: image.full))
XCTAssertTrue(FileManager.default.fileExists(atPath: image.thumb))
XCTAssertTrue(FileManager.default.fileExists(atPath: pdfPath))
XCTAssertTrue(FileManager.default.fileExists(atPath: previewURL.path))
cacheService.clearCache()
cacheService.flushForTesting()
XCTAssertTrue(try imageCacheFileURLs(in: baseURL).isEmpty)
XCTAssertTrue(FileManager.default.fileExists(atPath: pdfPath))
XCTAssertFalse(FileManager.default.fileExists(atPath: previewURL.path))
}
func testImageCacheFilesAreEncryptedAndLoadable() throws {
let baseURL = try makeTempDirectory()
let cacheService = ClipboardCacheService(baseURL: baseURL, encryptionService: fixedEncryptionService())
let image = makeImage(color: .systemPurple)
let paths = try XCTUnwrap(cacheService.cacheImage(image, id: UUID()))
let rawFull = try Data(contentsOf: URL(fileURLWithPath: paths.full))
let rawThumb = try Data(contentsOf: URL(fileURLWithPath: paths.thumb))
XCTAssertTrue(ClipboardEncryptionService.isProtected(rawFull))
XCTAssertTrue(ClipboardEncryptionService.isProtected(rawThumb))
XCTAssertNil(NSImage(data: rawFull))
XCTAssertNotNil(cacheService.image(for: paths.full))
XCTAssertNotNil(cacheService.image(for: paths.thumb))
XCTAssertEqual(try posixPermissions(URL(fileURLWithPath: paths.full)), 0o600)
XCTAssertEqual(try posixPermissions(URL(fileURLWithPath: paths.thumb)), 0o600)
}
func testPreviewThumbnailUsesExistingFilePreview() throws {
let baseURL = try makeTempDirectory()
let cacheService = ClipboardCacheService(baseURL: baseURL, encryptionService: noOpEncryptionService())
let imageURL = baseURL.appendingPathComponent("Copied Image.png")
let imageData = try XCTUnwrap(makeImage(color: .systemOrange).pngData())
try imageData.write(to: imageURL)
let item = fileItem(path: imageURL.path)
XCTAssertNotNil(cacheService.previewThumbnail(for: item))
}
func testPreviewThumbnailUsesManagedPDFPreviewFallback() throws {
let baseURL = try makeTempDirectory()
let cacheService = ClipboardCacheService(baseURL: baseURL, encryptionService: fixedEncryptionService())
let pdfData = try makePDFData()
let path = try XCTUnwrap(cacheService.cachePDF(pdfData, id: UUID()))
XCTAssertNotNil(cacheService.previewThumbnail(for: pdfItem(path: path)))
}
func testPDFCacheFilesAreEncryptedAndReadable() throws {
let baseURL = try makeTempDirectory()
let cacheService = ClipboardCacheService(baseURL: baseURL, encryptionService: fixedEncryptionService())
let pdfData = Data("%PDF-1.4\nclipbored\n%%EOF".utf8)
let path = try XCTUnwrap(cacheService.cachePDF(pdfData, id: UUID()))
let rawPDF = try Data(contentsOf: URL(fileURLWithPath: path))
XCTAssertTrue(ClipboardEncryptionService.isProtected(rawPDF))
XCTAssertNotEqual(rawPDF, pdfData)
XCTAssertEqual(cacheService.data(for: path), pdfData)
XCTAssertEqual(try posixPermissions(URL(fileURLWithPath: path)), 0o600)
}
func testAudioCacheFilesAreEncryptedAndReadable() throws {
let baseURL = try makeTempDirectory()
let cacheService = ClipboardCacheService(baseURL: baseURL, encryptionService: fixedEncryptionService())
let audioData = Data([0, 1, 2, 3, 5, 8, 13])
let path = try XCTUnwrap(cacheService.cacheAudio(audioData, id: UUID()))
let rawAudio = try Data(contentsOf: URL(fileURLWithPath: path))
XCTAssertTrue(ClipboardEncryptionService.isProtected(rawAudio))
XCTAssertNotEqual(rawAudio, audioData)
XCTAssertEqual(cacheService.data(for: path), audioData)
XCTAssertEqual(try posixPermissions(URL(fileURLWithPath: path)), 0o600)
}
func testRichTextCacheFilesAreEncryptedAndReadable() throws {
let baseURL = try makeTempDirectory()
let cacheService = ClipboardCacheService(baseURL: baseURL, encryptionService: fixedEncryptionService())
let rtfData = Data("{\\rtf1\\ansi ClipBored}".utf8)
let path = try XCTUnwrap(cacheService.cacheRichText(rtfData, id: UUID()))
let rawRTF = try Data(contentsOf: URL(fileURLWithPath: path))
XCTAssertTrue(ClipboardEncryptionService.isProtected(rawRTF))
XCTAssertNotEqual(rawRTF, rtfData)
XCTAssertEqual(cacheService.data(for: path), rtfData)
XCTAssertEqual(try posixPermissions(URL(fileURLWithPath: path)), 0o600)
}
func testLegacyManagedSidecarIsEncryptedAfterRead() throws {
let baseURL = try makeTempDirectory()
let cacheService = ClipboardCacheService(baseURL: baseURL, encryptionService: fixedEncryptionService())
let attachmentDirectory = baseURL.appendingPathComponent("attachments", isDirectory: true)
try FileManager.default.createDirectory(at: attachmentDirectory, withIntermediateDirectories: true)
let url = attachmentDirectory.appendingPathComponent("\(UUID().uuidString).pdf")
let pdfData = Data("%PDF-1.4\nlegacy\n%%EOF".utf8)
try pdfData.write(to: url)
XCTAssertEqual(cacheService.data(for: url.path), pdfData)
let migrated = try Data(contentsOf: url)
XCTAssertTrue(ClipboardEncryptionService.isProtected(migrated))
XCTAssertFalse(String(decoding: migrated, as: UTF8.self).contains("legacy"))
}
func testTemporaryReadableURLWritesPrivateCopyAndCleanupRemovesIt() throws {
let baseURL = try makeTempDirectory()
let cacheService = ClipboardCacheService(baseURL: baseURL, encryptionService: fixedEncryptionService())
let pdfData = Data("%PDF-1.4\ntemporary preview\n%%EOF".utf8)
let path = try XCTUnwrap(cacheService.cachePDF(pdfData, id: UUID()))
let previewURL = try XCTUnwrap(cacheService.temporaryReadableURL(for: pdfItem(path: path)))
XCTAssertEqual(try Data(contentsOf: previewURL), pdfData)
XCTAssertEqual(try posixPermissions(previewURL.deletingLastPathComponent()), 0o700)
XCTAssertEqual(try posixPermissions(previewURL), 0o600)
cacheService.clearTemporaryPreviews(wait: true)
XCTAssertFalse(FileManager.default.fileExists(atPath: previewURL.path))
}
func testTemporaryReadableURLWorksForAudio() throws {
let baseURL = try makeTempDirectory()
let cacheService = ClipboardCacheService(baseURL: baseURL, encryptionService: fixedEncryptionService())
let audioData = Data([9, 8, 7, 6])
let path = try XCTUnwrap(cacheService.cacheAudio(audioData, id: UUID()))
let previewURL = try XCTUnwrap(cacheService.temporaryReadableURL(for: audioItem(path: path)))
XCTAssertEqual(try Data(contentsOf: previewURL), audioData)
XCTAssertEqual(previewURL.pathExtension, "sound")
XCTAssertEqual(try posixPermissions(previewURL), 0o600)
}
func testTemporaryReadableURLWorksForRichText() throws {
let baseURL = try makeTempDirectory()
let cacheService = ClipboardCacheService(baseURL: baseURL, encryptionService: fixedEncryptionService())
let rtfData = Data("{\\rtf1\\ansi Temporary Rich Text}".utf8)
let path = try XCTUnwrap(cacheService.cacheRichText(rtfData, id: UUID()))
let previewURL = try XCTUnwrap(cacheService.temporaryReadableURL(for: richTextItem(path: path)))
XCTAssertEqual(try Data(contentsOf: previewURL), rtfData)
XCTAssertEqual(previewURL.pathExtension, "rtf")
XCTAssertEqual(try posixPermissions(previewURL), 0o600)
}
private func makeTempDirectory() throws -> URL {
let url = FileManager.default.temporaryDirectory
.appendingPathComponent("clipboredtests", isDirectory: true)
.appendingPathComponent(UUID().uuidString, isDirectory: true)
try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
tempURLs.append(url)
return url
}
private func imageCacheFileURLs(in baseURL: URL) throws -> [URL] {
let imageDirectory = baseURL.appendingPathComponent("images", isDirectory: true)
return try FileManager.default.contentsOfDirectory(at: imageDirectory, includingPropertiesForKeys: nil)
}
private func pdfItem(path: String) -> ClipboardItem {
ClipboardItem(
id: UUID(),
kind: .pdf,
displayText: "PDF",
payload: path,
payloadHash: "hash",
createdAt: Date(),
lastUsedAt: Date(),
useCount: 0,
sourceApp: nil,
imagePath: nil,
thumbnailPath: nil
)
}
private func fileItem(path: String) -> ClipboardItem {
ClipboardItem(
id: UUID(),
kind: .file,
displayText: "File",
payload: path,
payloadHash: "hash",
createdAt: Date(),
lastUsedAt: Date(),
useCount: 0,
sourceApp: nil,
imagePath: nil,
thumbnailPath: nil
)
}
private func audioItem(path: String) -> ClipboardItem {
ClipboardItem(
id: UUID(),
kind: .audio,
displayText: "Audio",
payload: path,
payloadHash: "hash",
createdAt: Date(),
lastUsedAt: Date(),
useCount: 0,
sourceApp: nil,
imagePath: nil,
thumbnailPath: nil
)
}
private func richTextItem(path: String) -> ClipboardItem {
ClipboardItem(
id: UUID(),
kind: .richText,
displayText: "Rich Text",
payload: path,
payloadHash: "hash",
createdAt: Date(),
lastUsedAt: Date(),
useCount: 0,
sourceApp: nil,
imagePath: nil,
thumbnailPath: nil
)
}
private func temporaryPreviewRoot() -> URL {
FileManager.default.temporaryDirectory.appendingPathComponent(AppConfiguration.appName, isDirectory: true)
}
private func makeImage(color: NSColor) -> NSImage {
let size = NSSize(width: 24, height: 24)
let image = NSImage(size: size)
image.lockFocus()
color.setFill()
NSRect(origin: .zero, size: size).fill()
image.unlockFocus()
return image
}
private func makePDFData() throws -> Data {
let data = NSMutableData()
guard let consumer = CGDataConsumer(data: data as CFMutableData) else {
throw NSError(domain: "ClipBoredTests", code: 1)
}
var box = CGRect(x: 0, y: 0, width: 160, height: 120)
guard let context = CGContext(consumer: consumer, mediaBox: &box, nil) else {
throw NSError(domain: "ClipBoredTests", code: 2)
}
context.beginPDFPage(nil)
context.setFillColor(NSColor.systemOrange.cgColor)
context.fill(CGRect(x: 0, y: 0, width: 160, height: 120))
context.setFillColor(NSColor.systemBlue.cgColor)
context.fillEllipse(in: CGRect(x: 45, y: 30, width: 70, height: 60))
context.endPDFPage()
context.closePDF()
return data as Data
}
private func posixPermissions(_ url: URL) throws -> Int {
let attributes = try FileManager.default.attributesOfItem(atPath: url.path)
return try XCTUnwrap(attributes[.posixPermissions] as? Int) & 0o777
}
private func noOpEncryptionService() -> ClipboardEncryptionService {
ClipboardEncryptionService(keyProvider: { nil })
}
private func fixedEncryptionService(byte: UInt8 = 7) -> ClipboardEncryptionService {
let keyData = Data(repeating: byte, count: 32)
return ClipboardEncryptionService(keyProvider: { SymmetricKey(data: keyData) })
}
}

View File

@@ -0,0 +1,73 @@
import CryptoKit
import XCTest
@testable import ClipBored
final class ClipboardEncryptionServiceTests: XCTestCase {
func testProtectAndUnprotectRoundTrip() throws {
let service = makeService(byte: 7)
let value = "secret clipboard value \(UUID().uuidString)"
let protected = try XCTUnwrap(service.protect(value))
XCTAssertTrue(ClipboardEncryptionService.isProtected(protected))
XCTAssertFalse(protected.contains(value))
XCTAssertEqual(service.unprotect(protected), value)
}
func testUnprotectLeavesPlaintextValuesUntouched() {
let service = makeService(byte: 7)
let value = "plain clipboard value"
XCTAssertEqual(service.unprotect(value), value)
}
func testMarkerLookingPlaintextIsStillEncryptable() throws {
let service = makeService(byte: 7)
let value = ClipboardEncryptionService.marker + "not encrypted user text"
let protected = try XCTUnwrap(service.protect(value))
XCTAssertNotEqual(protected, value)
XCTAssertEqual(service.unprotect(protected), value)
XCTAssertEqual(service.unprotect(value), value)
}
func testWrongKeyCannotDecryptProtectedValue() throws {
let service = makeService(byte: 7)
let wrongService = makeService(byte: 9)
let protected = try XCTUnwrap(service.protect("keyed secret"))
XCTAssertNil(wrongService.unprotect(protected))
}
func testProtectDataAndUnprotectDataRoundTrip() throws {
let service = makeService(byte: 7)
let data = Data((0..<128).map { UInt8($0) })
let protected = service.protectData(data)
XCTAssertTrue(ClipboardEncryptionService.isProtected(protected))
XCTAssertNotEqual(protected, data)
XCTAssertEqual(service.unprotectData(protected), data)
}
func testWrongKeyCannotDecryptProtectedData() {
let service = makeService(byte: 7)
let wrongService = makeService(byte: 9)
let protected = service.protectData(Data("binary secret".utf8))
XCTAssertNil(wrongService.unprotectData(protected))
}
func testProtectFallsBackToPlaintextWhenKeyIsUnavailable() {
let service = ClipboardEncryptionService(keyProvider: { nil })
XCTAssertEqual(service.protect("available only in memory"), "available only in memory")
XCTAssertEqual(service.protectData(Data("available only in memory".utf8)), Data("available only in memory".utf8))
}
private func makeService(byte: UInt8) -> ClipboardEncryptionService {
let keyData = Data(repeating: byte, count: 32)
return ClipboardEncryptionService(keyProvider: { SymmetricKey(data: keyData) })
}
}

View File

@@ -0,0 +1,722 @@
import XCTest
import Foundation
import AppKit
@testable import ClipBored
final class ClipboardMonitorServiceTests: XCTestCase {
private var tempURLs: [URL] = []
private var suiteNames: [String] = []
override func tearDown() {
tempURLs.forEach { try? FileManager.default.removeItem(at: $0) }
tempURLs.removeAll()
suiteNames.forEach {
UserDefaults(suiteName: $0)?.removePersistentDomain(forName: $0)
}
suiteNames.removeAll()
super.tearDown()
}
func testClampedIntervalEnforcesResponsiveMinimum() {
let settings = SettingsModel(defaults: makeTestDefaults())
settings.pollProfile = .responsive
let monitor = ClipboardMonitorService(
store: makeStore(settings: settings),
cacheService: ClipboardCacheService(),
settings: settings
)
XCTAssertEqual(monitor.clampedInterval(0.03), AppConfiguration.minResponsiveActiveInterval)
XCTAssertEqual(monitor.clampedInterval(0.05), AppConfiguration.minResponsiveActiveInterval)
XCTAssertEqual(monitor.clampedInterval(0.075), AppConfiguration.minResponsiveActiveInterval)
XCTAssertEqual(monitor.clampedInterval(0.2), 0.2)
}
func testClampedIntervalDoesNotIncreaseBalancedProfileWindow() {
let settings = SettingsModel(defaults: makeTestDefaults())
settings.pollProfile = .balanced
let monitor = ClipboardMonitorService(
store: makeStore(settings: settings),
cacheService: ClipboardCacheService(),
settings: settings
)
XCTAssertEqual(monitor.clampedInterval(settings.pollProfile.activeInterval), settings.pollProfile.activeInterval)
XCTAssertGreaterThanOrEqual(monitor.clampedInterval(settings.pollProfile.idleInterval), settings.pollProfile.idleInterval)
}
func testPollProfileChangeEmitsDedicatedNotification() {
let settings = SettingsModel(defaults: makeTestDefaults())
var sawPollProfileChange = false
settings.observe { change in
if case .pollProfile = change {
sawPollProfileChange = true
}
}
settings.pollProfile = .responsive
XCTAssertTrue(sawPollProfileChange)
}
func testSetPausedReschedulesWithCurrentPollingProfile() {
let settings = SettingsModel(defaults: makeTestDefaults())
settings.pollProfile = .battery
let (store, cacheService) = makeStoreAndCache(settings: settings)
let monitor = ClipboardMonitorService(store: store, cacheService: cacheService, settings: settings)
monitor.setPaused(false)
XCTAssertEqual(monitor.scheduledIntervalForTesting, settings.pollProfile.idleInterval)
settings.pollProfile = .responsive
monitor.setPaused(false)
XCTAssertEqual(monitor.scheduledIntervalForTesting, settings.pollProfile.idleInterval)
monitor.stop()
}
func testPollNowCapturesCopiedTextOnce() {
let settings = SettingsModel(defaults: makeTestDefaults())
settings.pruneDuplicates = false
let (store, cacheService) = makeStoreAndCache(settings: settings)
let monitor = ClipboardMonitorService(
store: store,
cacheService: cacheService,
settings: settings
)
let text = "ClipBored monitor smoke \(UUID().uuidString)"
let captured = expectation(description: "copied text captured")
store.observeItems { items in
if items.contains(where: { $0.payload == text }) {
captured.fulfill()
}
}
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
XCTAssertTrue(pasteboard.setString(text, forType: .string))
monitor.pollNowAndWait()
wait(for: [captured], timeout: 1.0)
monitor.pollNowAndWait()
RunLoop.main.run(until: Date().addingTimeInterval(0.05))
XCTAssertEqual(store.items.filter { $0.payload == text }.count, 1)
}
func testPollNowIgnoresClipBoredPasteboardWrites() {
let settings = SettingsModel(defaults: makeTestDefaults())
let (store, cacheService) = makeStoreAndCache(settings: settings)
let monitor = ClipboardMonitorService(store: store, cacheService: cacheService, settings: settings)
let item = ClipboardItem(
id: UUID(),
kind: .text,
displayText: "Internal copy",
payload: "Internal copy \(UUID().uuidString)",
payloadHash: "hash",
createdAt: Date(),
lastUsedAt: Date(),
useCount: 0,
sourceApp: nil,
imagePath: nil,
thumbnailPath: nil
)
XCTAssertEqual(PasteActionService().copy(item), .copied)
monitor.pollNowAndWait()
RunLoop.main.run(until: Date().addingTimeInterval(0.05))
XCTAssertTrue(store.items.isEmpty)
}
func testPollNowCapturesPDFAsRestorableAttachment() throws {
let settings = SettingsModel(defaults: makeTestDefaults())
let (store, cacheService) = makeStoreAndCache(settings: settings)
let monitor = ClipboardMonitorService(store: store, cacheService: cacheService, settings: settings)
let pdfData = Data("%PDF-1.4\nclipbored\n%%EOF".utf8)
let captured = expectation(description: "PDF captured")
store.observeItems { items in
if items.contains(where: { $0.kind == .pdf }) {
captured.fulfill()
}
}
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
XCTAssertTrue(pasteboard.setData(pdfData, forType: .pdf))
monitor.pollNowAndWait()
wait(for: [captured], timeout: 1.0)
let item = try XCTUnwrap(store.items.first(where: { $0.kind == .pdf }))
XCTAssertTrue(FileManager.default.fileExists(atPath: item.payload))
XCTAssertEqual(cacheService.data(for: item.payload), pdfData)
XCTAssertEqual(PasteActionService(cacheService: cacheService).copy(item), .copied)
XCTAssertEqual(NSPasteboard.general.data(forType: .pdf), pdfData)
}
func testPollNowCapturesAudioAsRestorableAttachment() throws {
let settings = SettingsModel(defaults: makeTestDefaults())
let (store, cacheService) = makeStoreAndCache(settings: settings)
let monitor = ClipboardMonitorService(store: store, cacheService: cacheService, settings: settings)
let audioData = Data([1, 2, 3, 4, 8, 16])
let captured = expectation(description: "audio captured")
store.observeItems { items in
if items.contains(where: { $0.kind == .audio }) {
captured.fulfill()
}
}
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
XCTAssertTrue(pasteboard.setData(audioData, forType: .sound))
monitor.pollNowAndWait()
wait(for: [captured], timeout: 1.0)
let item = try XCTUnwrap(store.items.first(where: { $0.kind == .audio }))
XCTAssertTrue(FileManager.default.fileExists(atPath: item.payload))
XCTAssertEqual(cacheService.data(for: item.payload), audioData)
XCTAssertEqual(PasteActionService(cacheService: cacheService).copy(item), .copied)
XCTAssertEqual(NSPasteboard.general.data(forType: .sound), audioData)
}
func testPollNowCapturesFileReference() throws {
let settings = SettingsModel(defaults: makeTestDefaults())
let (store, cacheService) = makeStoreAndCache(settings: settings)
let monitor = ClipboardMonitorService(store: store, cacheService: cacheService, settings: settings)
let fileURL = try makeTempFile(contents: "file reference")
let captured = expectation(description: "file reference captured")
store.observeItems { items in
if items.contains(where: { $0.kind == .file && $0.payload == fileURL.path }) {
captured.fulfill()
}
}
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
XCTAssertTrue(pasteboard.writeObjects([fileURL as NSURL]))
monitor.pollNowAndWait()
wait(for: [captured], timeout: 1.0)
XCTAssertEqual(store.items.first?.kind, .file)
XCTAssertEqual(store.items.first?.payload, fileURL.path)
}
func testPollNowCapturesMultipleFileReferencesAsOneRestorableItem() throws {
let settings = SettingsModel(defaults: makeTestDefaults())
let (store, cacheService) = makeStoreAndCache(settings: settings)
let monitor = ClipboardMonitorService(store: store, cacheService: cacheService, settings: settings)
let firstURL = try makeTempFile(contents: "first file reference")
let secondURL = try makeTempFile(contents: "second file reference")
let payload = FilePayload.payload(from: [firstURL, secondURL])
let captured = expectation(description: "multiple file references captured")
store.observeItems { items in
if items.contains(where: { $0.kind == .file && $0.payload == payload }) {
captured.fulfill()
}
}
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
XCTAssertTrue(pasteboard.writeObjects([firstURL as NSURL, secondURL as NSURL]))
monitor.pollNowAndWait()
wait(for: [captured], timeout: 1.0)
let item = try XCTUnwrap(store.items.first)
XCTAssertEqual(item.kind, .file)
XCTAssertEqual(item.displayText, "2 files")
XCTAssertEqual(item.payload, payload)
XCTAssertEqual(PasteActionService(cacheService: cacheService).copy(item), .copied)
let objects = NSPasteboard.general.readObjects(forClasses: [NSURL.self], options: nil) as? [URL]
XCTAssertEqual(objects?.map(\.standardizedFileURL), [firstURL.standardizedFileURL, secondURL.standardizedFileURL])
}
func testPollNowPrefersFileReferenceOverStringFallback() throws {
let settings = SettingsModel(defaults: makeTestDefaults())
let (store, cacheService) = makeStoreAndCache(settings: settings)
let monitor = ClipboardMonitorService(store: store, cacheService: cacheService, settings: settings)
let fileURL = try makeTempFile(contents: "file reference with fallback")
let captured = expectation(description: "file reference captured")
store.observeItems { items in
if items.contains(where: { $0.kind == .file && $0.payload == fileURL.path }) {
captured.fulfill()
}
}
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
XCTAssertTrue(pasteboard.writeObjects([fileURL as NSURL]))
XCTAssertTrue(pasteboard.setString(fileURL.path, forType: .string))
monitor.pollNowAndWait()
wait(for: [captured], timeout: 1.0)
XCTAssertEqual(store.items.first?.kind, .file)
XCTAssertEqual(store.items.first?.payload, fileURL.path)
}
func testPollNowCapturesBareURLAsLink() throws {
let settings = SettingsModel(defaults: makeTestDefaults())
let (store, cacheService) = makeStoreAndCache(settings: settings)
let monitor = ClipboardMonitorService(store: store, cacheService: cacheService, settings: settings)
let url = "https://example.com/releases"
let captured = expectation(description: "URL captured")
store.observeItems { items in
if items.contains(where: { $0.kind == .url && $0.payload == url }) {
captured.fulfill()
}
}
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
XCTAssertTrue(pasteboard.setString(url, forType: .string))
monitor.pollNowAndWait()
wait(for: [captured], timeout: 1.0)
XCTAssertEqual(store.items.first?.kind, .url)
XCTAssertEqual(store.items.first?.payload, url)
}
func testPollNowUsesPasteboardURLNameAsLinkTitle() throws {
let settings = SettingsModel(defaults: makeTestDefaults())
let (store, cacheService) = makeStoreAndCache(settings: settings)
let monitor = ClipboardMonitorService(store: store, cacheService: cacheService, settings: settings)
let url = "https://example.com/releases"
let title = "Release notes"
let captured = expectation(description: "URL with title captured")
store.observeItems { items in
if items.contains(where: { $0.kind == .url && $0.payload == url && $0.displayText == title }) {
captured.fulfill()
}
}
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
XCTAssertTrue(pasteboard.setString(url, forType: .URL))
XCTAssertTrue(pasteboard.setString(title, forType: NSPasteboard.PasteboardType(rawValue: "public.url-name")))
XCTAssertTrue(pasteboard.setString(url, forType: .string))
monitor.pollNowAndWait()
wait(for: [captured], timeout: 1.0)
XCTAssertEqual(store.items.first?.kind, .url)
XCTAssertEqual(store.items.first?.displayText, title)
XCTAssertEqual(store.items.first?.payload, url)
}
func testPollNowUsesHTMLAnchorTextAsLinkTitle() throws {
let settings = SettingsModel(defaults: makeTestDefaults())
let (store, cacheService) = makeStoreAndCache(settings: settings)
let monitor = ClipboardMonitorService(store: store, cacheService: cacheService, settings: settings)
let url = "https://example.com/releases"
let title = "Read the release notes"
let html = "<a href=\"\(url)\">\(title)</a>"
let captured = expectation(description: "URL with HTML title captured")
store.observeItems { items in
if items.contains(where: { $0.kind == .url && $0.payload == url && $0.displayText == title }) {
captured.fulfill()
}
}
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
XCTAssertTrue(pasteboard.setString(url, forType: .URL))
XCTAssertTrue(pasteboard.setString(html, forType: .html))
XCTAssertTrue(pasteboard.setString(url, forType: .string))
monitor.pollNowAndWait()
wait(for: [captured], timeout: 1.0)
XCTAssertEqual(store.items.first?.kind, .url)
XCTAssertEqual(store.items.first?.displayText, title)
XCTAssertEqual(store.items.first?.payload, url)
}
func testPollNowCapturesURLWithLocalImagePreviewAsLink() throws {
let settings = SettingsModel(defaults: makeTestDefaults())
let (store, cacheService) = makeStoreAndCache(settings: settings)
let monitor = ClipboardMonitorService(store: store, cacheService: cacheService, settings: settings)
let url = "https://example.com/lookbook"
let title = "Lookbook"
let preview = makeImage(color: .systemTeal)
let captured = expectation(description: "URL with local preview captured")
store.observeItems { items in
if let item = items.first, item.kind == .url, item.payload == url, item.thumbnailPath != nil {
captured.fulfill()
}
}
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
XCTAssertTrue(pasteboard.setString(url, forType: .URL))
XCTAssertTrue(pasteboard.setString(title, forType: NSPasteboard.PasteboardType(rawValue: "public.url-name")))
XCTAssertTrue(pasteboard.setData(try XCTUnwrap(preview.tiffRepresentation), forType: .tiff))
XCTAssertTrue(pasteboard.setString(url, forType: .string))
monitor.pollNowAndWait()
wait(for: [captured], timeout: 1.0)
cacheService.flushForTesting()
let item = try XCTUnwrap(store.items.first)
XCTAssertEqual(item.kind, .url)
XCTAssertEqual(item.displayText, title)
XCTAssertNotNil(item.imagePath)
XCTAssertNotNil(item.thumbnailPath)
XCTAssertNotNil(cacheService.previewThumbnail(for: item))
}
func testPollNowCapturesImageWithRecognizedTextWhenSearchIsEnabled() throws {
let settings = SettingsModel(defaults: makeTestDefaults())
settings.includeImageTextInSearch = true
let (store, cacheService) = makeStoreAndCache(settings: settings)
let monitor = ClipboardMonitorService(
store: store,
cacheService: cacheService,
settings: settings,
imageTextExtractor: { _ in " Receipt total $42\nOrder 1001 " }
)
let image = makeImage(color: .systemOrange)
let captured = expectation(description: "image with recognized text captured")
store.observeItems { items in
if items.contains(where: { $0.kind == .image && $0.ocrText == "Receipt total $42 Order 1001" }) {
captured.fulfill()
}
}
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
XCTAssertTrue(pasteboard.writeObjects([image]))
monitor.pollNowAndWait()
wait(for: [captured], timeout: 1.0)
let item = try XCTUnwrap(store.items.first)
XCTAssertEqual(item.kind, .image)
XCTAssertEqual(item.ocrText, "Receipt total $42 Order 1001")
XCTAssertTrue(FileManager.default.fileExists(atPath: item.payload))
}
func testPollNowSkipsImageTextExtractionWhenSearchIsDisabled() throws {
let settings = SettingsModel(defaults: makeTestDefaults())
settings.includeImageTextInSearch = false
let (store, cacheService) = makeStoreAndCache(settings: settings)
var extractionCount = 0
let monitor = ClipboardMonitorService(
store: store,
cacheService: cacheService,
settings: settings,
imageTextExtractor: { _ in
extractionCount += 1
return "Should not be captured"
}
)
let image = makeImage(color: .systemPurple)
let captured = expectation(description: "image captured without recognized text")
store.observeItems { items in
if items.contains(where: { $0.kind == .image }) {
captured.fulfill()
}
}
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
XCTAssertTrue(pasteboard.writeObjects([image]))
monitor.pollNowAndWait()
wait(for: [captured], timeout: 1.0)
let item = try XCTUnwrap(store.items.first)
XCTAssertEqual(item.kind, .image)
XCTAssertNil(item.ocrText)
XCTAssertEqual(extractionCount, 0)
}
func testIgnoredFileSettingDoesNotBlockWebURLObjects() throws {
let settings = SettingsModel(defaults: makeTestDefaults())
settings.ignoredItemKindsRaw = [ClipboardItemKind.file.rawValue]
let (store, cacheService) = makeStoreAndCache(settings: settings)
let monitor = ClipboardMonitorService(store: store, cacheService: cacheService, settings: settings)
let url = "https://example.com/releases"
let captured = expectation(description: "web URL captured when files are ignored")
store.observeItems { items in
if items.contains(where: { $0.kind == .url && $0.payload == url }) {
captured.fulfill()
}
}
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
XCTAssertTrue(pasteboard.writeObjects([NSURL(string: url)!]))
monitor.pollNowAndWait()
wait(for: [captured], timeout: 1.0)
XCTAssertEqual(store.items.first?.kind, .url)
XCTAssertEqual(store.items.first?.payload, url)
}
func testPollNowKeepsTextContainingURLAsText() throws {
let settings = SettingsModel(defaults: makeTestDefaults())
let (store, cacheService) = makeStoreAndCache(settings: settings)
let monitor = ClipboardMonitorService(store: store, cacheService: cacheService, settings: settings)
let text = "Review https://example.com/releases before shipping"
let captured = expectation(description: "text with URL captured")
store.observeItems { items in
if items.contains(where: { $0.kind == .text && $0.payload == text }) {
captured.fulfill()
}
}
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
XCTAssertTrue(pasteboard.setString(text, forType: .string))
monitor.pollNowAndWait()
wait(for: [captured], timeout: 1.0)
XCTAssertEqual(store.items.first?.kind, .text)
XCTAssertEqual(store.items.first?.payload, text)
}
func testPollNowPrefersRichTextOverPlainStringFallback() throws {
let settings = SettingsModel(defaults: makeTestDefaults())
let (store, cacheService) = makeStoreAndCache(settings: settings)
let monitor = ClipboardMonitorService(store: store, cacheService: cacheService, settings: settings)
let text = "Rich clipboard text"
let attributed = NSAttributedString(
string: text,
attributes: [.font: NSFont.boldSystemFont(ofSize: 13)]
)
let rtfData = try XCTUnwrap(
attributed.rtf(from: NSRange(location: 0, length: attributed.length), documentAttributes: [:])
)
let captured = expectation(description: "rich text captured")
store.observeItems { items in
if items.contains(where: { $0.kind == .richText && $0.displayText == text }) {
captured.fulfill()
}
}
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
XCTAssertTrue(pasteboard.setData(rtfData, forType: .rtf))
XCTAssertTrue(pasteboard.setString(text, forType: .string))
monitor.pollNowAndWait()
wait(for: [captured], timeout: 1.0)
XCTAssertEqual(store.items.first?.kind, .richText)
let item = try XCTUnwrap(store.items.first)
XCTAssertEqual(item.displayText, text)
XCTAssertTrue(FileManager.default.fileExists(atPath: item.payload))
XCTAssertEqual(cacheService.data(for: item.payload), rtfData)
XCTAssertEqual(PasteActionService(cacheService: cacheService).copy(item), .copied)
XCTAssertEqual(NSPasteboard.general.data(forType: .rtf), rtfData)
XCTAssertEqual(NSPasteboard.general.string(forType: .string), text)
}
func testPollNowCapturesHTMLClipboardDataAsRestorableRichText() throws {
let settings = SettingsModel(defaults: makeTestDefaults())
let (store, cacheService) = makeStoreAndCache(settings: settings)
let monitor = ClipboardMonitorService(store: store, cacheService: cacheService, settings: settings)
let text = "Styled HTML clipboard text"
let html = """
<span style="font-weight: 700; color: #0a84ff;">Styled HTML</span> clipboard text
"""
let htmlData = Data(html.utf8)
let captured = expectation(description: "HTML rich text captured")
store.observeItems { items in
if items.contains(where: { $0.kind == .richText && $0.displayText == text }) {
captured.fulfill()
}
}
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
XCTAssertTrue(pasteboard.setData(htmlData, forType: .html))
XCTAssertTrue(pasteboard.setString(text, forType: .string))
monitor.pollNowAndWait()
wait(for: [captured], timeout: 1.0)
let item = try XCTUnwrap(store.items.first)
XCTAssertEqual(item.kind, .richText)
XCTAssertEqual(item.displayText, text)
XCTAssertTrue(FileManager.default.fileExists(atPath: item.payload))
let cachedRTF = try XCTUnwrap(cacheService.data(for: item.payload))
XCTAssertNotNil(NSAttributedString(rtf: cachedRTF, documentAttributes: nil))
XCTAssertEqual(PasteActionService(cacheService: cacheService).copy(item), .copied)
XCTAssertNotNil(NSPasteboard.general.data(forType: .rtf))
XCTAssertEqual(NSPasteboard.general.string(forType: .string), text)
}
func testIgnoredImageKindDoesNotWriteCacheFiles() throws {
let settings = SettingsModel(defaults: makeTestDefaults())
settings.ignoredItemKindsRaw = [ClipboardItemKind.image.rawValue]
let (store, cacheService, baseURL) = makeStoreCacheAndBaseURL(settings: settings)
let monitor = ClipboardMonitorService(store: store, cacheService: cacheService, settings: settings)
let image = makeImage(color: .systemOrange)
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
XCTAssertTrue(pasteboard.writeObjects([image]))
monitor.pollNowAndWait()
RunLoop.main.run(until: Date().addingTimeInterval(0.05))
cacheService.flushForTesting()
XCTAssertTrue(store.items.isEmpty)
XCTAssertTrue(try imageCacheFileURLs(in: baseURL).isEmpty)
}
func testIgnoredPDFKindDoesNotWriteAttachmentFiles() throws {
let settings = SettingsModel(defaults: makeTestDefaults())
settings.ignoredItemKindsRaw = [ClipboardItemKind.pdf.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("%PDF-1.4\n%%EOF".utf8), forType: .pdf))
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: PDF items are ignored in capture settings.")
}
func testIgnoredAudioKindDoesNotWriteAttachmentFiles() throws {
let settings = SettingsModel(defaults: makeTestDefaults())
settings.ignoredItemKindsRaw = [ClipboardItemKind.audio.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([3, 1, 4, 1, 5]), forType: .sound))
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: Audio items are ignored in capture settings.")
}
func testIgnoredRichTextKindDoesNotWriteHTMLAttachmentFiles() throws {
let settings = SettingsModel(defaults: makeTestDefaults())
settings.ignoredItemKindsRaw = [ClipboardItemKind.richText.rawValue]
let (store, cacheService, baseURL) = makeStoreCacheAndBaseURL(settings: settings)
let monitor = ClipboardMonitorService(store: store, cacheService: cacheService, settings: settings)
let text = "Ignored HTML clipboard text"
let html = "<strong>Ignored HTML</strong> clipboard text"
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
XCTAssertTrue(pasteboard.setData(Data(html.utf8), forType: .html))
XCTAssertTrue(pasteboard.setString(text, forType: .string))
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: Rich Text items are ignored in capture settings.")
}
private func makeTestDefaults() -> UserDefaults {
let suiteName = "com.clipbored.testmonitor.\(UUID().uuidString)"
suiteNames.append(suiteName)
return UserDefaults(suiteName: suiteName)!
}
private func makeStore(settings: SettingsModel) -> ClipboardStore {
makeStoreAndCache(settings: settings).store
}
private func makeStoreAndCache(settings: SettingsModel) -> (store: ClipboardStore, cacheService: ClipboardCacheService) {
let result = makeStoreCacheAndBaseURL(settings: settings)
return (result.store, result.cacheService)
}
private func makeStoreCacheAndBaseURL(settings: SettingsModel) -> (store: ClipboardStore, cacheService: ClipboardCacheService, baseURL: URL) {
let baseURL = FileManager.default.temporaryDirectory
.appendingPathComponent("clipboredtests", isDirectory: true)
.appendingPathComponent(UUID().uuidString, isDirectory: true)
try? FileManager.default.createDirectory(at: baseURL, withIntermediateDirectories: true)
tempURLs.append(baseURL)
let cacheService = ClipboardCacheService(
baseURL: baseURL,
encryptionService: ClipboardEncryptionService(keyProvider: { nil })
)
return (
ClipboardStore(
settings: settings,
cacheService: cacheService,
baseURL: baseURL,
encryptionService: ClipboardEncryptionService(keyProvider: { nil })
),
cacheService,
baseURL
)
}
private func makeTempFile(contents: String) throws -> URL {
let baseURL = FileManager.default.temporaryDirectory
.appendingPathComponent("clipboredtests", isDirectory: true)
.appendingPathComponent(UUID().uuidString, isDirectory: true)
try FileManager.default.createDirectory(at: baseURL, withIntermediateDirectories: true)
tempURLs.append(baseURL)
let fileURL = baseURL.appendingPathComponent("payload.txt")
try contents.write(to: fileURL, atomically: true, encoding: .utf8)
return fileURL
}
private func imageCacheFileURLs(in baseURL: URL) throws -> [URL] {
let imageDirectory = baseURL.appendingPathComponent("images", isDirectory: true)
return try FileManager.default.contentsOfDirectory(at: imageDirectory, includingPropertiesForKeys: nil)
}
private func attachmentFileURLs(in baseURL: URL) throws -> [URL] {
let attachmentDirectory = baseURL.appendingPathComponent("attachments", isDirectory: true)
return try FileManager.default.contentsOfDirectory(at: attachmentDirectory, includingPropertiesForKeys: nil)
}
private func makeImage(color: NSColor) -> NSImage {
let size = NSSize(width: 24, height: 24)
let image = NSImage(size: size)
image.lockFocus()
color.setFill()
NSRect(origin: .zero, size: size).fill()
image.unlockFocus()
return image
}
}

View File

@@ -0,0 +1,147 @@
import XCTest
@testable import ClipBored
final class ClipboardPanelControllerTests: XCTestCase {
func testPanelFrameUsesFullWidthBottomShelf() {
let screenFrame = CGRect(x: -1200, y: -200, width: 1200, height: 800)
let frames = ClipboardPanelController.panelFrames(forScreenFrame: screenFrame, visibleFrame: screenFrame)
XCTAssertEqual(frames.shown.minX, screenFrame.minX)
XCTAssertEqual(frames.shown.maxX, screenFrame.maxX)
XCTAssertEqual(frames.shown.minY, screenFrame.minY)
XCTAssertEqual(frames.shown.height, 408)
XCTAssertLessThan(frames.hidden.maxY, screenFrame.minY)
}
func testPanelFrameUsesVisibleFrameAroundDock() {
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)
XCTAssertEqual(frames.shown.minX, visibleFrame.minX)
XCTAssertEqual(frames.shown.maxX, visibleFrame.maxX)
XCTAssertEqual(frames.shown.minY, visibleFrame.minY)
XCTAssertEqual(frames.shown.height, 408)
}
func testPanelFrameSitsAboveVisibleBottomDock() {
let screenFrame = CGRect(x: 0, y: 0, width: 1512, height: 982)
let visibleFrame = CGRect(x: 0, y: 96, width: 1512, height: 861)
let frames = ClipboardPanelController.panelFrames(forScreenFrame: screenFrame, visibleFrame: visibleFrame)
XCTAssertEqual(frames.shown.minX, visibleFrame.minX)
XCTAssertEqual(frames.shown.maxX, visibleFrame.maxX)
XCTAssertEqual(frames.shown.minY, visibleFrame.minY)
XCTAssertEqual(frames.shown.height, 408)
XCTAssertLessThan(frames.hidden.maxY, visibleFrame.minY)
}
func testPanelFrameClampsTallDisplaysToShelfMaximum() {
let screenFrame = CGRect(x: 0, y: 0, width: 3008, height: 2000)
let frames = ClipboardPanelController.panelFrames(forScreenFrame: screenFrame, visibleFrame: screenFrame)
XCTAssertEqual(frames.shown.width, 3008)
XCTAssertEqual(frames.shown.height, 430)
XCTAssertEqual(frames.shown.minY, screenFrame.minY)
}
func testPanelFrameFitsTinyVisibleFrameWithoutOverflowing() {
let screenFrame = CGRect(x: 0, y: 0, width: 640, height: 320)
let visibleFrame = CGRect(x: 0, y: 48, width: 640, height: 220)
let frames = ClipboardPanelController.panelFrames(forScreenFrame: screenFrame, visibleFrame: visibleFrame)
XCTAssertEqual(frames.shown.minY, visibleFrame.minY)
XCTAssertEqual(frames.shown.height, visibleFrame.height)
XCTAssertLessThan(frames.hidden.maxY, visibleFrame.minY)
}
func testPanelFramePlanningIsDeterministicAcrossRepeatedToggles() {
let screenFrame = CGRect(x: -1512, y: -120, width: 1512, height: 982)
let visibleFrame = CGRect(x: -1512, y: -24, width: 1512, height: 861)
let first = ClipboardPanelController.panelFrames(forScreenFrame: screenFrame, visibleFrame: visibleFrame)
for _ in 0..<50 {
let frames = ClipboardPanelController.panelFrames(forScreenFrame: screenFrame, visibleFrame: visibleFrame)
XCTAssertEqual(frames.shown, first.shown)
XCTAssertEqual(frames.hidden, first.hidden)
XCTAssertEqual(frames.hidden.maxY, frames.shown.minY - 1)
}
}
func testPanelAnimationProfileStaysShortForSixtyFpsFeel() {
let profile = ClipboardPanelController.animationProfile
XCTAssertEqual(profile.showDuration, 0.16)
XCTAssertEqual(profile.hideDuration, 0.12)
XCTAssertEqual(profile.reflowDuration, 0.10)
XCTAssertLessThanOrEqual(profile.showDuration * 60, 10)
XCTAssertLessThanOrEqual(profile.hideDuration * 60, 8)
XCTAssertLessThanOrEqual(profile.reflowDuration * 60, 6)
XCTAssertEqual(profile.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 visibleFrame = CGRect(x: 0, y: 96, width: 1512, height: 861)
let inset = ClipboardPanelController.contentBottomInset(forScreenFrame: screenFrame, visibleFrame: visibleFrame)
XCTAssertEqual(inset, 20)
}
func testContentBottomInsetUsesMinimumWhenDockIsNotAtBottom() {
let screenFrame = CGRect(x: 0, y: 0, width: 1512, height: 982)
let visibleFrame = CGRect(x: 80, y: 0, width: 1432, height: 957)
let inset = ClipboardPanelController.contentBottomInset(forScreenFrame: screenFrame, visibleFrame: visibleFrame)
XCTAssertEqual(inset, 18)
}
func testCommandNumberShortcutsMapToCollections() {
XCTAssertEqual(ClipboardPanelController.collectionShortcutMode(forKeyCode: 18, modifiers: .command), .mostRecent)
XCTAssertEqual(ClipboardPanelController.collectionShortcutMode(forKeyCode: 19, modifiers: .command), .mostUsed)
XCTAssertEqual(ClipboardPanelController.collectionShortcutMode(forKeyCode: 20, modifiers: .command), .text)
XCTAssertEqual(ClipboardPanelController.collectionShortcutMode(forKeyCode: 21, modifiers: .command), .links)
XCTAssertEqual(ClipboardPanelController.collectionShortcutMode(forKeyCode: 23, modifiers: .command), .images)
XCTAssertEqual(ClipboardPanelController.collectionShortcutMode(forKeyCode: 22, modifiers: .command), .files)
XCTAssertEqual(ClipboardPanelController.collectionShortcutMode(forKeyCode: 26, modifiers: .command), .pinned)
XCTAssertEqual(ClipboardPanelController.collectionShortcutMode(forKeyCode: 28, modifiers: .command), .audio)
}
func testCollectionShortcutsRequireCommandOnlySoSearchTypingIsUntouched() {
XCTAssertNil(ClipboardPanelController.collectionShortcutMode(forKeyCode: 18, modifiers: []))
XCTAssertNil(ClipboardPanelController.collectionShortcutMode(forKeyCode: 18, modifiers: [.command, .shift]))
XCTAssertNil(ClipboardPanelController.collectionShortcutMode(forKeyCode: 29, modifiers: .command))
}
}

View File

@@ -0,0 +1,551 @@
import AppKit
import XCTest
@testable import ClipBored
final class ClipboardPanelViewModelTests: XCTestCase {
private var tempURLs: [URL] = []
override func tearDownWithError() throws {
for url in tempURLs {
try? FileManager.default.removeItem(at: url)
}
tempURLs.removeAll()
try super.tearDownWithError()
}
func testComputeVisibleItemsFiltersAndSortsByMode() {
let settings = makeSettings()
let store = makeStore(settings: settings)
let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: ClipboardCacheService())
let sampleItems = makeSampleItems()
let filteredLinks = viewModel.computeVisibleItems(from: sampleItems, query: "https://", sortMode: .links)
XCTAssertEqual(filteredLinks.map(\.payload), ["https://apple.com"])
let recentByUse = viewModel.computeVisibleItems(from: sampleItems, query: "", sortMode: .mostUsed)
XCTAssertEqual(recentByUse.map(\.payload), ["two", "/tmp/report.pdf", "/tmp/voice.sound", "one", "https://apple.com", "four"])
let textOnly = viewModel.computeVisibleItems(from: sampleItems, query: "", sortMode: .text)
XCTAssertEqual(textOnly.map(\.payload), ["four", "two", "one"])
let filesOnly = viewModel.computeVisibleItems(from: sampleItems, query: "", sortMode: .files)
XCTAssertEqual(filesOnly.map(\.payload), ["/tmp/report.pdf"])
let audioOnly = viewModel.computeVisibleItems(from: sampleItems, query: "", sortMode: .audio)
XCTAssertEqual(audioOnly.map(\.payload), ["/tmp/voice.sound"])
let pinnedOnly = viewModel.computeVisibleItems(from: sampleItems, query: "", sortMode: .pinned)
XCTAssertEqual(pinnedOnly.map(\.payload), ["four", "one"])
}
func testSearchMatchesIndependentTokensCaseInsensitively() {
let settings = makeSettings()
let store = makeStore(settings: settings)
let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: ClipboardCacheService())
let items = [
ClipboardItem(
id: UUID(),
kind: .text,
displayText: "GitHub release token",
payload: "Copied from github.com",
payloadHash: hash("github-token"),
createdAt: Date(timeIntervalSince1970: 100),
lastUsedAt: Date(timeIntervalSince1970: 100),
useCount: 0,
sourceApp: "Safari",
imagePath: nil,
thumbnailPath: nil
),
ClipboardItem(
id: UUID(),
kind: .text,
displayText: "Unrelated note",
payload: "release notes",
payloadHash: hash("note"),
createdAt: Date(timeIntervalSince1970: 90),
lastUsedAt: Date(timeIntervalSince1970: 90),
useCount: 0,
sourceApp: "Notes",
imagePath: nil,
thumbnailPath: nil
)
]
let result = viewModel.computeVisibleItems(from: items, query: "TOKEN github", sortMode: .mostRecent)
XCTAssertEqual(result.map(\.displayText), ["GitHub release token"])
}
func testCollectionsFilterSearchAndPersistSelection() {
let settings = makeSettings()
let cacheService = makeCacheService()
let store = makeStore(settings: settings, cacheService: cacheService)
let link = ClipboardItem(
id: UUID(),
kind: .url,
displayText: "Release notes",
payload: "https://example.com/releases",
payloadHash: hash("https://example.com/releases"),
createdAt: Date(timeIntervalSince1970: 100),
lastUsedAt: Date(timeIntervalSince1970: 100),
useCount: 0,
sourceApp: "Safari",
imagePath: nil,
thumbnailPath: nil
)
let note = makeTextItem("Important meeting note", createdAt: Date(timeIntervalSince1970: 200))
store.upsert(link)
store.upsert(note)
store.flushPersistenceForTesting()
let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: cacheService)
waitForVisibleItems(in: viewModel, count: 2)
viewModel.selectItem(at: 1)
viewModel.assignSelected(to: " Client Work ")
store.flushPersistenceForTesting()
waitForVisibleItems(in: viewModel, count: 2)
XCTAssertEqual(viewModel.collectionNames, ["Client Work"])
XCTAssertEqual(viewModel.collectionCount(named: "Client Work"), 1)
XCTAssertEqual(viewModel.statusMessage, "Added to Client Work")
viewModel.selectCollection(named: "Client Work")
waitForVisibleItems(in: viewModel, count: 1)
XCTAssertEqual(viewModel.visibleItems.map(\.payload), ["https://example.com/releases"])
viewModel.searchText = "release"
XCTAssertEqual(viewModel.collectionCount(named: "Client Work"), 1)
XCTAssertEqual(viewModel.visibleItems.map(\.displayText), ["Release notes"])
viewModel.searchText = "meeting"
XCTAssertEqual(viewModel.collectionCount(named: "Client Work"), 0)
XCTAssertTrue(viewModel.visibleItems.isEmpty)
}
func testSearchTextRecomputesVisibleItemsImmediately() {
let settings = makeSettings()
let cacheService = makeCacheService()
let store = makeStore(settings: settings, cacheService: cacheService)
store.upsert(makeTextItem("alpha note", createdAt: Date(timeIntervalSince1970: 100)))
store.upsert(makeTextItem("needle note", createdAt: Date(timeIntervalSince1970: 200)))
store.flushPersistenceForTesting()
let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: cacheService)
waitForVisibleItems(in: viewModel, count: 2)
viewModel.searchText = "needle"
XCTAssertEqual(viewModel.visibleItems.map(\.payload), ["needle note"])
XCTAssertEqual(viewModel.selectedItem?.payload, "needle note")
}
func testSelectFirstItemSelectsFirstVisibleItem() {
let settings = makeSettings()
let cacheService = makeCacheService()
let store = makeStore(settings: settings, cacheService: cacheService)
store.upsert(makeTextItem("older", createdAt: Date(timeIntervalSince1970: 100)))
store.upsert(makeTextItem("newer", createdAt: Date(timeIntervalSince1970: 200)))
store.flushPersistenceForTesting()
let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: cacheService)
waitForVisibleItems(in: viewModel, count: 2)
viewModel.selectItem(at: 1)
viewModel.selectFirstItem()
XCTAssertEqual(viewModel.selectedItem?.payload, "newer")
}
func testSelectFirstItemPrefersLatestOnSubsequentUpdates() {
let settings = makeSettings()
let cacheService = makeCacheService()
let store = makeStore(settings: settings, cacheService: cacheService)
store.upsert(makeTextItem("older", createdAt: Date(timeIntervalSince1970: 100)))
store.upsert(makeTextItem("newer", createdAt: Date(timeIntervalSince1970: 200)))
store.flushPersistenceForTesting()
let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: cacheService)
waitForVisibleItems(in: viewModel, count: 2)
viewModel.selectItem(at: 1)
viewModel.selectFirstItem()
store.upsert(makeTextItem("latest", createdAt: Date(timeIntervalSince1970: 300)))
store.flushPersistenceForTesting()
waitForVisibleItems(in: viewModel, count: 3)
XCTAssertEqual(viewModel.selectedItem?.payload, "latest")
}
func testDuplicateRecopyMovesExistingClipToMostRecentFront() {
let settings = makeSettings()
settings.pruneDuplicates = true
let cacheService = makeCacheService()
let store = makeStore(settings: settings, cacheService: cacheService)
store.upsert(makeTextItem("older duplicate text", createdAt: Date(timeIntervalSince1970: 100)))
store.upsert(makeTextItem("new unique text", createdAt: Date(timeIntervalSince1970: 200)))
store.flushPersistenceForTesting()
let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: cacheService)
waitForVisibleItems(in: viewModel, count: 2)
XCTAssertEqual(viewModel.visibleItems.first?.payload, "new unique text")
store.upsert(makeTextItem("older duplicate text", createdAt: Date(timeIntervalSince1970: 50)))
store.flushPersistenceForTesting()
waitForVisibleItems(in: viewModel, count: 2)
XCTAssertEqual(viewModel.visibleItems.first?.payload, "older duplicate text")
}
func testPollThenSearchAndCopyFlow() {
let settings = makeSettings()
let cacheService = makeCacheService()
let store = makeStore(settings: settings, cacheService: cacheService)
let monitor = ClipboardMonitorService(store: store, cacheService: cacheService, settings: settings)
let payload = "clipbored-flow-test-\(UUID().uuidString)"
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
XCTAssertTrue(pasteboard.setString(payload, forType: .string))
monitor.pollNowAndWait()
waitForStoreCount(store, count: 1, matching: payload)
let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: cacheService)
waitForVisibleItems(in: viewModel, count: 1)
viewModel.searchText = payload
waitForVisibleItems(in: viewModel, count: 1)
XCTAssertEqual(viewModel.visibleItems.first?.payload, payload)
viewModel.copySelected()
XCTAssertEqual(NSPasteboard.general.string(forType: .string), payload)
XCTAssertEqual(viewModel.statusMessage, "Copied")
}
func testCopySelectedWritesTextToPasteboard() {
let settings = makeSettings()
let cacheService = makeCacheService()
let store = makeStore(settings: settings, cacheService: cacheService)
let item = makeTextItem("panel copy text", createdAt: Date(timeIntervalSince1970: 100))
store.upsert(item)
store.flushPersistenceForTesting()
let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: cacheService)
waitForVisibleItems(in: viewModel, count: 1)
viewModel.copySelected()
XCTAssertEqual(viewModel.statusMessage, "Copied")
XCTAssertEqual(NSPasteboard.general.string(forType: .string), item.payload)
}
func testCopySelectedWritesURLToPasteboardTypes() {
let settings = makeSettings()
let cacheService = makeCacheService()
let store = makeStore(settings: settings, cacheService: cacheService)
let item = ClipboardItem(
id: UUID(),
kind: .url,
displayText: "https://example.com",
payload: "https://example.com",
payloadHash: hash("https://example.com"),
createdAt: Date(timeIntervalSince1970: 200),
lastUsedAt: Date(timeIntervalSince1970: 200),
useCount: 0,
sourceApp: nil,
imagePath: nil,
thumbnailPath: nil
)
store.upsert(item)
store.flushPersistenceForTesting()
let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: cacheService)
waitForVisibleItems(in: viewModel, count: 1)
viewModel.copySelected()
XCTAssertEqual(viewModel.statusMessage, "Copied")
XCTAssertEqual(NSPasteboard.general.string(forType: .string), item.payload)
XCTAssertEqual(NSPasteboard.general.string(forType: .URL), item.payload)
}
func testFailedCopyDoesNotMarkItemUsed() {
let settings = makeSettings()
let cacheService = makeCacheService()
let store = makeStore(settings: settings, cacheService: cacheService)
let item = makeMissingFileItem(useCount: 0)
store.upsert(item)
store.flushPersistenceForTesting()
let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: cacheService)
waitForVisibleItems(in: viewModel, count: 1)
viewModel.copySelected()
store.flushPersistenceForTesting()
XCTAssertEqual(viewModel.statusMessage, "Could not write item to clipboard.")
XCTAssertEqual(store.items.first?.id, item.id)
XCTAssertEqual(store.items.first?.useCount, 0)
XCTAssertEqual(store.items.first?.lastUsedAt, item.lastUsedAt)
}
func testFailedPasteDoesNotMarkItemUsed() {
let settings = makeSettings()
let cacheService = makeCacheService()
let store = makeStore(settings: settings, cacheService: cacheService)
let item = makeMissingFileItem(useCount: 2)
store.upsert(item)
store.flushPersistenceForTesting()
let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: cacheService)
var didRequestHide = false
viewModel.willPasteToTarget = { didRequestHide = true }
waitForVisibleItems(in: viewModel, count: 1)
viewModel.pasteSelected()
store.flushPersistenceForTesting()
XCTAssertEqual(viewModel.statusMessage, "Could not write item to clipboard.")
XCTAssertFalse(didRequestHide)
XCTAssertEqual(store.items.first?.id, item.id)
XCTAssertEqual(store.items.first?.useCount, 2)
XCTAssertEqual(store.items.first?.lastUsedAt, item.lastUsedAt)
}
func testPasteWithoutTargetCopiesButDoesNotRequestPanelHide() {
let settings = makeSettings()
let cacheService = makeCacheService()
let store = makeStore(settings: settings, cacheService: cacheService)
let item = makeTextItem("manual paste fallback", createdAt: Date(timeIntervalSince1970: 10))
store.upsert(item)
store.flushPersistenceForTesting()
let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: cacheService)
var didRequestHide = false
viewModel.willPasteToTarget = { didRequestHide = true }
viewModel.targetApplicationProvider = { nil }
waitForVisibleItems(in: viewModel, count: 1)
viewModel.pasteSelected()
store.flushPersistenceForTesting()
XCTAssertEqual(viewModel.statusMessage, "Copied")
XCTAssertFalse(didRequestHide)
XCTAssertEqual(store.items.first?.id, item.id)
XCTAssertEqual(store.items.first?.useCount, 1)
}
func testActionStatusIsNotOverwrittenUntilCaptureStatusChanges() {
let settings = makeSettings()
let cacheService = makeCacheService()
let store = makeStore(settings: settings, cacheService: cacheService)
let item = makeTextItem("status visibility", createdAt: Date(timeIntervalSince1970: 10))
store.upsert(item)
store.flushPersistenceForTesting()
let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: cacheService)
waitForVisibleItems(in: viewModel, count: 1)
viewModel.copySelected()
XCTAssertEqual(viewModel.statusMessage, "Copied")
settings.setCaptureStatus(message: "Capture status updated while panel is open")
RunLoop.main.run(until: Date().addingTimeInterval(0.05))
XCTAssertEqual(viewModel.statusMessage, "")
}
private func makeSettings() -> SettingsModel {
let settings = SettingsModel(defaults: UserDefaults(suiteName: "com.clipbored.testmodel.\(UUID().uuidString)")!)
settings.maxHistoryItems = 10
settings.includeImageTextInSearch = false
settings.pruneDuplicates = false
return settings
}
private func makeStore(settings: SettingsModel) -> ClipboardStore {
let cacheService = makeCacheService()
return makeStore(settings: settings, cacheService: cacheService)
}
private func makeStore(settings: SettingsModel, cacheService: ClipboardCacheService) -> ClipboardStore {
let tempURL = makeTempDirectory()
return ClipboardStore(
settings: settings,
cacheService: cacheService,
baseURL: tempURL,
encryptionService: ClipboardEncryptionService(keyProvider: { nil })
)
}
private func makeCacheService() -> ClipboardCacheService {
ClipboardCacheService(baseURL: makeTempDirectory(), encryptionService: ClipboardEncryptionService(keyProvider: { nil }))
}
private func makeTempDirectory() -> URL {
let tempURL = FileManager.default.temporaryDirectory
.appendingPathComponent("clipboredtests", isDirectory: true)
.appendingPathComponent(UUID().uuidString, isDirectory: true)
try? FileManager.default.createDirectory(at: tempURL, withIntermediateDirectories: true)
tempURLs.append(tempURL)
return tempURL
}
private func makeMissingFileItem(useCount: Int) -> ClipboardItem {
let date = Date(timeIntervalSince1970: 2_000)
let missingPath = FileManager.default.temporaryDirectory
.appendingPathComponent("clipbored-missing-\(UUID().uuidString)")
.path
return ClipboardItem(
id: UUID(),
kind: .file,
displayText: "Missing file",
payload: missingPath,
payloadHash: hash(missingPath),
createdAt: date,
lastUsedAt: date,
useCount: useCount,
sourceApp: nil,
imagePath: nil,
thumbnailPath: nil
)
}
private func makeTextItem(_ text: String, createdAt: Date) -> ClipboardItem {
ClipboardItem(
id: UUID(),
kind: .text,
displayText: text,
payload: text,
payloadHash: hash(text),
createdAt: createdAt,
lastUsedAt: createdAt,
useCount: 0,
sourceApp: nil,
imagePath: nil,
thumbnailPath: nil
)
}
private func waitForStoreCount(
_ store: ClipboardStore,
count: Int,
matching payload: String,
file: StaticString = #filePath,
line: UInt = #line
) {
let deadline = Date().addingTimeInterval(1)
while store.items.filter({ $0.payload == payload }).count != count && Date() < deadline {
RunLoop.current.run(until: Date().addingTimeInterval(0.01))
}
XCTAssertEqual(store.items.filter({ $0.payload == payload }).count, count, file: file, line: line)
}
private func waitForVisibleItems(
in viewModel: ClipboardPanelViewModel,
count: Int,
file: StaticString = #filePath,
line: UInt = #line
) {
let deadline = Date().addingTimeInterval(1)
while viewModel.visibleItems.count != count && Date() < deadline {
RunLoop.current.run(until: Date().addingTimeInterval(0.01))
}
XCTAssertEqual(viewModel.visibleItems.count, count, file: file, line: line)
}
private func makeSampleItems() -> [ClipboardItem] {
[
ClipboardItem(
id: UUID(),
kind: .text,
displayText: "Project notes",
payload: "one",
payloadHash: hash("one"),
createdAt: Date(timeIntervalSince1970: 1000),
lastUsedAt: Date(timeIntervalSince1970: 1000),
useCount: 2,
sourceApp: nil,
imagePath: nil,
thumbnailPath: nil,
isPinned: true
),
ClipboardItem(
id: UUID(),
kind: .richText,
displayText: "Two",
payload: "two",
payloadHash: hash("two"),
createdAt: Date(timeIntervalSince1970: 1100),
lastUsedAt: Date(timeIntervalSince1970: 1080),
useCount: 4,
sourceApp: "Mail",
imagePath: nil,
thumbnailPath: nil,
isPinned: false
),
ClipboardItem(
id: UUID(),
kind: .url,
displayText: "Apple",
payload: "https://apple.com",
payloadHash: hash("https://apple.com"),
createdAt: Date(timeIntervalSince1970: 1030),
lastUsedAt: Date(timeIntervalSince1970: 1050),
useCount: 1,
sourceApp: "Safari",
imagePath: nil,
thumbnailPath: nil,
isPinned: false
),
ClipboardItem(
id: UUID(),
kind: .file,
displayText: "report.pdf",
payload: "/tmp/report.pdf",
payloadHash: hash("/tmp/report.pdf"),
createdAt: Date(timeIntervalSince1970: 1060),
lastUsedAt: Date(timeIntervalSince1970: 1070),
useCount: 3,
sourceApp: "Finder",
imagePath: nil,
thumbnailPath: nil,
isPinned: false
),
ClipboardItem(
id: UUID(),
kind: .audio,
displayText: "Voice memo",
payload: "/tmp/voice.sound",
payloadHash: hash("/tmp/voice.sound"),
createdAt: Date(timeIntervalSince1970: 1040),
lastUsedAt: Date(timeIntervalSince1970: 1060),
useCount: 2,
sourceApp: "Voice Memos",
imagePath: nil,
thumbnailPath: nil,
isPinned: false
),
ClipboardItem(
id: UUID(),
kind: .text,
displayText: "Four",
payload: "four",
payloadHash: hash("four"),
createdAt: Date(timeIntervalSince1970: 1200),
lastUsedAt: Date(timeIntervalSince1970: 1200),
useCount: 0,
sourceApp: "Notes",
imagePath: nil,
thumbnailPath: nil,
isPinned: true
)
]
}
private func hash(_ value: String) -> String {
value
}
}

View File

@@ -0,0 +1,623 @@
import AppKit
import XCTest
@testable import ClipBored
final class ClipboardPanelViewTests: XCTestCase {
private var tempURLs: [URL] = []
private struct PanelFixture {
let window: NSWindow
let view: ClipboardPanelView
let viewModel: ClipboardPanelViewModel
let settings: SettingsModel
let store: ClipboardStore
let cacheService: ClipboardCacheService
}
override func tearDown() {
tempURLs.forEach { try? FileManager.default.removeItem(at: $0) }
tempURLs.removeAll()
super.tearDown()
}
func testSearchFieldEditingWhenSearchFieldIsFirstResponder() {
let (window, view) = makePanelWithPanelView()
window.makeFirstResponder(view)
XCTAssertFalse(view.isSearchFieldEditing)
view.focusSearchField()
XCTAssertTrue(view.isSearchFieldEditing)
}
func testSearchFieldEditingWhenFieldEditorIsFirstResponder() {
let (window, view) = makePanelWithPanelView()
view.focusSearchField()
guard let editor = window.fieldEditor(false, for: nil) else {
return XCTFail("Expected a search field editor")
}
window.makeFirstResponder(editor)
XCTAssertTrue(view.isSearchFieldEditing)
}
func testCapturedTextItemCreatesVisibleCardDocument() {
let fixture = makePanelFixture()
let item = makeTextItem("Bruh it said copied text but it does not appear.", store: fixture.store)
fixture.store.upsert(item)
drainMainQueue()
fixture.window.contentView?.layoutSubtreeIfNeeded()
XCTAssertEqual(fixture.viewModel.visibleItems.map(\.id), [item.id])
XCTAssertEqual(fixture.view.debugVisibleCardCount, 1)
XCTAssertEqual(fixture.view.debugResultCountText, "1 clip")
XCTAssertTrue(fixture.view.debugDocumentViewIsCardStack)
XCTAssertGreaterThanOrEqual(fixture.view.debugDocumentViewFrame.width, 292)
XCTAssertGreaterThanOrEqual(fixture.view.debugDocumentViewFrame.height, 244)
XCTAssertEqual(fixture.view.debugCardPreviewStyles, ["text-preview"])
}
func testFooterShowsCaptureStatusInsteadOfShortcutInstructions() {
let fixture = makePanelFixture()
fixture.store.upsert(makeTextItem("Footer status item", store: fixture.store))
drainMainQueue()
XCTAssertEqual(fixture.view.debugStatusText, "Capture running")
XCTAssertEqual(fixture.view.debugStatusTone, "ready")
XCTAssertFalse(fixture.view.debugStatusText.contains("Enter paste"))
}
func testSkippedCaptureStatusUsesWarningTone() {
let fixture = makePanelFixture()
fixture.settings.setCaptureStatus(message: "Skipped: Audio items are ignored in capture settings.")
drainMainQueue()
XCTAssertEqual(fixture.view.debugStatusText, "Skipped: Audio items are ignored in capture settings.")
XCTAssertEqual(fixture.view.debugStatusTone, "warning")
}
func testPanelShellRendersAsSquareDockedSurface() {
let (_, view) = makePanelWithPanelView()
drainMainQueue()
view.layoutSubtreeIfNeeded()
view.displayIfNeeded()
let rep = try! XCTUnwrap(view.bitmapImageRepForCachingDisplay(in: view.bounds))
rep.size = view.bounds.size
view.cacheDisplay(in: view.bounds, to: rep)
let width = rep.pixelsWide
let height = rep.pixelsHigh
func alphaAt(_ x: Int, _ y: Int) -> CGFloat {
rep.colorAt(x: x, y: y)?.alphaComponent ?? 0
}
XCTAssertEqual(view.debugPanelCornerRadius, 0)
XCTAssertGreaterThan(alphaAt(8, 8), 0.9)
XCTAssertGreaterThan(alphaAt(width - 9, 8), 0.9)
XCTAssertGreaterThan(alphaAt(8, height - 9), 0.9)
XCTAssertGreaterThan(alphaAt(width - 9, height - 9), 0.9)
}
func testOpeningTransitionDefersCardRailReloadUntilAnimationCompletes() {
let fixture = makePanelFixture()
fixture.store.upsert(makeTextItem("Existing clip", store: fixture.store))
drainMainQueue()
fixture.window.contentView?.layoutSubtreeIfNeeded()
XCTAssertEqual(fixture.view.debugVisibleCardCount, 1)
fixture.view.beginOpeningTransition()
XCTAssertTrue(fixture.view.debugIsDeferringVisualReloads)
fixture.store.upsert(makeTextItem("New clip during open", store: fixture.store))
drainMainQueue()
fixture.window.contentView?.layoutSubtreeIfNeeded()
XCTAssertEqual(fixture.view.debugVisibleCardCount, 1)
XCTAssertEqual(fixture.view.debugResultCountText, "2 clips")
fixture.view.finishOpeningTransition()
fixture.window.contentView?.layoutSubtreeIfNeeded()
XCTAssertFalse(fixture.view.debugIsDeferringVisualReloads)
XCTAssertEqual(fixture.view.debugVisibleCardCount, 2)
XCTAssertEqual(fixture.view.debugCardAccessibilityLabels.first, "Text: New clip during open")
}
func testCollectionRailUsesPasteStyleLabelsAndTracksSelection() {
let fixture = makePanelFixture()
XCTAssertEqual(
fixture.view.debugCollectionTitles,
["Clipboard", "Frequent", "Text", "Links", "Images", "Audio", "Files", "Pinned"]
)
XCTAssertEqual(fixture.view.debugSelectedCollectionTitle, "Clipboard")
fixture.viewModel.sortMode = .links
drainMainQueue()
XCTAssertEqual(fixture.view.debugSelectedCollectionTitle, "Links")
}
func testSelectedCardActionsRespectSelectedKind() {
let fixture = makePanelFixture()
fixture.store.upsert(makeTextItem("Plain text", store: fixture.store))
drainMainQueue()
XCTAssertEqual(fixture.view.debugFirstCardVisibleActionLabels, ["Paste", "Copy", "Pin", "Delete"])
XCTAssertEqual(fixture.view.debugFirstCardVisibleActionRailWidth, 126)
XCTAssertFalse(fixture.view.debugFirstCardFooterDetailIsHidden)
XCTAssertTrue(fixture.view.debugFirstCardHeaderBadgeIsHidden)
fixture.store.upsert(makeItem(kind: .file, text: "/tmp/report.txt", store: fixture.store))
drainMainQueue()
fixture.viewModel.selectFirstItem()
XCTAssertEqual(fixture.viewModel.visibleItems.first?.kind, .file)
XCTAssertEqual(fixture.view.debugFirstCardVisibleActionLabels, ["Paste", "Copy", "Pin", "Open", "Reveal", "Delete"])
XCTAssertEqual(fixture.view.debugFirstCardVisibleActionRailWidth, 182)
XCTAssertFalse(fixture.view.debugFirstCardFooterDetailIsHidden)
XCTAssertTrue(fixture.view.debugFirstCardHeaderBadgeIsHidden)
}
func testCardHeaderUsesKindSymbolBadgeWhenSourceIconIsUnavailable() {
let fixture = makePanelFixture()
fixture.store.upsert(makeItem(kind: .url, text: "https://example.com", store: fixture.store))
drainMainQueue()
XCTAssertEqual(fixture.view.debugCardHeaderBadgeSymbols, ["link"])
}
func testCollectionRailShowsLiveCounts() {
let fixture = makePanelFixture()
var pinned = makeTextItem("Pinned note", store: fixture.store)
pinned.isPinned = true
let rich = makeItem(kind: .richText, text: "Rich note", store: fixture.store)
let link = makeItem(kind: .url, text: "https://example.com/releases", store: fixture.store)
let image = makeItem(kind: .image, text: "image payload", store: fixture.store)
let audio = makeItem(kind: .audio, text: "audio payload", store: fixture.store)
let file = makeItem(kind: .file, text: "/tmp/report.pdf", store: fixture.store)
[pinned, rich, link, image, audio, file].forEach {
fixture.store.upsert($0)
drainMainQueue()
}
XCTAssertEqual(fixture.viewModel.visibleItems.count, 6)
XCTAssertEqual(ClipboardSortMode.allCases.map { fixture.viewModel.collectionCount(for: $0) }, [6, 6, 2, 1, 1, 1, 1, 1])
XCTAssertEqual(fixture.view.debugCollectionCounts, [6, 6, 2, 1, 1, 1, 1, 1])
}
func testCollectionRailShowsAssignedCollections() {
let fixture = makePanelFixture()
var link = makeItem(kind: .url, text: "https://example.com/read", store: fixture.store)
link.collectionName = "Useful Links"
var note = makeTextItem("Meeting note", store: fixture.store)
note.collectionName = "Important Notes"
var file = makeItem(kind: .file, text: "/tmp/client-brief.pdf", store: fixture.store)
file.collectionName = "Client Work"
fixture.store.upsert(link)
fixture.store.upsert(note)
fixture.store.upsert(file)
drainMainQueue()
XCTAssertEqual(fixture.view.debugCustomCollectionTitles, ["Useful Links", "Important Notes", "Client Work"])
XCTAssertEqual(fixture.view.debugCustomCollectionCounts, [1, 1, 1])
fixture.viewModel.selectCollection(named: "Useful Links")
drainMainQueue()
XCTAssertEqual(fixture.viewModel.visibleItems.map(\.payload), ["https://example.com/read"])
}
func testCollectionRailUsesScrollableDocumentForCrowdedCustomCollections() {
let fixture = makePanelFixture()
let names = [
"Client Work",
"Research Archive",
"Launch Planning",
"Design QA",
"Product References",
"Reading Stack",
"Invoices",
"Hiring Pipeline"
]
for name in names {
var item = makeTextItem("Collection item \(name)", store: fixture.store)
item.collectionName = name
fixture.store.upsert(item)
}
drainMainQueue()
fixture.window.contentView?.layoutSubtreeIfNeeded()
XCTAssertGreaterThan(fixture.view.debugCollectionRailVisibleWidth, 0)
XCTAssertGreaterThan(
fixture.view.debugCollectionRailDocumentWidth,
fixture.view.debugCollectionRailVisibleWidth + 1
)
XCTAssertTrue(fixture.view.debugCustomCollectionTitles.contains("Client Work"))
XCTAssertTrue(fixture.view.debugCustomCollectionTitles.contains("Product References"))
}
func testFilteredEmptyStateNamesCurrentCollection() {
let fixture = makePanelFixture()
fixture.store.upsert(makeTextItem("Only text exists", store: fixture.store))
drainMainQueue()
fixture.viewModel.sortMode = .images
drainMainQueue()
XCTAssertEqual(fixture.view.debugEmptyStateText?.title, "No images yet")
XCTAssertEqual(fixture.view.debugEmptyStateText?.detail, "Image clips are saved when the clipboard contains image data.")
}
func testCardsExposeContextMenuActions() {
let fixture = makePanelFixture()
fixture.store.upsert(makeTextItem("Context menu text", store: fixture.store))
drainMainQueue()
fixture.window.contentView?.layoutSubtreeIfNeeded()
XCTAssertEqual(
fixture.view.debugFirstCardMenuTitles,
["Paste", "Copy", "Pin", "Add to Collection", "-", "Open", "Reveal in Finder", "-", "Delete"]
)
XCTAssertEqual(
fixture.view.debugFirstCardCollectionMenuTitles,
["Useful Links", "Important Notes", "Code Snippets", "Read Later", "-", "New Collection..."]
)
}
func testCollectionMenuOffersExistingCustomCollections() {
let fixture = makePanelFixture()
var existing = makeTextItem("Existing client note", store: fixture.store)
existing.collectionName = "Client Work"
fixture.store.upsert(existing)
fixture.store.upsert(makeTextItem("Unsorted card", store: fixture.store))
drainMainQueue()
fixture.window.contentView?.layoutSubtreeIfNeeded()
XCTAssertEqual(
fixture.view.debugFirstCardCollectionMenuTitles,
["Useful Links", "Important Notes", "Code Snippets", "Read Later", "Client Work", "-", "New Collection..."]
)
}
func testBottomSafeInsetIsAppliedToPanelContent() {
let fixture = makePanelFixture()
fixture.view.setBottomSafeInset(108)
XCTAssertEqual(fixture.view.debugContentInsets.bottom, 108)
}
func testInternalLookingTextDoesNotBecomePrimaryCardTitle() {
let fixture = makePanelFixture()
let item = makeTextItem("clipbored-flow-test-\(UUID().uuidString)", store: fixture.store)
fixture.store.upsert(item)
drainMainQueue()
fixture.window.contentView?.layoutSubtreeIfNeeded()
XCTAssertEqual(fixture.view.debugCardAccessibilityLabels, ["Text: Copied text"])
}
func testLinkCardsUseReadableTitleAndAddressPreview() {
let fixture = makePanelFixture()
let item = makeItem(
kind: .url,
displayText: "Release notes",
payload: "https://www.example.com/releases/v1?utm_source=copy",
store: fixture.store
)
fixture.store.upsert(item)
drainMainQueue()
fixture.window.contentView?.layoutSubtreeIfNeeded()
XCTAssertEqual(fixture.view.debugCardAccessibilityLabels, ["Link: Release notes"])
XCTAssertEqual(fixture.view.debugCardPreviewSummaries, ["Release notes|example.com/releases/v1|example.com"])
XCTAssertEqual(fixture.view.debugCardPreviewStyles, ["link-preview"])
}
func testLinkCardsUseMediaPreviewWhenThumbnailExists() throws {
let fixture = makePanelFixture()
let id = UUID()
let paths = try XCTUnwrap(fixture.cacheService.cacheImage(sampleImage(), id: id))
let item = ClipboardItem(
id: id,
kind: .url,
displayText: "Lookbook",
payload: "https://example.com/lookbook",
payloadHash: fixture.store.hashString("https://example.com/lookbook"),
createdAt: Date(),
lastUsedAt: Date(),
useCount: 0,
sourceApp: "Safari",
imagePath: paths.full,
thumbnailPath: paths.thumb
)
fixture.store.upsert(item)
fixture.viewModel.sortMode = .links
drainMainQueue()
fixture.window.contentView?.layoutSubtreeIfNeeded()
XCTAssertEqual(fixture.view.debugCardAccessibilityLabels, ["Link: Lookbook"])
XCTAssertEqual(fixture.view.debugCardPreviewSummaries, ["Lookbook|example.com/lookbook|example.com"])
XCTAssertEqual(fixture.view.debugCardPreviewStyles, ["link-media-preview"])
}
func testRichTextCardsUseDisplayTextInsteadOfManagedPayloadPath() {
let fixture = makePanelFixture()
let item = makeItem(
kind: .richText,
displayText: "Styled note",
payload: "/tmp/clipbored-managed-rich-text.rtf",
store: fixture.store
)
fixture.store.upsert(item)
drainMainQueue()
fixture.window.contentView?.layoutSubtreeIfNeeded()
XCTAssertEqual(fixture.view.debugCardAccessibilityLabels, ["Rich Text: Styled note"])
XCTAssertEqual(fixture.view.debugCardPreviewSummaries, ["Styled note|Styled note|11 characters"])
XCTAssertEqual(fixture.view.debugCardPreviewStyles, ["rich-text-preview"])
}
func testFileCardsUseFilenameLocationAndType() {
let fixture = makePanelFixture()
let fileURL = FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent("Documents")
.appendingPathComponent("Project Plan.pdf")
let item = makeItem(kind: .file, displayText: "File", payload: fileURL.path, store: fixture.store)
fixture.store.upsert(item)
drainMainQueue()
fixture.window.contentView?.layoutSubtreeIfNeeded()
XCTAssertEqual(fixture.view.debugCardAccessibilityLabels, ["File: Project Plan.pdf"])
XCTAssertEqual(fixture.view.debugCardPreviewSummaries, ["Project Plan.pdf|~/Documents|PDF"])
XCTAssertEqual(fixture.view.debugCardPreviewStyles, ["file-preview"])
}
func testMultipleFileCardsUseCountAndSharedLocation() throws {
let fixture = makePanelFixture()
let directory = makeTempDirectory()
let firstURL = directory.appendingPathComponent("Brief.pdf")
let secondURL = directory.appendingPathComponent("Invoice.csv")
try Data("brief".utf8).write(to: firstURL)
try Data("invoice".utf8).write(to: secondURL)
let item = makeItem(
kind: .file,
displayText: "2 files",
payload: FilePayload.payload(from: [firstURL, secondURL]),
store: fixture.store
)
fixture.store.upsert(item)
drainMainQueue()
fixture.window.contentView?.layoutSubtreeIfNeeded()
XCTAssertEqual(fixture.view.debugCardAccessibilityLabels, ["File: 2 files"])
XCTAssertEqual(fixture.view.debugCardPreviewSummaries, ["2 files|\(directory.path)|2 files"])
XCTAssertEqual(fixture.view.debugCardPreviewStyles, ["file-preview"])
}
func testExistingFileCardsUseFullBleedMediaPreviewLayout() throws {
let fixture = makePanelFixture()
let imageURL = makeTempDirectory().appendingPathComponent("Campaign Reference.png")
let imageData = try XCTUnwrap(sampleImage().pngData())
try imageData.write(to: imageURL)
let item = makeItem(kind: .file, displayText: "File", payload: imageURL.path, store: fixture.store)
fixture.store.upsert(item)
fixture.viewModel.sortMode = .files
drainMainQueue()
fixture.window.contentView?.layoutSubtreeIfNeeded()
XCTAssertEqual(fixture.view.debugCardAccessibilityLabels, ["File: Campaign Reference.png"])
XCTAssertEqual(fixture.view.debugCardPreviewStyles, ["file-media-preview"])
}
func testPdfAndImageCardsUseSpecificPreviewText() {
let fixture = makePanelFixture()
let pdfURL = FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent("Downloads")
.appendingPathComponent("Reference Guide.pdf")
let pdf = makeItem(
kind: .pdf,
displayText: "PDF",
payload: pdfURL.path,
store: fixture.store,
ocrText: "Quarterly metrics\nSecond page"
)
fixture.store.upsert(pdf)
drainMainQueue()
fixture.window.contentView?.layoutSubtreeIfNeeded()
XCTAssertEqual(fixture.view.debugCardAccessibilityLabels, ["PDF: Reference Guide.pdf"])
XCTAssertEqual(
fixture.view.debugCardPreviewSummaries,
["Reference Guide.pdf|Quarterly metrics Second page|PDF"]
)
XCTAssertEqual(fixture.view.debugCardPreviewStyles, ["file-preview"])
let image = makeItem(
kind: .image,
displayText: "Image",
payload: "",
store: fixture.store,
ocrText: "Receipt total $42"
)
fixture.store.upsert(image)
fixture.viewModel.sortMode = .images
drainMainQueue()
fixture.window.contentView?.layoutSubtreeIfNeeded()
XCTAssertEqual(fixture.view.debugCardAccessibilityLabels, ["Image: Receipt total $42"])
XCTAssertEqual(fixture.view.debugCardPreviewSummaries, ["Receipt total $42|Receipt total $42|OCR text"])
XCTAssertEqual(fixture.view.debugCardPreviewStyles, ["text-fallback-preview"])
}
func testImageCardsUseMediaPreviewWhenThumbnailExists() {
let fixture = makePanelFixture()
let item = makeCachedImageItem(store: fixture.store, cacheService: fixture.cacheService)
fixture.store.upsert(item)
fixture.viewModel.sortMode = .images
drainMainQueue()
fixture.window.contentView?.layoutSubtreeIfNeeded()
XCTAssertEqual(fixture.view.debugCardAccessibilityLabels, ["Image: Campaign portrait"])
XCTAssertEqual(fixture.view.debugCardPreviewStyles, ["media-preview"])
}
func testAudioCardsUseSpecificPreviewText() {
let fixture = makePanelFixture()
let item = makeItem(
kind: .audio,
displayText: "Audio (14 KB)",
payload: "/tmp/clipbored-audio.sound",
store: fixture.store
)
fixture.store.upsert(item)
fixture.viewModel.sortMode = .audio
drainMainQueue()
fixture.window.contentView?.layoutSubtreeIfNeeded()
XCTAssertEqual(fixture.view.debugCardAccessibilityLabels, ["Audio: Audio (14 KB)"])
XCTAssertEqual(fixture.view.debugCardPreviewSummaries, ["Audio (14 KB)|Sound clip|Audio"])
XCTAssertEqual(fixture.view.debugCardPreviewStyles, ["audio-preview"])
}
private func makePanelWithPanelView() -> (NSWindow, ClipboardPanelView) {
let fixture = makePanelFixture()
return (fixture.window, fixture.view)
}
private func makePanelFixture() -> PanelFixture {
let settings = makeSettings()
let cacheService = ClipboardCacheService()
let store = makeStore(settings: settings, cacheService: cacheService)
let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: cacheService)
let view = ClipboardPanelView(
viewModel: viewModel,
onClose: {},
onSettings: {}
)
let window = NSPanel(
contentRect: NSRect(x: 0, y: 0, width: 1200, height: 520),
styleMask: [.titled, .closable],
backing: .buffered,
defer: false
)
window.contentView = view
window.makeKeyAndOrderFront(nil)
return PanelFixture(
window: window,
view: view,
viewModel: viewModel,
settings: settings,
store: store,
cacheService: cacheService
)
}
private func makeSettings() -> SettingsModel {
let settings = SettingsModel(defaults: UserDefaults(suiteName: "com.clipbored.viewtest.\(UUID().uuidString)")!)
settings.maxHistoryItems = 10
settings.pruneDuplicates = false
return settings
}
private func makeStore(settings: SettingsModel, cacheService: ClipboardCacheService) -> ClipboardStore {
ClipboardStore(settings: settings, cacheService: cacheService, baseURL: makeTempDirectory())
}
private func makeTempDirectory() -> URL {
let directory = FileManager.default.temporaryDirectory
.appendingPathComponent("clipbored-viewtest")
.appendingPathComponent(UUID().uuidString)
try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
tempURLs.append(directory)
return directory
}
private func makeTextItem(_ text: String, store: ClipboardStore) -> ClipboardItem {
makeItem(kind: .text, text: text, store: store)
}
private func makeItem(kind: ClipboardItemKind, text: String, store: ClipboardStore) -> ClipboardItem {
makeItem(kind: kind, displayText: text, payload: text, store: store)
}
private func makeItem(
kind: ClipboardItemKind,
displayText: String,
payload: String,
store: ClipboardStore,
ocrText: String? = nil
) -> ClipboardItem {
ClipboardItem(
id: UUID(),
kind: kind,
displayText: displayText,
payload: payload,
payloadHash: store.hashString(payload),
createdAt: Date(),
lastUsedAt: Date(),
useCount: 0,
sourceApp: "Ghostty",
imagePath: nil,
thumbnailPath: nil,
ocrText: ocrText
)
}
private func makeCachedImageItem(store: ClipboardStore, cacheService: ClipboardCacheService) -> ClipboardItem {
let id = UUID()
let paths = cacheService.cacheImage(sampleImage(), id: id)
return ClipboardItem(
id: id,
kind: .image,
displayText: "Campaign portrait",
payload: paths?.full ?? "",
payloadHash: store.hashString("campaign-portrait"),
createdAt: Date(),
lastUsedAt: Date(),
useCount: 0,
sourceApp: "Photos",
imagePath: paths?.full,
thumbnailPath: paths?.thumb,
ocrText: nil
)
}
private func sampleImage() -> NSImage {
let image = NSImage(size: NSSize(width: 180, height: 140))
image.lockFocus()
NSColor(calibratedRed: 1.0, green: 0.76, blue: 0.20, alpha: 1).setFill()
NSRect(x: 0, y: 0, width: 180, height: 140).fill()
NSColor(calibratedRed: 0.92, green: 0.20, blue: 0.26, alpha: 1).setFill()
NSBezierPath(ovalIn: NSRect(x: 34, y: 24, width: 82, height: 82)).fill()
NSColor(calibratedRed: 0.05, green: 0.42, blue: 0.86, alpha: 1).setFill()
NSBezierPath(roundedRect: NSRect(x: 92, y: 45, width: 58, height: 48), xRadius: 12, yRadius: 12).fill()
image.unlockFocus()
return image
}
private func drainMainQueue() {
for _ in 0..<20 {
RunLoop.main.run(until: Date().addingTimeInterval(0.01))
}
}
}

View File

@@ -0,0 +1,547 @@
import CryptoKit
import XCTest
import Foundation
import AppKit
@testable import ClipBored
final class ClipboardStoreTests: XCTestCase {
private var defaults: UserDefaults!
private var defaultsSuiteName: String!
private var cacheService: ClipboardCacheService!
private var baseURL: URL!
override func setUpWithError() throws {
try super.setUpWithError()
defaultsSuiteName = "com.clipbored.teststore.\(UUID().uuidString)"
defaults = UserDefaults(suiteName: defaultsSuiteName)
defaults.removePersistentDomain(forName: defaultsSuiteName)
baseURL = FileManager.default.temporaryDirectory
.appendingPathComponent("clipboredtests", isDirectory: true)
.appendingPathComponent(UUID().uuidString, isDirectory: true)
try FileManager.default.createDirectory(at: baseURL, withIntermediateDirectories: true)
cacheService = ClipboardCacheService(baseURL: baseURL, encryptionService: noOpEncryptionService())
}
override func tearDownWithError() throws {
defaults.removePersistentDomain(forName: defaultsSuiteName)
if let baseURL {
try? FileManager.default.removeItem(at: baseURL)
}
cacheService = nil
defaults = nil
baseURL = nil
try super.tearDownWithError()
}
func testUpsertMovesDuplicateToFrontAndPersists() throws {
let settings = makeSettings(maxHistory: 4)
let store = makeStore(settings: settings)
let start = Date()
store.upsert(makeItem("one", displayText: "One", created: start))
store.upsert(makeItem("two", displayText: "Two", created: start.addingTimeInterval(-10)))
store.upsert(makeItem("three", displayText: "Three", created: start.addingTimeInterval(-20)))
store.upsert(makeItem("one", displayText: "One (updated)", created: start.addingTimeInterval(-30)))
store.flushPersistenceForTesting()
XCTAssertEqual(store.items.count, 3)
XCTAssertEqual(store.items.map(\.payload), ["one", "three", "two"])
XCTAssertEqual(store.items.first?.useCount, 2)
let restored = makeStore(settings: settings)
restored.flushPersistenceForTesting()
XCTAssertEqual(restored.items.count, 3)
XCTAssertEqual(restored.items.first?.payload, "one")
XCTAssertEqual(restored.items.first?.useCount, 2)
}
func testHistoryLimitIsEnforcedByOverflowPurge() throws {
let settings = makeSettings(maxHistory: 50)
let store = makeStore(settings: settings)
let start = Date()
(0...50).forEach { i in
store.upsert(makeItem("item-\(i)", displayText: "\(i)", created: start.addingTimeInterval(-Double(i))))
}
store.flushPersistenceForTesting()
XCTAssertEqual(store.items.count, 50)
XCTAssertEqual(store.items.first?.payload, "item-50")
XCTAssertEqual(store.items.last?.payload, "item-1")
let restored = makeStore(settings: settings)
restored.flushPersistenceForTesting()
XCTAssertEqual(restored.items.count, 50)
XCTAssertFalse(restored.items.contains(where: { $0.payload == "item-0" }))
XCTAssertTrue(restored.items.contains(where: { $0.payload == "item-50" }))
XCTAssertTrue(restored.items.contains(where: { $0.payload == "item-1" }))
}
func testMarkUsedUpdatesStateAndWritesMutationOnlyOnce() {
let settings = makeSettings(maxHistory: 50)
let store = makeStore(settings: settings)
store.upsert(makeItem("same", displayText: "First", created: Date()))
store.upsert(makeItem("same", displayText: "First duplicate", created: Date().addingTimeInterval(1)))
store.flushPersistenceForTesting()
let first = try! XCTUnwrap(store.items.first)
let firstID = first.id
store.markUsed(firstID)
store.flushPersistenceForTesting()
let restored = makeStore(settings: settings)
restored.flushPersistenceForTesting()
XCTAssertEqual(restored.items.count, 1)
XCTAssertEqual(restored.items.first?.useCount, 3)
XCTAssertEqual(restored.items.first?.displayText, "First duplicate")
}
func testTogglePinPersistsAcrossReload() {
let settings = makeSettings(maxHistory: 50)
let store = makeStore(settings: settings)
store.upsert(makeItem("alpha", displayText: "A", created: Date()))
store.flushPersistenceForTesting()
let itemID = try! XCTUnwrap(store.items.first?.id)
store.togglePin(itemID)
store.flushPersistenceForTesting()
let restored = makeStore(settings: settings)
restored.flushPersistenceForTesting()
XCTAssertEqual(restored.items.first?.isPinned, true)
}
func testSetCollectionPersistsAcrossReload() {
let settings = makeSettings(maxHistory: 50)
let store = makeStore(settings: settings)
store.upsert(makeItem("alpha", displayText: "A", created: Date()))
store.flushPersistenceForTesting()
let itemID = try! XCTUnwrap(store.items.first?.id)
store.setCollection(itemID, name: " Client Work ")
store.flushPersistenceForTesting()
let restored = makeStore(settings: settings)
restored.flushPersistenceForTesting()
XCTAssertEqual(restored.items.first?.collectionName, "Client Work")
let restoredID = try! XCTUnwrap(restored.items.first?.id)
restored.setCollection(restoredID, name: nil)
restored.flushPersistenceForTesting()
let cleared = makeStore(settings: settings)
cleared.flushPersistenceForTesting()
XCTAssertNil(cleared.items.first?.collectionName)
}
func testLegacyJSONHistoryMigratesToSQLite() throws {
let settings = makeSettings(maxHistory: 50)
let itemID = UUID()
let legacyJSON = """
[
{
"id": "\(itemID.uuidString)",
"kind": 0,
"displayText": "Legacy Note",
"payload": "legacy payload",
"payloadHash": "legacy-hash",
"createdAt": "2026-06-27T12:00:00Z",
"lastUsedAt": "2026-06-27T12:01:00Z",
"useCount": 3,
"sourceApp": "Notes",
"imagePath": null,
"thumbnailPath": null
}
]
"""
try legacyJSON.data(using: .utf8)!.write(to: baseURL.appendingPathComponent("history.json"))
let store = makeStore(settings: settings)
store.flushPersistenceForTesting()
XCTAssertEqual(store.items.count, 1)
XCTAssertEqual(store.items.first?.id, itemID)
XCTAssertEqual(store.items.first?.payload, "legacy payload")
XCTAssertEqual(store.items.first?.useCount, 3)
let restored = makeStore(settings: settings)
restored.flushPersistenceForTesting()
XCTAssertEqual(restored.items.first?.payload, "legacy payload")
}
func testPinnedItemsSurviveNormalHistoryPrune() {
let settings = makeSettings(maxHistory: 50)
let store = makeStore(settings: settings)
let start = Date()
store.upsert(makeItem("pinned-old", displayText: "Pinned", created: start.addingTimeInterval(-500)))
let pinnedID = try! XCTUnwrap(store.items.first?.id)
store.togglePin(pinnedID)
(0..<60).forEach { index in
store.upsert(makeItem("new-\(index)", displayText: "New \(index)", created: start.addingTimeInterval(Double(index))))
}
store.flushPersistenceForTesting()
XCTAssertTrue(store.items.contains(where: { $0.payload == "pinned-old" && $0.isPinned }))
XCTAssertEqual(store.items.filter { !$0.isPinned }.count, 50)
XCTAssertEqual(store.items.filter(\.isPinned).count, 1)
let restored = makeStore(settings: settings)
restored.flushPersistenceForTesting()
XCTAssertTrue(restored.items.contains(where: { $0.payload == "pinned-old" && $0.isPinned }))
}
func testStorageFilesUsePrivatePermissions() throws {
let settings = makeSettings(maxHistory: 50)
let store = makeStore(settings: settings)
store.upsert(makeItem("private", displayText: "Private", created: Date()))
store.flushPersistenceForTesting()
XCTAssertEqual(try posixPermissions(baseURL), 0o700)
XCTAssertEqual(try posixPermissions(baseURL.appendingPathComponent("history.sqlite")), 0o600)
}
func testStorageDirectoryHonorsEnvironmentOverride() throws {
let overrideURL = baseURL.appendingPathComponent("OverrideStorage", isDirectory: true)
setenv(AppConfiguration.storageDirectoryOverrideEnvironmentKey, overrideURL.path, 1)
defer { unsetenv(AppConfiguration.storageDirectoryOverrideEnvironmentKey) }
let resolved = ClipboardStore.storageDirectory()
XCTAssertEqual(resolved.path, overrideURL.standardizedFileURL.path)
XCTAssertTrue(FileManager.default.fileExists(atPath: overrideURL.path))
XCTAssertEqual(try posixPermissions(overrideURL), 0o700)
}
func testRemoveAllResetsEncryptionKeyAfterClearingDatabase() throws {
let settings = makeSettings(maxHistory: 50)
var resetCount = 0
let keyData = Data(repeating: 7, count: 32)
let encryptionService = ClipboardEncryptionService(
keyProvider: { SymmetricKey(data: keyData) },
resetProvider: { resetCount += 1 }
)
let store = makeStore(settings: settings, encryptionService: encryptionService)
store.upsert(makeItem("first", displayText: "First", created: Date()))
store.upsert(makeItem("second", displayText: "Second", created: Date()))
store.flushPersistenceForTesting()
let firstID = try XCTUnwrap(store.items.first?.id)
store.remove(firstID)
store.flushPersistenceForTesting()
XCTAssertEqual(resetCount, 0)
store.removeAll()
store.flushPersistenceForTesting()
XCTAssertEqual(resetCount, 1)
XCTAssertTrue(store.items.isEmpty)
let restored = makeStore(settings: settings, encryptionService: encryptionService)
restored.flushPersistenceForTesting()
XCTAssertTrue(restored.items.isEmpty)
}
func testRemoveAllCompactsDatabaseFile() throws {
let settings = makeSettings(maxHistory: 2000)
settings.pruneDuplicates = false
let store = makeStore(settings: settings)
let payload = String(repeating: "clipbored database compaction payload ", count: 180)
for index in 0..<60 {
store.upsert(makeItem("\(payload)\(index)", displayText: "Large \(index)", created: Date(timeIntervalSince1970: Double(index))))
}
store.flushPersistenceForTesting()
let dbURL = baseURL.appendingPathComponent("history.sqlite")
let sizeBeforeClear = try fileSize(dbURL)
XCTAssertGreaterThan(sizeBeforeClear, 200_000)
store.removeAll()
store.flushPersistenceForTesting()
let sizeAfterClear = try fileSize(dbURL)
XCTAssertLessThan(sizeAfterClear, sizeBeforeClear / 2)
XCTAssertEqual(try posixPermissions(dbURL), 0o600)
}
func testPersistedTextFieldsAreEncryptedAndReload() throws {
let settings = makeSettings(maxHistory: 50)
let encryptionService = fixedEncryptionService()
let store = makeStore(settings: settings, encryptionService: encryptionService)
let item = ClipboardItem(
id: UUID(),
kind: .text,
displayText: "Displayed secret \(UUID().uuidString)",
payload: "Payload secret \(UUID().uuidString)",
payloadHash: "Hash secret \(UUID().uuidString)",
createdAt: Date(timeIntervalSince1970: 100),
lastUsedAt: Date(timeIntervalSince1970: 100),
useCount: 1,
sourceApp: "Secret app \(UUID().uuidString)",
imagePath: nil,
thumbnailPath: nil,
isPinned: false,
sourceAppBundleId: "com.example.secret.\(UUID().uuidString)",
ocrText: "OCR secret \(UUID().uuidString)",
collectionName: "Collection secret \(UUID().uuidString)"
)
store.upsert(item)
store.flushPersistenceForTesting()
let rawDatabaseText = try databaseText()
XCTAssertTrue(rawDatabaseText.contains(ClipboardEncryptionService.marker))
XCTAssertFalse(rawDatabaseText.contains(item.displayText))
XCTAssertFalse(rawDatabaseText.contains(item.payload))
XCTAssertFalse(rawDatabaseText.contains(item.payloadHash))
XCTAssertFalse(rawDatabaseText.contains(item.sourceApp!))
XCTAssertFalse(rawDatabaseText.contains(item.sourceAppBundleId!))
XCTAssertFalse(rawDatabaseText.contains(item.ocrText!))
XCTAssertFalse(rawDatabaseText.contains(item.collectionName!))
let restored = makeStore(settings: settings, encryptionService: encryptionService)
restored.flushPersistenceForTesting()
XCTAssertEqual(restored.items.first?.displayText, item.displayText)
XCTAssertEqual(restored.items.first?.payload, item.payload)
XCTAssertEqual(restored.items.first?.payloadHash, item.payloadHash)
XCTAssertEqual(restored.items.first?.sourceApp, item.sourceApp)
XCTAssertEqual(restored.items.first?.sourceAppBundleId, item.sourceAppBundleId)
XCTAssertEqual(restored.items.first?.ocrText, item.ocrText)
XCTAssertEqual(restored.items.first?.collectionName, item.collectionName)
}
func testPlaintextDatabaseMigratesToEncryptedFieldsOnLoad() throws {
let settings = makeSettings(maxHistory: 50)
let plaintextStore = makeStore(settings: settings, encryptionService: noOpEncryptionService())
let item = ClipboardItem(
id: UUID(),
kind: .text,
displayText: "Legacy display \(UUID().uuidString)",
payload: "Legacy payload \(UUID().uuidString)",
payloadHash: "Legacy hash \(UUID().uuidString)",
createdAt: Date(timeIntervalSince1970: 200),
lastUsedAt: Date(timeIntervalSince1970: 200),
useCount: 1,
sourceApp: "Legacy source \(UUID().uuidString)",
imagePath: nil,
thumbnailPath: nil,
isPinned: false,
sourceAppBundleId: "com.example.legacy.\(UUID().uuidString)",
ocrText: "Legacy OCR \(UUID().uuidString)",
collectionName: "Legacy collection \(UUID().uuidString)"
)
plaintextStore.upsert(item)
plaintextStore.flushPersistenceForTesting()
XCTAssertTrue(try databaseText().contains(item.payload))
let encryptionService = fixedEncryptionService()
let restored = makeStore(settings: settings, encryptionService: encryptionService)
restored.flushPersistenceForTesting()
XCTAssertEqual(restored.items.first?.displayText, item.displayText)
XCTAssertEqual(restored.items.first?.payload, item.payload)
XCTAssertEqual(restored.items.first?.payloadHash, item.payloadHash)
XCTAssertEqual(restored.items.first?.sourceApp, item.sourceApp)
XCTAssertEqual(restored.items.first?.sourceAppBundleId, item.sourceAppBundleId)
XCTAssertEqual(restored.items.first?.ocrText, item.ocrText)
XCTAssertEqual(restored.items.first?.collectionName, item.collectionName)
let migratedDatabaseText = try databaseText()
XCTAssertTrue(migratedDatabaseText.contains(ClipboardEncryptionService.marker))
XCTAssertFalse(migratedDatabaseText.contains(item.displayText))
XCTAssertFalse(migratedDatabaseText.contains(item.payload))
XCTAssertFalse(migratedDatabaseText.contains(item.payloadHash))
XCTAssertFalse(migratedDatabaseText.contains(item.sourceApp!))
XCTAssertFalse(migratedDatabaseText.contains(item.sourceAppBundleId!))
XCTAssertFalse(migratedDatabaseText.contains(item.ocrText!))
XCTAssertFalse(migratedDatabaseText.contains(item.collectionName!))
}
func testDuplicatePDFReplacementRemovesOldAttachment() throws {
let settings = makeSettings(maxHistory: 50)
let store = makeStore(settings: settings)
let hash = "same-pdf"
let oldPath = try XCTUnwrap(cacheService.cachePDF(Data("old".utf8), id: UUID()))
let newPath = try XCTUnwrap(cacheService.cachePDF(Data("new".utf8), id: UUID()))
store.upsert(makePDFItem(path: oldPath, hash: hash, created: Date(timeIntervalSince1970: 10)))
store.upsert(makePDFItem(path: newPath, hash: hash, created: Date(timeIntervalSince1970: 20)))
store.flushPersistenceForTesting()
cacheService.flushForTesting()
XCTAssertEqual(store.items.count, 1)
XCTAssertEqual(store.items.first?.payload, newPath)
XCTAssertFalse(FileManager.default.fileExists(atPath: oldPath))
XCTAssertTrue(FileManager.default.fileExists(atPath: newPath))
}
func testDuplicateReplacementClearsStaleImageSearchMetadata() throws {
let settings = makeSettings(maxHistory: 50)
settings.keepFirstImage = false
let store = makeStore(settings: settings)
let staleImage = try XCTUnwrap(cacheService.cacheImage(makeImage(color: .systemBlue), id: UUID()))
let hash = "same-content"
let imageItem = makeImageItem(
fullPath: staleImage.full,
thumbPath: staleImage.thumb,
hash: hash,
ocrText: "stale search marker",
created: Date(timeIntervalSince1970: 10)
)
let textItem = ClipboardItem(
id: UUID(),
kind: .text,
displayText: "Replacement text",
payload: "Replacement text",
payloadHash: hash,
createdAt: Date(timeIntervalSince1970: 20),
lastUsedAt: Date(timeIntervalSince1970: 20),
useCount: 1,
sourceApp: "Notes",
imagePath: nil,
thumbnailPath: nil,
isPinned: false,
sourceAppBundleId: "com.apple.Notes",
ocrText: nil
)
store.upsert(imageItem)
store.upsert(textItem)
store.flushPersistenceForTesting()
cacheService.flushForTesting()
let item = try XCTUnwrap(store.items.first)
XCTAssertEqual(store.items.count, 1)
XCTAssertEqual(item.kind, .text)
XCTAssertEqual(item.payload, textItem.payload)
XCTAssertNil(item.imagePath)
XCTAssertNil(item.thumbnailPath)
XCTAssertNil(item.ocrText)
XCTAssertFalse(item.searchableText.contains("stale search marker"))
XCTAssertFalse(FileManager.default.fileExists(atPath: staleImage.full))
XCTAssertFalse(FileManager.default.fileExists(atPath: staleImage.thumb))
}
private func makeSettings(maxHistory: Int) -> SettingsModel {
let settings = SettingsModel(defaults: defaults)
settings.maxHistoryItems = maxHistory
settings.pruneDuplicates = true
settings.keepFirstImage = true
return settings
}
private func makeStore(settings: SettingsModel) -> ClipboardStore {
makeStore(settings: settings, encryptionService: noOpEncryptionService())
}
private func makeStore(settings: SettingsModel, encryptionService: ClipboardEncryptionService) -> ClipboardStore {
ClipboardStore(
settings: settings,
cacheService: cacheService,
baseURL: baseURL,
encryptionService: encryptionService
)
}
private func makeItem(_ payload: String, displayText: String, created: Date) -> ClipboardItem {
let hash = settingsHash(for: payload)
return ClipboardItem(
id: UUID(),
kind: .text,
displayText: displayText,
payload: payload,
payloadHash: hash,
createdAt: created,
lastUsedAt: created,
useCount: 1,
sourceApp: nil,
imagePath: nil,
thumbnailPath: nil,
isPinned: false
)
}
private func makePDFItem(path: String, hash: String, created: Date) -> ClipboardItem {
ClipboardItem(
id: UUID(),
kind: .pdf,
displayText: "PDF",
payload: path,
payloadHash: hash,
createdAt: created,
lastUsedAt: created,
useCount: 1,
sourceApp: nil,
imagePath: nil,
thumbnailPath: nil,
isPinned: false
)
}
private func makeImageItem(fullPath: String, thumbPath: String, hash: String, ocrText: String?, created: Date) -> ClipboardItem {
ClipboardItem(
id: UUID(),
kind: .image,
displayText: "Image",
payload: fullPath,
payloadHash: hash,
createdAt: created,
lastUsedAt: created,
useCount: 1,
sourceApp: "Preview",
imagePath: fullPath,
thumbnailPath: thumbPath,
isPinned: false,
sourceAppBundleId: "com.apple.Preview",
ocrText: ocrText
)
}
private func makeImage(color: NSColor) -> NSImage {
let size = NSSize(width: 24, height: 24)
let image = NSImage(size: size)
image.lockFocus()
color.setFill()
NSRect(origin: .zero, size: size).fill()
image.unlockFocus()
return image
}
private func settingsHash(for payload: String) -> String {
payload
}
private func posixPermissions(_ url: URL) throws -> Int {
let attributes = try FileManager.default.attributesOfItem(atPath: url.path)
return try XCTUnwrap(attributes[.posixPermissions] as? Int) & 0o777
}
private func fileSize(_ url: URL) throws -> Int64 {
let attributes = try FileManager.default.attributesOfItem(atPath: url.path)
let size = try XCTUnwrap(attributes[.size] as? NSNumber)
return size.int64Value
}
private func databaseText() throws -> String {
let data = try Data(contentsOf: baseURL.appendingPathComponent("history.sqlite"))
return String(decoding: data, as: UTF8.self)
}
private func noOpEncryptionService() -> ClipboardEncryptionService {
ClipboardEncryptionService(keyProvider: { nil })
}
private func fixedEncryptionService(byte: UInt8 = 7) -> ClipboardEncryptionService {
let keyData = Data(repeating: byte, count: 32)
return ClipboardEncryptionService(keyProvider: { SymmetricKey(data: keyData) })
}
}

View File

@@ -0,0 +1,35 @@
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()
}
}

View File

@@ -0,0 +1,426 @@
import AppKit
import CryptoKit
import XCTest
@testable import ClipBored
final class PasteActionServiceTests: XCTestCase {
private var tempURLs: [URL] = []
override func tearDown() {
tempURLs.forEach { try? FileManager.default.removeItem(at: $0) }
tempURLs.removeAll()
super.tearDown()
}
func testCopyWritesTextToPasteboard() {
let service = PasteActionService()
let item = ClipboardItem(
id: UUID(),
kind: .text,
displayText: "Hello",
payload: "Hello",
payloadHash: "hash",
createdAt: Date(),
lastUsedAt: Date(),
useCount: 0,
sourceApp: nil,
imagePath: nil,
thumbnailPath: nil
)
XCTAssertEqual(service.copy(item), .copied)
XCTAssertEqual(NSPasteboard.general.string(forType: .string), "Hello")
}
func testPasteWithoutTargetCopiesWithoutRequestingAutomaticPaste() {
let service = PasteActionService()
let item = ClipboardItem(
id: UUID(),
kind: .text,
displayText: "No target",
payload: "No target",
payloadHash: "hash",
createdAt: Date(),
lastUsedAt: Date(),
useCount: 0,
sourceApp: nil,
imagePath: nil,
thumbnailPath: nil
)
XCTAssertEqual(service.paste(item, targetApp: nil), .copied)
XCTAssertEqual(NSPasteboard.general.string(forType: .string), "No target")
}
func testAutomaticPasteActivatesTargetAndSchedulesKeyboardPasteWhenPermissionGranted() throws {
var activatedProcessID: pid_t?
let targetApp = try makeRunningTargetApp()
var didScheduleKeyboardPaste = false
let service = PasteActionService(
accessibilityPermissionProvider: { true },
targetActivator: { app in
activatedProcessID = app.processIdentifier
return true
},
keyboardPasteScheduler: { _ in
didScheduleKeyboardPaste = true
}
)
XCTAssertEqual(service.paste(makeTextItem("Paste into target"), targetApp: targetApp), .pasted)
XCTAssertEqual(activatedProcessID, targetApp.processIdentifier)
XCTAssertTrue(didScheduleKeyboardPaste)
XCTAssertEqual(NSPasteboard.general.string(forType: .string), "Paste into target")
}
func testAutomaticPasteDoesNotPostShortcutWhenTargetActivationFails() throws {
var didAttemptActivation = false
let targetApp = try makeRunningTargetApp()
let service = PasteActionService(
accessibilityPermissionProvider: { true },
targetActivator: { _ in
didAttemptActivation = true
return false
},
keyboardPasteScheduler: { _ in
XCTFail("Keyboard paste should not be scheduled when target activation fails")
}
)
XCTAssertEqual(service.paste(makeTextItem("Activation failed"), targetApp: targetApp), .copied)
XCTAssertTrue(didAttemptActivation)
XCTAssertEqual(NSPasteboard.general.string(forType: .string), "Activation failed")
}
func testAutomaticPasteWithoutPermissionDoesNotActivateTarget() throws {
let targetApp = try makeRunningTargetApp()
let service = PasteActionService(
accessibilityPermissionProvider: { false },
targetActivator: { _ in
XCTFail("Target should not be activated without Accessibility permission")
return true
},
keyboardPasteScheduler: { _ in
XCTFail("Keyboard paste should not be scheduled without Accessibility permission")
}
)
XCTAssertEqual(service.paste(makeTextItem("Needs permission"), targetApp: targetApp), .copiedNeedsPermission)
XCTAssertEqual(NSPasteboard.general.string(forType: .string), "Needs permission")
}
func testCopyMissingFileDoesNotClearExistingPasteboard() {
let service = PasteActionService()
let board = NSPasteboard.general
board.clearContents()
XCTAssertTrue(board.setString("keep me", forType: .string))
let item = ClipboardItem(
id: UUID(),
kind: .file,
displayText: "Missing file",
payload: "/tmp/clipbored-missing-\(UUID().uuidString)",
payloadHash: "hash",
createdAt: Date(),
lastUsedAt: Date(),
useCount: 0,
sourceApp: nil,
imagePath: nil,
thumbnailPath: nil
)
XCTAssertEqual(service.copy(item), .failed("Could not write item to clipboard."))
XCTAssertEqual(board.string(forType: .string), "keep me")
}
func testCopyEmptyTextDoesNotClearExistingPasteboard() {
let service = PasteActionService()
let board = NSPasteboard.general
board.clearContents()
XCTAssertTrue(board.setString("keep me", forType: .string))
let item = ClipboardItem(
id: UUID(),
kind: .text,
displayText: "",
payload: "",
payloadHash: "hash",
createdAt: Date(),
lastUsedAt: Date(),
useCount: 0,
sourceApp: nil,
imagePath: nil,
thumbnailPath: nil
)
XCTAssertEqual(service.copy(item), .failed("Could not write item to clipboard."))
XCTAssertEqual(board.string(forType: .string), "keep me")
}
func testCopyWritesURLType() {
let service = PasteActionService()
let item = 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
)
XCTAssertEqual(service.copy(item), .copied)
XCTAssertEqual(NSPasteboard.general.string(forType: .string), "https://apple.com")
XCTAssertEqual(NSPasteboard.general.string(forType: .URL), "https://apple.com")
XCTAssertEqual(
NSPasteboard.general.string(forType: NSPasteboard.PasteboardType(rawValue: "public.url-name")),
"Apple"
)
}
func testCopyWritesRichTextRTFAndPlainStringFallback() throws {
let directory = try makeTempDirectory()
let cacheService = ClipboardCacheService(baseURL: directory, encryptionService: fixedEncryptionService())
let attributed = NSAttributedString(
string: "Styled clipboard text",
attributes: [.font: NSFont.boldSystemFont(ofSize: 15)]
)
let rtfData = try XCTUnwrap(
attributed.rtf(from: NSRange(location: 0, length: attributed.length), documentAttributes: [:])
)
let path = try XCTUnwrap(cacheService.cacheRichText(rtfData, id: UUID()))
let service = PasteActionService(cacheService: cacheService)
let item = ClipboardItem(
id: UUID(),
kind: .richText,
displayText: attributed.string,
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: .rtf), rtfData)
XCTAssertEqual(NSPasteboard.general.string(forType: .string), attributed.string)
}
func testCopyLegacyRichTextWritesPlainPayloadWhenRTFCacheIsUnavailable() {
let service = PasteActionService()
let item = ClipboardItem(
id: UUID(),
kind: .richText,
displayText: "Legacy rich text",
payload: "Legacy rich text",
payloadHash: "hash",
createdAt: Date(),
lastUsedAt: Date(),
useCount: 0,
sourceApp: nil,
imagePath: nil,
thumbnailPath: nil
)
XCTAssertEqual(service.copy(item), .copied)
XCTAssertEqual(NSPasteboard.general.string(forType: .string), "Legacy rich text")
XCTAssertNil(NSPasteboard.general.data(forType: .rtf))
}
func testCopyRichTextWithMissingCacheWritesDisplayTextInsteadOfPath() throws {
let missingPath = try makeTempDirectory().appendingPathComponent("missing-rich-text.rtf").path
let service = PasteActionService()
let item = ClipboardItem(
id: UUID(),
kind: .richText,
displayText: "Readable rich text",
payload: missingPath,
payloadHash: "hash",
createdAt: Date(),
lastUsedAt: Date(),
useCount: 0,
sourceApp: nil,
imagePath: nil,
thumbnailPath: nil
)
XCTAssertFalse(FileManager.default.fileExists(atPath: missingPath))
XCTAssertEqual(service.copy(item), .copied)
XCTAssertEqual(NSPasteboard.general.string(forType: .string), "Readable rich text")
}
func testCopyWritesFileReferenceType() throws {
let fileURL = try makeTempFile(contents: "file contents")
let service = PasteActionService()
let item = ClipboardItem(
id: UUID(),
kind: .file,
displayText: fileURL.path,
payload: fileURL.path,
payloadHash: "hash",
createdAt: Date(),
lastUsedAt: Date(),
useCount: 0,
sourceApp: nil,
imagePath: nil,
thumbnailPath: nil
)
XCTAssertEqual(service.copy(item), .copied)
let objects = NSPasteboard.general.readObjects(forClasses: [NSURL.self], options: nil) as? [URL]
XCTAssertEqual(objects?.first?.standardizedFileURL, fileURL.standardizedFileURL)
XCTAssertEqual(NSPasteboard.general.string(forType: .string), fileURL.path)
}
func testCopyWritesMultipleFileReferences() throws {
let firstURL = try makeTempFile(contents: "first file")
let secondURL = try makeTempFile(contents: "second file")
let payload = FilePayload.payload(from: [firstURL, secondURL])
let service = PasteActionService()
let item = ClipboardItem(
id: UUID(),
kind: .file,
displayText: "2 files",
payload: payload,
payloadHash: "hash",
createdAt: Date(),
lastUsedAt: Date(),
useCount: 0,
sourceApp: nil,
imagePath: nil,
thumbnailPath: nil
)
XCTAssertEqual(service.copy(item), .copied)
let objects = NSPasteboard.general.readObjects(forClasses: [NSURL.self], options: nil) as? [URL]
XCTAssertEqual(objects?.map(\.standardizedFileURL), [firstURL.standardizedFileURL, secondURL.standardizedFileURL])
XCTAssertEqual(NSPasteboard.general.string(forType: .string), payload)
}
func testCopyWritesPDFData() throws {
let pdfData = Data("%PDF-1.4\nclipbored\n%%EOF".utf8)
let fileURL = try makeTempFile(contents: pdfData)
let service = PasteActionService()
let item = ClipboardItem(
id: UUID(),
kind: .pdf,
displayText: "PDF",
payload: fileURL.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: .pdf), pdfData)
}
func testCopyWritesAudioData() throws {
let directory = try makeTempDirectory()
let cacheService = ClipboardCacheService(baseURL: directory, encryptionService: fixedEncryptionService())
let audioData = Data([1, 3, 5, 7, 9])
let path = try XCTUnwrap(cacheService.cacheAudio(audioData, id: UUID()))
let service = PasteActionService(cacheService: cacheService)
let item = ClipboardItem(
id: UUID(),
kind: .audio,
displayText: "Audio",
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: .sound), audioData)
}
func testCopyWritesEncryptedPDFData() throws {
let directory = try makeTempDirectory()
let cacheService = ClipboardCacheService(baseURL: directory, encryptionService: fixedEncryptionService())
let pdfData = Data("%PDF-1.4\nencrypted clipbored\n%%EOF".utf8)
let path = try XCTUnwrap(cacheService.cachePDF(pdfData, id: UUID()))
let service = PasteActionService(cacheService: cacheService)
let item = ClipboardItem(
id: UUID(),
kind: .pdf,
displayText: "PDF",
payload: path,
payloadHash: "hash",
createdAt: Date(),
lastUsedAt: Date(),
useCount: 0,
sourceApp: nil,
imagePath: nil,
thumbnailPath: nil
)
XCTAssertTrue(ClipboardEncryptionService.isProtected(try Data(contentsOf: URL(fileURLWithPath: path))))
XCTAssertEqual(service.copy(item), .copied)
XCTAssertEqual(NSPasteboard.general.data(forType: .pdf), pdfData)
}
private func makeTempFile(contents: String) throws -> URL {
try makeTempFile(contents: Data(contents.utf8))
}
private func makeTextItem(_ value: String) -> ClipboardItem {
ClipboardItem(
id: UUID(),
kind: .text,
displayText: value,
payload: value,
payloadHash: "hash",
createdAt: Date(),
lastUsedAt: Date(),
useCount: 0,
sourceApp: nil,
imagePath: nil,
thumbnailPath: nil
)
}
private func makeRunningTargetApp() throws -> NSRunningApplication {
try XCTUnwrap(
NSWorkspace.shared.runningApplications.first {
!$0.isTerminated && $0.processIdentifier > 0
}
)
}
private func makeTempFile(contents: Data) throws -> URL {
let directory = try makeTempDirectory()
let url = directory.appendingPathComponent("payload")
try contents.write(to: url)
return url
}
private func makeTempDirectory() throws -> URL {
let directory = FileManager.default.temporaryDirectory
.appendingPathComponent("clipboredtests", isDirectory: true)
.appendingPathComponent(UUID().uuidString, isDirectory: true)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
tempURLs.append(directory)
return directory
}
private func fixedEncryptionService(byte: UInt8 = 7) -> ClipboardEncryptionService {
let keyData = Data(repeating: byte, count: 32)
return ClipboardEncryptionService(keyProvider: { SymmetricKey(data: keyData) })
}
}

View File

@@ -0,0 +1,70 @@
import XCTest
@testable import ClipBored
final class SensitiveContentDetectorTests: XCTestCase {
func testDetectsKnownSecretFormats() {
XCTAssertEqual(
SensitiveContentDetector.detect("-----BEGIN PRIVATE KEY-----\nabc\n-----END PRIVATE KEY-----"),
.privateKey
)
XCTAssertEqual(
SensitiveContentDetector.detect("Authorization: Bearer abcdefghijklmnopqrstuvwxyz123456"),
.bearerToken
)
XCTAssertEqual(
SensitiveContentDetector.detect("ghp_abcdefghijklmnopqrstuvwxyzABCDE1234567890"),
.githubToken
)
XCTAssertEqual(
SensitiveContentDetector.detect("AKIA1234567890ABCDEF"),
.awsAccessKey
)
XCTAssertEqual(
SensitiveContentDetector.detect("xoxb-abcdefghijklmnopqrst"),
.slackToken
)
XCTAssertEqual(
SensitiveContentDetector.detect("sk_live_abcdefghijklmnop"),
.stripeKey
)
XCTAssertEqual(
SensitiveContentDetector.detect("sk-proj-abcdefghijklmnopqrstuvwxyz1234567890"),
.openAIToken
)
XCTAssertEqual(
SensitiveContentDetector.detect("AIzaabcdefghijklmnopqrstuvwxyz123456789"),
.googleAPIKey
)
XCTAssertEqual(
SensitiveContentDetector.detect("eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.signature123"),
.jsonWebToken
)
}
func testDetectsCreditCardWithLuhnCheck() {
XCTAssertEqual(SensitiveContentDetector.detect("4242424242424242"), .creditCard)
XCTAssertNil(SensitiveContentDetector.detect("4242424242424241"))
}
func testAllowsNormalClipboardText() {
XCTAssertNil(SensitiveContentDetector.detect("Project notes for tomorrow"))
XCTAssertNil(SensitiveContentDetector.detect("https://www.apple.com/mac/"))
XCTAssertNil(SensitiveContentDetector.detect("Remember to request the API key from the platform team"))
XCTAssertNil(SensitiveContentDetector.detect("Release token cleanup notes"))
}
func testDetectsOtpOnlyForSensitiveSources() {
XCTAssertNil(SensitiveContentDetector.detect("123456"))
XCTAssertEqual(
SensitiveContentDetector.detect("123456", sourceBundleId: "com.1password.1password", sourceApp: "1Password"),
.oneTimeCode
)
}
func testDetectsSecretAssignments() {
XCTAssertEqual(SensitiveContentDetector.detect("OPENAI_API_KEY=sk-proj-abcdefghijklmnopqrstuvwxyz"), .openAIToken)
XCTAssertEqual(SensitiveContentDetector.detect("client_secret: supersecretvalue"), .keyword)
XCTAssertEqual(SensitiveContentDetector.detect("refresh_token = \"abc1234567890\""), .keyword)
XCTAssertEqual(SensitiveContentDetector.detect("passwd='correct-horse-battery'"), .keyword)
}
}

View File

@@ -0,0 +1,70 @@
import AppKit
import Carbon
import XCTest
@testable import ClipBored
final class ShortcutManagerTests: XCTestCase {
func testVirtualKeyCodeMappingSupportsDefaults() {
XCTAssertEqual(ShortcutManager.virtualKeyCode(for: "v"), UInt16(kVK_ANSI_V))
XCTAssertEqual(ShortcutManager.virtualKeyCode(for: ","), UInt16(kVK_ANSI_Comma))
}
func testVirtualKeyCodeRejectsUnsupportedKeys() {
XCTAssertNil(ShortcutManager.virtualKeyCode(for: "space"))
XCTAssertNil(ShortcutManager.virtualKeyCode(for: ""))
}
func testCarbonModifierMapping() {
let flags = NSEvent.ModifierFlags.command.union(.option).rawValue
let carbon = ShortcutManager.carbonModifiers(for: flags)
XCTAssertTrue(carbon & UInt32(cmdKey) != 0)
XCTAssertTrue(carbon & UInt32(optionKey) != 0)
XCTAssertFalse(carbon & UInt32(controlKey) != 0)
}
func testRejectsBareGlobalShortcut() {
let manager = makeManager(openShortcut: ShortcutBinding(key: "v", modifierFlags: 0))
XCTAssertEqual(manager.start(), .unsupportedShortcut("V"))
manager.stop()
}
func testRejectsShiftOnlyGlobalShortcut() {
let manager = makeManager(openShortcut: ShortcutBinding(key: "v", modifierFlags: NSEvent.ModifierFlags.shift.rawValue))
XCTAssertEqual(manager.start(), .unsupportedShortcut("⇧V"))
manager.stop()
}
func testRejectsUnsupportedSettingsShortcutBeforeRegistration() {
let manager = makeManager(
openShortcut: AppConfiguration.defaultOpenShortcut,
settingsShortcut: ShortcutBinding(key: "space", modifierFlags: NSEvent.ModifierFlags.command.rawValue)
)
XCTAssertEqual(manager.start(), .unsupportedShortcut("⌘SPACE"))
manager.stop()
}
func testRejectsDuplicateShortcutBindingsBeforeRegistration() {
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(
onOpenClipboardPanel: {},
onOpenSettings: {},
openShortcut: openShortcut,
settingsShortcut: settingsShortcut
)
}
}