WIP
This commit is contained in:
24
.github/workflows/ci.yml
vendored
Normal file
24
.github/workflows/ci.yml
vendored
Normal 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
30
.gitignore
vendored
Normal 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
36
CONTRIBUTING.md
Normal 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
343
LICENSE
Normal 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
32
Package.swift
Normal 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
83
README.md
Normal 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
31
SECURITY.md
Normal 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
55
docs/ARCHITECTURE.md
Normal 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
82
docs/RELEASE.md
Normal 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
26
docs/ROADMAP.md
Normal 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
54
docs/SECURITY.md
Normal 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
86
docs/SMOKE_TEST.md
Normal 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
49
scripts/build-macos-app.sh
Executable 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
12
scripts/check.sh
Executable 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
22
scripts/idle-soak-report.sh
Executable 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
102
scripts/release-macos-app.sh
Executable 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"
|
||||||
512
sources/clipbored/app/AppDelegate.swift
Normal file
512
sources/clipbored/app/AppDelegate.swift
Normal 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
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
14
sources/clipbored/app/ClipBoredApp.swift
Normal file
14
sources/clipbored/app/ClipBoredApp.swift
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
117
sources/clipbored/config/AppConfiguration.swift
Normal file
117
sources/clipbored/config/AppConfiguration.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
34
sources/clipbored/extensions/NSImage+Helpers.swift
Normal file
34
sources/clipbored/extensions/NSImage+Helpers.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
19
sources/clipbored/extensions/String+Whitespace.swift
Normal file
19
sources/clipbored/extensions/String+Whitespace.swift
Normal 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])
|
||||||
|
}
|
||||||
|
}
|
||||||
181
sources/clipbored/models/ClipboardItem.swift
Normal file
181
sources/clipbored/models/ClipboardItem.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
26
sources/clipbored/models/FilePayload.swift
Normal file
26
sources/clipbored/models/FilePayload.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
214
sources/clipbored/models/SettingsModel.swift
Normal file
214
sources/clipbored/models/SettingsModel.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
sources/clipbored/resources/AppIcon.icns
Normal file
BIN
sources/clipbored/resources/AppIcon.icns
Normal file
Binary file not shown.
BIN
sources/clipbored/resources/AppIcon.png
Normal file
BIN
sources/clipbored/resources/AppIcon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.6 KiB |
28
sources/clipbored/resources/Info.plist
Normal file
28
sources/clipbored/resources/Info.plist
Normal 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>
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
40
sources/clipbored/services/AppLifecycleService.swift
Normal file
40
sources/clipbored/services/AppLifecycleService.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
342
sources/clipbored/services/ClipboardCacheService.swift
Normal file
342
sources/clipbored/services/ClipboardCacheService.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
284
sources/clipbored/services/ClipboardEncryptionService.swift
Normal file
284
sources/clipbored/services/ClipboardEncryptionService.swift
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
761
sources/clipbored/services/ClipboardMonitorService.swift
Normal file
761
sources/clipbored/services/ClipboardMonitorService.swift
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
25
sources/clipbored/services/ClipboardSelfWriteTracker.swift
Normal file
25
sources/clipbored/services/ClipboardSelfWriteTracker.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
841
sources/clipbored/services/ClipboardStore.swift
Normal file
841
sources/clipbored/services/ClipboardStore.swift
Normal 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
61
sources/clipbored/services/DiagnosticsService.swift
Normal file
61
sources/clipbored/services/DiagnosticsService.swift
Normal 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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
40
sources/clipbored/services/ImageTextExtractor.swift
Normal file
40
sources/clipbored/services/ImageTextExtractor.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
196
sources/clipbored/services/PasteActionService.swift
Normal file
196
sources/clipbored/services/PasteActionService.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
385
sources/clipbored/services/SensitiveContentDetector.swift
Normal file
385
sources/clipbored/services/SensitiveContentDetector.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
266
sources/clipbored/services/ShortcutManager.swift
Normal file
266
sources/clipbored/services/ShortcutManager.swift
Normal 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)"
|
||||||
|
}
|
||||||
|
}
|
||||||
457
sources/clipbored/views/ClipboardPanelController.swift
Normal file
457
sources/clipbored/views/ClipboardPanelController.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
2507
sources/clipbored/views/ClipboardPanelView.swift
Normal file
2507
sources/clipbored/views/ClipboardPanelView.swift
Normal file
File diff suppressed because it is too large
Load Diff
406
sources/clipbored/views/ClipboardPanelViewModel.swift
Normal file
406
sources/clipbored/views/ClipboardPanelViewModel.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
642
sources/clipbored/views/SettingsWindowController.swift
Normal file
642
sources/clipbored/views/SettingsWindowController.swift
Normal 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
|
||||||
|
}
|
||||||
100
tests/clipboredtests/AppDelegateTests.swift
Normal file
100
tests/clipboredtests/AppDelegateTests.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
319
tests/clipboredtests/ClipboardCacheServiceTests.swift
Normal file
319
tests/clipboredtests/ClipboardCacheServiceTests.swift
Normal 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) })
|
||||||
|
}
|
||||||
|
}
|
||||||
73
tests/clipboredtests/ClipboardEncryptionServiceTests.swift
Normal file
73
tests/clipboredtests/ClipboardEncryptionServiceTests.swift
Normal 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) })
|
||||||
|
}
|
||||||
|
}
|
||||||
722
tests/clipboredtests/ClipboardMonitorServiceTests.swift
Normal file
722
tests/clipboredtests/ClipboardMonitorServiceTests.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
147
tests/clipboredtests/ClipboardPanelControllerTests.swift
Normal file
147
tests/clipboredtests/ClipboardPanelControllerTests.swift
Normal 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
551
tests/clipboredtests/ClipboardPanelViewModelTests.swift
Normal file
551
tests/clipboredtests/ClipboardPanelViewModelTests.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
623
tests/clipboredtests/ClipboardPanelViewTests.swift
Normal file
623
tests/clipboredtests/ClipboardPanelViewTests.swift
Normal 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
547
tests/clipboredtests/ClipboardStoreTests.swift
Normal file
547
tests/clipboredtests/ClipboardStoreTests.swift
Normal 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) })
|
||||||
|
}
|
||||||
|
}
|
||||||
35
tests/clipboredtests/DiagnosticsServiceTests.swift
Normal file
35
tests/clipboredtests/DiagnosticsServiceTests.swift
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
426
tests/clipboredtests/PasteActionServiceTests.swift
Normal file
426
tests/clipboredtests/PasteActionServiceTests.swift
Normal 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) })
|
||||||
|
}
|
||||||
|
}
|
||||||
70
tests/clipboredtests/SensitiveContentDetectorTests.swift
Normal file
70
tests/clipboredtests/SensitiveContentDetectorTests.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
70
tests/clipboredtests/ShortcutManagerTests.swift
Normal file
70
tests/clipboredtests/ShortcutManagerTests.swift
Normal 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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user