Prepare v0.4 release and open source docs

This commit is contained in:
Akshay Kolli
2026-06-29 23:42:39 -07:00
parent 085d7a16dc
commit 504bd2d39a
58 changed files with 5076 additions and 923 deletions

1
.github/CODEOWNERS vendored Normal file
View File

@@ -0,0 +1 @@
* @akkolli

36
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View File

@@ -0,0 +1,36 @@
---
name: Bug report
about: Report a reproducible problem in I Hate PDFs
title: ""
labels: bug
assignees: ""
---
## Summary
What happened?
## Expected Behavior
What did you expect to happen?
## Steps To Reproduce
1.
2.
3.
## Environment
- I Hate PDFs version/build:
- macOS version:
- Install source: GitHub release or Mac App Store
- Mac architecture: Apple Silicon or Intel
## Screenshots Or Recordings
Attach screenshots or recordings for UI problems. Each file must be under 1 MB.
## Additional Context
Add any relevant PDF workflow details. Do not attach private PDFs publicly.

View File

@@ -0,0 +1,27 @@
---
name: Feature request
about: Suggest an improvement for I Hate PDFs
title: ""
labels: enhancement
assignees: ""
---
## Problem
What workflow problem should this solve?
## Proposed Solution
What should I Hate PDFs do?
## Alternatives Considered
What workaround or other app behavior have you tried?
## UI Impact
Will this change visible UI? If yes, describe the screen, toolbar, menu, sidebar, popover, or dialog affected.
## Size And Privacy Impact
Does this require new assets, dependencies, network behavior, bundled PDFs, or release-size increases?

28
.github/pull_request_template.md vendored Normal file
View File

@@ -0,0 +1,28 @@
## Summary
Describe what changed and why.
## Screenshots Or Recordings
Required for UI changes. Include before and after screenshots for changed UI, or at least one screenshot/recording for new UI.
Each screenshot, recording, and committed media file included with this pull request must be under 1 MB.
## Checks
List the checks you ran:
- [ ] `swift test`
- [ ] `swift build -c release`
- [ ] `scripts/verify-release-artifacts.sh`
- [ ] Manual UI check
- [ ] Not run, because:
## Checklist
- [ ] I read `CONTRIBUTING.md`.
- [ ] This pull request is focused on one behavior, fix, or documentation change.
- [ ] I updated `CHANGELOG.md` for user-visible changes, or this change is not user-visible.
- [ ] UI changes include screenshots or recordings, or this pull request does not change UI.
- [ ] Every screenshot, recording, and committed media file in this pull request is under 1 MB.
- [ ] I explained any app-size impact from new assets, dependencies, or release packaging changes.

53
.github/workflows/media-size.yml vendored Normal file
View File

@@ -0,0 +1,53 @@
name: Media Size
on:
pull_request:
paths:
- "**/*.gif"
- "**/*.jpeg"
- "**/*.jpg"
- "**/*.mov"
- "**/*.mp4"
- "**/*.png"
- "**/*.webp"
jobs:
media-size:
name: Media files under 1 MB
runs-on: ubuntu-latest
steps:
- name: Check out repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Check changed media size
shell: bash
run: |
set -euo pipefail
limit_bytes=1048576
failed=0
mapfile -t media_files < <(
git diff --name-only --diff-filter=ACMRT \
"${{ github.event.pull_request.base.sha }}" \
"${{ github.event.pull_request.head.sha }}" \
-- \
'*.gif' '*.jpeg' '*.jpg' '*.mov' '*.mp4' '*.png' '*.webp'
)
for file in "${media_files[@]}"; do
if [[ ! -f "$file" ]]; then
continue
fi
size_bytes=$(wc -c < "$file" | tr -d ' ')
if (( size_bytes >= limit_bytes )); then
echo "::error file=${file}::Media file is ${size_bytes} bytes. Keep each screenshot, recording, and committed media file under 1 MB."
failed=1
fi
done
exit "$failed"

6
.gitignore vendored
View File

@@ -1,8 +1,14 @@
.DS_Store .DS_Store
.swiftpm/
.build/ .build/
DerivedData/ DerivedData/
dist/ dist/
release/ release/
plans/
*.dmg
*.pkg
*.tar.gz
*.tar.xz
*.zip *.zip
*.xcuserstate *.xcuserstate
*.xcworkspace/xcuserdata/ *.xcworkspace/xcuserdata/

View File

@@ -1,5 +1,71 @@
# Changelog # Changelog
## Unreleased
### Repository
- Added README badges for release, license, platform, Swift, contributions, and media-size policy.
- Added contribution, support, security, issue, and pull request policies for open-source contributors.
- Added a pull request media-size policy requiring UI screenshots or recordings and limiting each screenshot, recording, or committed media file to less than 1 MB.
## Version 0.4.0 (build 6) - 2026-06-25
Version 0.4 removes the experimental Fill & Sign, form-field navigation, and PDF signing implementation from the shipping app. The release is back to a small native reader and annotation tool while preserving the size target.
### Removed
- Prepared the release metadata for app version `0.4.0`, build `6`.
- Removed the Fill & Sign panel, menu commands, settings, flat fill-mark factory, form scanner/navigation, custom form choice popover, PDF signing pipeline, and related QA scripts/tests.
- Removed the SecurityInterface link from the app target.
- Preserved the size target by returning to the existing lightweight native stack: SwiftUI, AppKit, PDFKit, and Foundation.
### Reliability
- Cleaned up PDFKit page/selection observers when reattaching a PDF view and when app state is released, avoiding stale observer callbacks in long app sessions.
- Re-ran the core test suite, release build, annotation verification, and release artifact verification for the 0.4 prep pass.
## Version 0.3.0 (build 5) - 2026-06-25
Build 5 is a release-candidate polish build for signatures, Recent PDFs, and release metadata. Complete the manual Acrobat and Preview signature checks before public distribution.
### Fill And Sign
- Added a Fill & Sign toolbar/menu workflow for placing flat PDF fill marks without adding a bundled PDF engine.
- Added text, checkbox, date, initials, and typed signature-appearance marks as standard printable `/FreeText` annotations with app metadata.
- Added AcroForm widget scanning so PDFs with form fields report field counts in the reader/status UI.
- Added native form-field navigation from the Fill & Sign strip, Annotate menu, Tab, and Shift-Tab.
- Captured choice/list field options and export values through PDFKit's public form APIs.
- Added a compact native choice/list popover for PDFKit-backed combo and list fields.
- Kept visual signature appearances separate from real digital signatures; cryptographic signing still goes through Keychain-backed File > Sign PDF....
- Typed signature appearances can now be reused as the visible widget appearance for a real digital signature.
- Added unit coverage for Fill & Sign models, form scanning, choice/list options and fallback values, unsupported fields, form-value save/reopen behavior, form-scan performance, fill mark metadata, page clamping, visible signature appearance reuse, and signature-appearance non-detection as a digital signature.
### PDF Signatures
- Added digital PDF signing from File > Sign PDF..., using macOS Keychain identities and detached CMS signatures.
- Signed output is written as an incremental PDF update, preserving original PDF bytes instead of rewriting through PDFKit.
- Added invisible signatures and click-to-place visible signature boxes.
- Visible signatures are now drawn into page content as well as the signature widget appearance, so macOS Preview renders the signer text instead of a blank signature box.
- Added signature detection and status reporting for signed PDFs.
- Added a compact signature inspector from the status bar with signer, status, date, reason, location, format, and ByteRange details.
- Normal Save is disabled for signed PDFs; Save As creates an unsigned edited copy instead.
- Added CMS validation for signed PDFs, including separate reporting for invalid signatures and untrusted certificates.
- Added CMS parsing support for macOS Security's indefinite-length CMS output, so real Keychain-signed PDFs validate after signing.
- Added performance-budget coverage for large-document annotation snapshots, page-scoped annotation refresh, and large PDF signature scanning.
- Added opt-in Keychain signature QA that can either use an installed identity or create a temporary QA identity, then writes invisible and visible signed PDFs and checks Poppler `pdfsig` recognition when available.
- Added an Acrobat QA handoff script that verifies signed QA files and prints the exact remaining Acrobat Reader checks.
- Fixed signature QA fixtures to emit valid classic xref offsets, avoiding parser reconstruction warnings and Preview rejection.
- Added unit coverage for signature scanning, incremental writing, field construction, ByteRange/Contents patching, and validator parsing.
### Reader Polish
- Added compact Recent PDFs shortcuts in the empty window and File > Open Recent, backed by macOS's native recent-document list.
- Added paste-ready App Store metadata, review notes, privacy answers, and screenshot guidance.
- Added a bitmap Preview screenshot showing visible signed-PDF output for external-reader QA.
- Added isolated App Store package staging so Mac App Store builds no longer overwrite the direct-download app bundle in `dist`.
- Strengthened release artifact verification for bundle IDs, App Store package signing, embedded provisioning profiles, and expected sandbox entitlements.
- Removed SVG mock screenshots from the documentation assets.
## Version 0.3.0 (build 4) - 2026-06-24 ## Version 0.3.0 (build 4) - 2026-06-24
Version 0.3 is focused on making annotation work feel reliable enough for real PDF review: clearer highlights, better comment behavior, safer saving, and release packaging for the Mac App Store. Version 0.3 is focused on making annotation work feel reliable enough for real PDF review: clearer highlights, better comment behavior, safer saving, and release packaging for the Mac App Store.

25
CODE_OF_CONDUCT.md Normal file
View File

@@ -0,0 +1,25 @@
# Code Of Conduct
This project expects direct, respectful, technical collaboration.
## Expected Behavior
- Be clear and constructive.
- Focus criticism on code, behavior, documentation, and project direction.
- Assume contributors may have different experience levels and constraints.
- Respect maintainer decisions about scope, release timing, licensing, and App Store distribution.
## Unacceptable Behavior
- Harassment, threats, insults, or discriminatory language.
- Personal attacks in issues, pull requests, discussions, commits, or reviews.
- Publishing private information without permission.
- Repeatedly pushing a rejected change without new technical evidence.
## Enforcement
Maintainers may edit, hide, or remove comments; close issues or pull requests; block participants; or restrict repository access when behavior harms the project.
Report conduct issues through the product support page:
<https://www.akkolli.net/ihatepdfs>

74
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,74 @@
# Contributing To I Hate PDFs
I Hate PDFs is a native macOS app for local PDF reading and annotation. Contributions should preserve that direction: small bundle, native system frameworks, local files, no account requirement, no analytics, and no cloud upload.
By contributing, you agree that your contribution is licensed under GNU General Public License version 2 only.
## Before You Start
- Check existing issues and pull requests before starting duplicate work.
- Open an issue first for large UI changes, new dependencies, release-process changes, or features that affect PDF saving/export behavior.
- Read `docs/ENGINEERING.md` before adding dependencies, bundled assets, PDF engines, runtimes, or broad architectural changes.
- Read `docs/WORKFLOW_AUDIT.md` before changing a user workflow.
- Read `docs/RELEASE.md` before preparing a release.
## Development
Requirements:
- macOS 13 or newer
- Xcode 15 or newer with command line tools
- Swift Package Manager
Useful local commands:
```sh
swift run IHatePDFs
swift test
swift build -c release
scripts/build-app.sh
scripts/make-dmg.sh
scripts/verify-release-artifacts.sh
```
Run the checks that match your change and list them in the pull request. If you skip a relevant check, say why.
## Pull Request Policy
- Keep each pull request focused on one behavior, bug fix, or documentation change.
- Explain the user-visible behavior change, not only the implementation detail.
- Link the issue when one exists.
- Update `CHANGELOG.md` for user-visible changes.
- Avoid unrelated formatting churn.
- Do not add a new dependency, bundled asset, PDF engine, runtime, or release artifact without explaining the size and maintenance impact.
- Preserve the app's local-first privacy model.
## UI Screenshot Policy
Every pull request that changes visible UI must include screenshots or a short screen recording in the pull request description.
For UI changes:
- Include before and after screenshots when changing an existing screen.
- Include at least one screenshot when adding a new screen, state, toolbar item, sidebar, popover, dialog, menu, or empty state.
- Cover light and dark mode when the change affects colors, contrast, icons, materials, or selection states.
- Cover narrow window behavior when the change affects layout or toolbar/sidebar density.
- Keep every screenshot, recording, and committed media file under 1 MB.
If a UI change cannot be captured meaningfully, explain that in the pull request.
## Size Policy
The app is intentionally small. Pull requests should keep source, assets, and release artifacts lean.
- Each committed screenshot, recording, fixture image, or other media file added or changed in a pull request must be less than 1 MB.
- Prefer cropped screenshots that show the changed UI instead of full-desktop captures.
- Prefer compressed PNG, JPEG, or WebP where appropriate.
- Do not commit raw screen recordings, large sample PDFs, generated archives, `.app` bundles, `.dmg` files, or App Store packages.
- Explain any app-size increase caused by assets, dependencies, or release packaging changes.
The `media-size.yml` workflow checks changed media files in pull requests. PR description attachments are still governed by this policy even though GitHub Actions cannot inspect them.
## Review Expectations
Maintainers may ask for smaller scope, screenshots, reduced assets, additional tests, or release-size justification before merging. Release submission and App Store upload decisions stay with the maintainers.

351
LICENSE
View File

@@ -1,21 +1,338 @@
MIT License GNU GENERAL PUBLIC LICENSE
Version 2, June 1991
Copyright (c) 2026 I Hate PDFs contributors Copyright (C) 1989, 1991 Free Software Foundation, Inc.,
<https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Permission is hereby granted, free of charge, to any person obtaining a copy Preamble
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all The licenses for most software are designed to take away your
copies or substantial portions of the Software. 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.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR When we speak of free software, we are referring to freedom, not
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, price. Our General Public Licenses are designed to make sure that you
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE have the freedom to distribute copies of free software (and charge for
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER this service if you wish), that you receive source code or can get it
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, if you want it, that you can change the software or use pieces of it
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE in new free programs; and that you know you can do these things.
SOFTWARE.
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.
GNU GENERAL PUBLIC LICENSE
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 a brief idea of what it does.>
Copyright (C) <year> <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, see <https://www.gnu.org/licenses/>.
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 Moe Ghoul>, 1 April 1989
Moe Ghoul, 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.

View File

@@ -19,6 +19,10 @@ let package = Package(
.testTarget( .testTarget(
name: "IHatePDFsCoreTests", name: "IHatePDFsCoreTests",
dependencies: ["IHatePDFsCore"] dependencies: ["IHatePDFsCore"]
),
.testTarget(
name: "IHatePDFsTests",
dependencies: ["IHatePDFs", "IHatePDFsCore"]
) )
] ]
) )

View File

@@ -1,5 +1,12 @@
# I Hate PDFs # I Hate PDFs
[![Latest release](https://img.shields.io/github/v/release/akkolli/ihatepdfs?label=release)](https://github.com/akkolli/ihatepdfs/releases/latest)
[![License: GPL v2](https://img.shields.io/badge/license-GPL--2.0--only-blue.svg)](LICENSE)
[![Platform: macOS 13+](https://img.shields.io/badge/platform-macOS%2013%2B-lightgrey.svg)](#build-from-source)
[![Swift native](https://img.shields.io/badge/Swift-native-orange.svg)](#development)
[![Contributions welcome](https://img.shields.io/badge/contributions-welcome-brightgreen.svg)](CONTRIBUTING.md)
[![Media size policy](https://github.com/akkolli/ihatepdfs/actions/workflows/media-size.yml/badge.svg)](https://github.com/akkolli/ihatepdfs/actions/workflows/media-size.yml)
I Hate PDFs is a small native macOS PDF reader for local reading, highlighting, commenting, and review. It uses SwiftUI, AppKit, and PDFKit, keeps documents on your Mac, and avoids accounts, tracking, and cloud upload. I Hate PDFs is a small native macOS PDF reader for local reading, highlighting, commenting, and review. It uses SwiftUI, AppKit, and PDFKit, keeps documents on your Mac, and avoids accounts, tracking, and cloud upload.
Minimum supported macOS version: macOS 13 Ventura. Minimum supported macOS version: macOS 13 Ventura.
@@ -8,13 +15,13 @@ Supported Mac architectures: Apple Silicon and Intel.
## Latest Release ## Latest Release
Current version: `0.3.0` build `4`. Current version: `0.4.0` build `6`.
Download the v0.3 macOS DMG from the GitHub release page: Download the v0.4 macOS DMG from the GitHub release page:
<https://github.com/akkolli/ihatepdfs/releases/tag/v0.3> <https://github.com/akkolli/ihatepdfs/releases/tag/v0.4>
Use `IHatePDFs-v0.3-macos.dmg` for direct installation. Open the DMG, then move `I Hate PDFs.app` into `/Applications`. Use `IHatePDFs-v0.4-macos.dmg` for direct installation. Open the DMG, then move `I Hate PDFs.app` into `/Applications`.
The direct-download DMG is separate from the Mac App Store build. The App Store package uses bundle ID `net.akkolli.ihatepdfs` and is built with the sandbox entitlements documented in `docs/APP_STORE.md`. The direct-download DMG is separate from the Mac App Store build. The App Store package uses bundle ID `net.akkolli.ihatepdfs` and is built with the sandbox entitlements documented in `docs/APP_STORE.md`.
@@ -24,9 +31,11 @@ The direct-download DMG is separate from the Mac App Store build. The App Store
- Drag a PDF onto the empty app window to open it. - Drag a PDF onto the empty app window to open it.
- Read with smooth PDFKit scrolling, Retina rendering, zoom, fit-to-width, fit-to-page, and page navigation. - Read with smooth PDFKit scrolling, Retina rendering, zoom, fit-to-width, fit-to-page, and page navigation.
- Search selectable text PDFs from a compact toolbar control. - Search selectable text PDFs from a compact toolbar control.
- Start in a focused single-pane reading layout, with thumbnail and comments sidebars hidden until requested. - Start each opened PDF in a focused single-pane reading layout, with the document fit to the available width and sidebars hidden until requested.
- Remember thumbnail and comments sidebar visibility per PDF and coarse window size. - Adapt the reader layout across compact, regular, and wide Mac windows while preserving usable PDF width.
- Configure highlight and comment colors, including opacity, from Settings. - Configure highlight and comment colors, including opacity, from Settings.
- Reopen recent PDFs from the empty window or File > Open Recent.
- Close the current PDF back to the empty window without closing the app window.
- Create standalone highlights from selected text. - Create standalone highlights from selected text.
- Create selected-text comments and underline comments. - Create selected-text comments and underline comments.
- Create free-text annotations directly on the page. - Create free-text annotations directly on the page.
@@ -37,6 +46,15 @@ The direct-download DMG is separate from the Mac App Store build. The App Store
- Share the annotated PDF through the native macOS share picker. - Share the annotated PDF through the native macOS share picker.
- Review annotations in a comments sidebar with page grouping, search, filters, replies, edit/delete, and click-to-navigate. - Review annotations in a comments sidebar with page grouping, search, filters, replies, edit/delete, and click-to-navigate.
## Privacy And Support
- Product and support page: <https://www.akkolli.net/ihatepdfs>
- Privacy policy: <https://www.akkolli.net/ihatepdfs/privacy>
- Support policy: [SUPPORT.md](SUPPORT.md)
- Security policy: [SECURITY.md](SECURITY.md)
I Hate PDFs works with user-selected local files. It does not require an account, collect analytics, or upload PDFs.
## Build From Source ## Build From Source
Requirements: Requirements:
@@ -75,7 +93,15 @@ Create a downloadable `.dmg`:
scripts/make-dmg.sh scripts/make-dmg.sh
``` ```
The packaged app is written to `dist/I Hate PDFs.app`; the disk image is written to `dist/IHatePDFs-v0.3-macos.dmg` by default. The packaged app is written to `dist/I Hate PDFs.app`; the disk image is written to `dist/IHatePDFs-v0.4-macos.dmg` by default.
Create the size-gated per-architecture archives:
```sh
scripts/make-tiny-archives.sh
```
This writes `dist/IHatePDFs-v0.4-macos-arm64.tar.xz` and `dist/IHatePDFs-v0.4-macos-x86_64.tar.xz`, then verifies each archive is under the 400,000-byte direct-download budget.
Build an App Store upload package after installing the application signing certificate, installer signing certificate, and App Store provisioning profile: Build an App Store upload package after installing the application signing certificate, installer signing certificate, and App Store provisioning profile:
@@ -86,11 +112,11 @@ PROVISIONING_PROFILE="$HOME/Downloads/IHatePDFs_AppStore.provisionprofile" \
scripts/make-app-store-pkg.sh scripts/make-app-store-pkg.sh
``` ```
The App Store package is written to `dist/IHatePDFs-v0.3-macos-appstore.pkg`. More details are in `docs/APP_STORE.md`. The App Store package is written to `dist/IHatePDFs-v0.4-macos-appstore.pkg`. More details are in `docs/APP_STORE.md`.
## Installation ## Installation
Download `IHatePDFs-v0.3-macos.dmg` from the latest GitHub release, open it, and move `I Hate PDFs.app` into `/Applications`. Download `IHatePDFs-v0.4-macos.dmg` from the latest GitHub release, open it, and move `I Hate PDFs.app` into `/Applications`.
For local direct-download builds, the app may not be Developer ID notarized. If macOS blocks first launch, open Finder, Control-click `I Hate PDFs.app`, choose Open, then confirm. For local direct-download builds, the app may not be Developer ID notarized. If macOS blocks first launch, open Finder, Control-click `I Hate PDFs.app`, choose Open, then confirm.
@@ -102,6 +128,15 @@ The project is a Swift Package with two targets:
- `IHatePDFs`: SwiftUI macOS app, PDFKit bridge, toolbar, menus, sidebars, anchored comment popovers, opening, saving, sharing, and search. - `IHatePDFs`: SwiftUI macOS app, PDFKit bridge, toolbar, menus, sidebars, anchored comment popovers, opening, saving, sharing, and search.
Engineering rule: keep this a native macOS app with the smallest final bundle that still delivers the required fluidity and functionality. See `docs/ENGINEERING.md` before adding dependencies, bundled assets, PDF engines, runtimes, or broad architectural changes. Engineering rule: keep this a native macOS app with the smallest final bundle that still delivers the required fluidity and functionality. See `docs/ENGINEERING.md` before adding dependencies, bundled assets, PDF engines, runtimes, or broad architectural changes.
Use `docs/WORKFLOW_AUDIT.md` when checking whether a feature matches the intended user workflow before changing or releasing it.
Use `docs/RELEASE.md` when preparing a new version; it is the checklist for version bumps, release validation, size gates, and upload packaging.
Open source contribution policy:
- Contributions are accepted under GPL-2.0-only. See [LICENSE](LICENSE).
- Start with [CONTRIBUTING.md](CONTRIBUTING.md) before opening a pull request.
- UI pull requests must include before/after screenshots or a short screen recording.
- Screenshots, recordings, and committed media files included with a pull request must each be under 1 MB.
Useful checks: Useful checks:
@@ -110,35 +145,34 @@ swift test
swift build -c release swift build -c release
swift scripts/verify-sample-pdf.swift swift scripts/verify-sample-pdf.swift
swift scripts/verify-pdf-annotations.swift swift scripts/verify-pdf-annotations.swift
scripts/verify-release-artifacts.sh
``` ```
The PDF verification scripts generate and inspect standard highlight, underline, selected-text comment, reply, free-text, contents, and annotation relationship dictionaries. The PDF verification scripts generate and inspect standard highlight, underline, selected-text comment, reply, free-text, contents, and annotation relationship dictionaries.
The release artifact verifier checks the current direct-download app and DMG by default. Run `REQUIRE_APP_STORE_PKG=1 scripts/verify-release-artifacts.sh` after creating an App Store package.
Manual release QA for Preview, Acrobat Reader, and browser PDF viewers is documented in `docs/QA.md`. App Store packaging is documented in `docs/APP_STORE.md`. Manual release QA for Preview, Acrobat Reader, and browser PDF viewers is documented in `docs/QA.md`. App Store packaging is documented in `docs/APP_STORE.md`, and paste-ready App Store metadata is in `docs/APP_STORE_COPY.md`.
## Screenshots ## Screenshots
Screenshots live in `docs/screenshots`. Screenshots live in `docs/screenshots`.
Current repository screenshots: Current repository screenshots that are useful for local review:
- `docs/screenshots/no-document.png`
- `docs/screenshots/default-reading.png` - `docs/screenshots/default-reading.png`
- `docs/screenshots/highlight-comment-popover.png`
- `docs/screenshots/main-window.png` - `docs/screenshots/main-window.png`
- `docs/screenshots/comments-sidebar.png` - `docs/screenshots/comments-sidebar.png`
- `docs/screenshots/dark-mode-reading.png` - `docs/screenshots/dark-mode-reading.png`
- `docs/screenshots/preview-interoperability.png`
![No document open](docs/screenshots/no-document.png) `docs/screenshots/no-document.png` and `docs/screenshots/highlight-comment-popover.png` are capture targets that need to be retaken before public release docs use them.
![Default reading mode](docs/screenshots/default-reading.png) ![Default reading mode](docs/screenshots/default-reading.png)
![Highlight comment popover](docs/screenshots/highlight-comment-popover.png)
![Comments sidebar](docs/screenshots/comments-sidebar.png) ![Comments sidebar](docs/screenshots/comments-sidebar.png)
![Dark mode reading](docs/screenshots/dark-mode-reading.png) ![Dark mode reading](docs/screenshots/dark-mode-reading.png)
## License ## License
MIT. See `LICENSE`. GNU General Public License version 2 only. See [LICENSE](LICENSE).

View File

@@ -1,30 +1,33 @@
# Release Notes # Release Notes
## I Hate PDFs v0.3.0 ## I Hate PDFs v0.4.0
Version 0.3 makes I Hate PDFs much safer and more comfortable for everyday PDF review. Version 0.4 is focused on keeping the app small, fast, and reader-first. The experimental Fill & Sign, form-field navigation, and PDF signing work has been removed from this release line and will be revisited later from scratch.
### What's New ### What's New
- Focused document opening: PDFs open in the reader with sidebars hidden and the page fit to available width.
- Smaller app surface area after removing the experimental Fill & Sign/signing implementation.
- Recent PDFs in the empty window and File > Open Recent.
- Settings for highlight and comment colors, including opacity. - Settings for highlight and comment colors, including opacity.
- Drag-and-drop opening from the empty app window.
- Standalone highlights that do not open a comment editor. - Standalone highlights that do not open a comment editor.
- Return saves a comment or reply; Shift-Return inserts a new line. - Return saves a comment or reply; Shift-Return inserts a new line.
- Better default highlight contrast.
- Mac App Store packaging for `net.akkolli.ihatepdfs`. - Mac App Store packaging for `net.akkolli.ihatepdfs`.
### Reliability Fixes ### Reliability Fixes
- Comment text now survives when saved PDFs are opened in macOS Preview and Adobe Acrobat. - Comment text survives when saved PDFs are opened in macOS Preview and Adobe Acrobat.
- The comment editor focuses correctly when a new selected-text comment is created. - The comment editor focuses correctly when a new selected-text comment is created.
- Comment popovers now open from the actual annotated text instead of nearby whitespace. - Comment popovers open from the actual annotated text instead of nearby whitespace.
- The app warns before unsaved annotations or reply drafts are lost. - The app warns before unsaved annotations or reply drafts are lost.
- Search highlights clear correctly when search is closed or edited. - Search highlights clear correctly when search is closed or edited.
- The comments sidebar keeps threads, filters, replies, and selected highlights in sync. - The comments sidebar keeps threads, filters, replies, and selected highlights in sync.
- PDFKit page/selection observers are cleaned up when the reader view is reattached.
- Sidebar toggles now close the active sidebar mode directly instead of switching modes first.
### Version ### Version
- App version: `0.3.0` - App version: `0.4.0`
- Build number: `4` - Build number: `6`
- Direct-download DMG name: `IHatePDFs-v0.3-macos.dmg` - Direct-download DMG name: `IHatePDFs-v0.4-macos.dmg`
- Mac App Store package name: `IHatePDFs-v0.3-macos-appstore.pkg` - Mac App Store package name: `IHatePDFs-v0.4-macos-appstore.pkg`

View File

@@ -7,7 +7,7 @@
- Reading controls: scrolling, zoom, fit width, fit page, page navigation, search. - Reading controls: scrolling, zoom, fit width, fit page, page navigation, search.
- Focused default reading mode with optional page thumbnail sidebar. - Focused default reading mode with optional page thumbnail sidebar.
- Highlight, underline, selection-bound comment, and free-text annotations. - Highlight, underline, selection-bound comment, and free-text annotations.
- Anchored comment popovers from newly created selected-text comments, highlights, underlines, free text, and clicked annotations. - Anchored comment popovers from newly created selected-text comments, underlines, free text, and clicked comment-capable annotations; plain highlights remain standalone.
- Annotation list sidebar. - Annotation list sidebar.
- Optional comments review sidebar with grouping, collapsed filtering, replies, and navigation. - Optional comments review sidebar with grouping, collapsed filtering, replies, and navigation.
- Save, Save As, and native macOS sharing with standard PDF annotation writing. - Save, Save As, and native macOS sharing with standard PDF annotation writing.
@@ -25,6 +25,14 @@
- Safer close/open/quit prompts for unsaved annotations and reply drafts. - Safer close/open/quit prompts for unsaved annotations and reply drafts.
- Mac App Store packaging path for `net.akkolli.ihatepdfs`. - Mac App Store packaging path for `net.akkolli.ihatepdfs`.
## Preparing Version 0.4
- Keep the reader focused on open: sidebars are hidden and the PDF is fit to the available width.
- Preserve the lightweight annotation workflow: highlight, underline, selected-text comments, free text, replies, search, bookmarks, and native sharing.
- Remove the experimental Fill & Sign, form-field navigation, and PDF signing implementation from v0.4.
- Keep the direct-download DMG and per-architecture archives under the release size budget.
- Release metadata, docs, and packaging names prepared for `0.4.0` build `6`.
## Next ## Next
- More explicit visual selection handles for the active annotation. - More explicit visual selection handles for the active annotation.
@@ -34,6 +42,7 @@
- Fully standards-compliant reply-thread relationships through a lower-level PDF writer if PDFKit continues rejecting object-valued `/IRT`. - Fully standards-compliant reply-thread relationships through a lower-level PDF writer if PDFKit continues rejecting object-valued `/IRT`.
- Stronger interoperability test corpus covering Preview, Acrobat Reader, and browser PDF viewers. - Stronger interoperability test corpus covering Preview, Acrobat Reader, and browser PDF viewers.
- Import/export verification fixtures for existing annotated PDFs. - Import/export verification fixtures for existing annotated PDFs.
- Revisit form fill and signing later as a smaller, cleaner design.
## Later ## Later

25
SECURITY.md Normal file
View File

@@ -0,0 +1,25 @@
# Security Policy
I Hate PDFs works with user-selected local PDFs and does not require an account, analytics, or cloud upload.
## Supported Versions
Security fixes target the latest public release and the `main` branch.
## Reporting A Vulnerability
Do not report security vulnerabilities in public issues.
Use GitHub private vulnerability reporting if it is enabled for this repository. If it is not enabled, contact the maintainer through the product support page:
<https://www.akkolli.net/ihatepdfs>
Include:
- A clear description of the issue
- Reproduction steps
- A minimal sample PDF when one is required to reproduce the issue
- Your macOS version
- The app version and build number
The maintainer will assess impact, prepare a fix when needed, and coordinate disclosure timing before public details are posted.

19
SUPPORT.md Normal file
View File

@@ -0,0 +1,19 @@
# Support
Use the public issue tracker for reproducible bugs, feature requests, and documentation problems:
<https://github.com/akkolli/ihatepdfs/issues>
Use the product and support page for user support:
<https://www.akkolli.net/ihatepdfs>
Before opening an issue, include:
- I Hate PDFs version and build number
- macOS version
- Whether the app came from GitHub releases or the Mac App Store
- A short description of the PDF workflow involved
- Screenshots or recordings for UI problems, each under 1 MB
Do not open public issues for security vulnerabilities. Follow `SECURITY.md`.

View File

@@ -11,6 +11,19 @@ enum AppSettings {
private static let minimumHighlightAlpha: CGFloat = 0.38 private static let minimumHighlightAlpha: CGFloat = 0.38
private static let minimumCommentAlpha: CGFloat = 0.12 private static let minimumCommentAlpha: CGFloat = 0.12
static var highlightSwatches: [(name: String, color: NSColor)] {
[
("Yellow", AcademicAnnotationPalette.highlight),
("Green", NSColor(calibratedRed: 0.27, green: 0.78, blue: 0.28, alpha: 0.58)),
("Aqua", NSColor(calibratedRed: 0.0, green: 0.70, blue: 0.78, alpha: 0.58)),
("Pink", NSColor(calibratedRed: 1.0, green: 0.32, blue: 0.62, alpha: 0.58)),
("Orange", NSColor(calibratedRed: 1.0, green: 0.48, blue: 0.08, alpha: 0.58)),
("Purple", NSColor(calibratedRed: 0.62, green: 0.36, blue: 0.94, alpha: 0.58)),
("Cyan", NSColor(calibratedRed: 0.0, green: 0.78, blue: 0.92, alpha: 0.56)),
("Graphite", NSColor(calibratedWhite: 0.28, alpha: 0.50))
]
}
static var highlightColor: NSColor { static var highlightColor: NSColor {
get { get {
highlightColor(from: UserDefaults.standard.string(forKey: highlightColorStorageKey)) highlightColor(from: UserDefaults.standard.string(forKey: highlightColorStorageKey))
@@ -61,6 +74,10 @@ enum AppSettings {
storageString(forHighlightColor: NSColor(color)) storageString(forHighlightColor: NSColor(color))
} }
static func displayColor(forHighlightColor color: NSColor) -> NSColor {
highlightColor(from: storageString(for: color)).withAlphaComponent(1)
}
static func storageString(forCommentColor color: NSColor) -> String { static func storageString(forCommentColor color: NSColor) -> String {
storageString(for: commentColor(from: storageString(for: color))) storageString(for: commentColor(from: storageString(for: color)))
} }

File diff suppressed because it is too large Load Diff

View File

@@ -45,7 +45,6 @@ final class CommentPopoverModel: ObservableObject {
struct CommentEditorView: View { struct CommentEditorView: View {
@ObservedObject var model: CommentPopoverModel @ObservedObject var model: CommentPopoverModel
@Environment(\.colorScheme) private var colorScheme @Environment(\.colorScheme) private var colorScheme
@FocusState private var isCommentFocused: Bool
private let editorHorizontalInset: CGFloat = 9 private let editorHorizontalInset: CGFloat = 9
private let editorVerticalInset: CGFloat = 7 private let editorVerticalInset: CGFloat = 7
@@ -58,20 +57,12 @@ struct CommentEditorView: View {
.padding(12) .padding(12)
.frame(width: 340) .frame(width: 340)
.background(.regularMaterial) .background(.regularMaterial)
.onAppear {
DispatchQueue.main.async {
isCommentFocused = true
}
}
.onChange(of: model.text) { _ in .onChange(of: model.text) { _ in
model.updateDraft() model.updateDraft()
} }
.onChange(of: model.author) { _ in .onChange(of: model.author) { _ in
model.updateDraft() model.updateDraft()
} }
.commitOnPlainReturn {
model.commit()
}
} }
private var header: some View { private var header: some View {
@@ -83,32 +74,23 @@ struct CommentEditorView: View {
Text(title) Text(title)
.font(.headline) .font(.headline)
.lineLimit(1) .lineLimit(1)
Spacer()
Button {
model.commit()
} label: {
Label("Done", systemImage: "checkmark")
}
.labelStyle(.iconOnly)
.keyboardShortcut(.return, modifiers: [.command])
.help("Done")
} }
} }
private var commentField: some View { private var commentField: some View {
ZStack(alignment: .topLeading) { ZStack(alignment: .topLeading) {
TextEditor(text: $model.text) CommitTextView(
.font(.body) text: $model.text,
.foregroundStyle(InterfacePalette.primaryText(for: colorScheme)) font: NSFont.preferredFont(forTextStyle: .body),
.scrollContentBackground(.hidden) onCommit: {
.focused($isCommentFocused) model.commit()
}
)
.padding(.horizontal, editorHorizontalInset) .padding(.horizontal, editorHorizontalInset)
.padding(.vertical, editorVerticalInset) .padding(.vertical, editorVerticalInset)
if model.text.isEmpty { if model.text.isEmpty {
Text("Add comment") Text(placeholderText)
.font(.body) .font(.body)
.foregroundStyle(InterfacePalette.quietText(for: colorScheme)) .foregroundStyle(InterfacePalette.quietText(for: colorScheme))
.padding(.leading, editorHorizontalInset + 7) .padding(.leading, editorHorizontalInset + 7)
@@ -130,6 +112,9 @@ struct CommentEditorView: View {
TextField("Author", text: $model.author) TextField("Author", text: $model.author)
.textFieldStyle(.plain) .textFieldStyle(.plain)
.foregroundStyle(InterfacePalette.primaryText(for: colorScheme)) .foregroundStyle(InterfacePalette.primaryText(for: colorScheme))
.onSubmit {
model.commit()
}
.padding(.horizontal, 7) .padding(.horizontal, 7)
.frame(height: 28) .frame(height: 28)
.background(InterfacePalette.fieldFill(for: colorScheme)) .background(InterfacePalette.fieldFill(for: colorScheme))
@@ -143,7 +128,8 @@ struct CommentEditorView: View {
Spacer() Spacer()
if !model.context.isNewAnnotation, if model.context.allowsReply,
!model.context.isNewAnnotation,
model.context.primaryAnnotation != nil { model.context.primaryAnnotation != nil {
Button { Button {
model.reply() model.reply()
@@ -157,6 +143,17 @@ struct CommentEditorView: View {
} }
if model.context.allowsDelete { if model.context.allowsDelete {
if model.context.isNewAnnotation {
Button {
model.delete()
} label: {
Label("Cancel", systemImage: "xmark")
}
.labelStyle(.iconOnly)
.keyboardShortcut(.cancelAction)
.frame(width: 34)
.help("Cancel")
} else {
Button(role: .destructive) { Button(role: .destructive) {
model.delete() model.delete()
} label: { } label: {
@@ -168,11 +165,16 @@ struct CommentEditorView: View {
} }
} }
} }
}
private var title: String { private var title: String {
model.context.title.replacingOccurrences(of: " Comment", with: "") model.context.title.replacingOccurrences(of: " Comment", with: "")
} }
private var placeholderText: String {
model.context.allowsReply ? "Add comment" : "Edit text"
}
private var symbolName: String { private var symbolName: String {
guard let annotation = model.context.primaryAnnotation else { guard let annotation = model.context.primaryAnnotation else {
return "text.bubble" return "text.bubble"

View File

@@ -0,0 +1,106 @@
import AppKit
import IHatePDFsCore
import SwiftUI
struct CommitTextView: NSViewRepresentable {
@Binding var text: String
var font: NSFont
var focusOnAppear = true
var onCommit: () -> Void
func makeCoordinator() -> Coordinator {
Coordinator(text: $text)
}
func makeNSView(context: Context) -> NSScrollView {
let scrollView = NSScrollView()
scrollView.drawsBackground = false
scrollView.borderType = .noBorder
scrollView.hasVerticalScroller = true
scrollView.autohidesScrollers = true
let textView = CommitTextNSTextView()
textView.delegate = context.coordinator
textView.string = text
textView.font = font
textView.textColor = .labelColor
textView.drawsBackground = false
textView.isRichText = false
textView.importsGraphics = false
textView.allowsUndo = true
textView.isVerticallyResizable = true
textView.isHorizontallyResizable = false
textView.autoresizingMask = [.width]
textView.textContainerInset = .zero
textView.textContainer?.lineFragmentPadding = 0
textView.textContainer?.widthTracksTextView = true
textView.onCommit = onCommit
scrollView.documentView = textView
context.coordinator.textView = textView
return scrollView
}
func updateNSView(_ scrollView: NSScrollView, context: Context) {
guard let textView = scrollView.documentView as? CommitTextNSTextView else { return }
textView.onCommit = onCommit
textView.font = font
if textView.string != text {
textView.string = text
}
guard focusOnAppear,
!context.coordinator.didFocus,
textView.window != nil
else {
return
}
context.coordinator.didFocus = true
DispatchQueue.main.async { [weak textView] in
guard let textView else { return }
textView.window?.makeFirstResponder(textView)
}
}
final class Coordinator: NSObject, NSTextViewDelegate {
@Binding var text: String
weak var textView: NSTextView?
var didFocus = false
init(text: Binding<String>) {
_text = text
}
func textDidChange(_ notification: Notification) {
guard let textView = notification.object as? NSTextView else { return }
text = textView.string
}
}
}
private final class CommitTextNSTextView: NSTextView {
var onCommit: (() -> Void)?
override func keyDown(with event: NSEvent) {
guard !hasMarkedText() else {
super.keyDown(with: event)
return
}
if ReturnKeyCommitPolicy.shouldCommit(
keyCode: UInt16(event.keyCode),
shift: event.modifierFlags.contains(.shift),
option: event.modifierFlags.contains(.option),
command: event.modifierFlags.contains(.command),
control: event.modifierFlags.contains(.control),
isEditableMultilineText: isEditable && !isFieldEditor
) {
onCommit?()
return
}
super.keyDown(with: event)
}
}

View File

@@ -1,4 +1,5 @@
import AppKit import AppKit
import IHatePDFsCore
import SwiftUI import SwiftUI
@main @main
@@ -22,6 +23,10 @@ struct IHatePDFsApp: App {
@MainActor @MainActor
private final class AppDelegate: NSObject, NSApplicationDelegate { private final class AppDelegate: NSObject, NSApplicationDelegate {
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
true
}
func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply { func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply {
AppStateRegistry.shared.confirmApplicationShouldTerminate() AppStateRegistry.shared.confirmApplicationShouldTerminate()
? .terminateNow ? .terminateNow
@@ -68,6 +73,21 @@ private final class AppStateRegistry {
isTerminationApproved = false isTerminationApproved = false
} }
func closeOtherEmptyWindows(keeping activeAppState: AppState) {
prune()
for appState in appStates.compactMap(\.value) where appState !== activeAppState {
guard appState.document == nil,
!appState.hasUnsavedWork,
let window = appState.hostingWindow
else {
continue
}
window.close()
}
}
private func prune() { private func prune() {
appStates.removeAll { $0.value == nil } appStates.removeAll { $0.value == nil }
} }
@@ -91,6 +111,9 @@ private struct AppWindowRoot: View {
.background(WindowCloseGuard(appState: appState)) .background(WindowCloseGuard(appState: appState))
.onOpenURL { url in .onOpenURL { url in
appState.loadDocument(from: url) appState.loadDocument(from: url)
if appState.documentURL == url {
AppStateRegistry.shared.closeOtherEmptyWindows(keeping: appState)
}
} }
.onAppear { .onAppear {
AppStateRegistry.shared.register(appState) AppStateRegistry.shared.register(appState)
@@ -144,6 +167,7 @@ private struct WindowCloseGuard: NSViewRepresentable {
self.window = window self.window = window
previousDelegate = window?.delegate previousDelegate = window?.delegate
appState?.hostingWindow = window
if window?.delegate !== self { if window?.delegate !== self {
window?.delegate = self window?.delegate = self
@@ -185,6 +209,7 @@ private struct WindowCloseGuard: NSViewRepresentable {
if window?.delegate === self { if window?.delegate === self {
window?.delegate = previousDelegate window?.delegate = previousDelegate
} }
appState?.hostingWindow = nil
window = nil window = nil
previousDelegate = nil previousDelegate = nil
} }
@@ -226,10 +251,18 @@ private struct AppCommands: Commands {
appState?.hasTextSelection == true appState?.hasTextSelection == true
} }
private var isHighlighterModeActive: Bool {
appState?.isHighlighterModeActive == true
}
private var canSaveDocument: Bool { private var canSaveDocument: Bool {
appState?.canSaveDocument == true appState?.canSaveDocument == true
} }
private var recentDocumentURLs: [URL] {
appState?.recentDocumentURLs ?? []
}
private var saveHelpText: String { private var saveHelpText: String {
appState?.saveHelpText ?? "Open a PDF before saving." appState?.saveHelpText ?? "Open a PDF before saving."
} }
@@ -242,6 +275,27 @@ private struct AppCommands: Commands {
.keyboardShortcut("o") .keyboardShortcut("o")
.disabled(appState == nil) .disabled(appState == nil)
Menu("Open Recent") {
if recentDocumentURLs.isEmpty {
Button("No Recent PDFs") {}
.disabled(true)
} else {
ForEach(recentDocumentURLs, id: \.self) { url in
Button(url.lastPathComponent) {
appState?.openRecentDocument(url)
}
.help(url.path)
}
Divider()
Button("Clear Menu") {
appState?.clearRecentDocuments()
}
}
}
.disabled(appState == nil)
Button("Save") { Button("Save") {
appState?.saveDocument() appState?.saveDocument()
} }
@@ -266,14 +320,19 @@ private struct AppCommands: Commands {
Button("Settings...") { Button("Settings...") {
openSettingsWindow() openSettingsWindow()
} }
.keyboardShortcut(",")
Divider() Divider()
Button("Close PDF") { Button(hasDocument ? "Close PDF" : "Close Window") {
if hasDocument {
appState?.closeDocument() appState?.closeDocument()
} else {
NSApp.keyWindow?.performClose(nil)
}
} }
.keyboardShortcut("w") .keyboardShortcut("w")
.disabled(!hasDocument) .disabled(appState == nil)
} }
CommandGroup(after: .textEditing) { CommandGroup(after: .textEditing) {
@@ -296,15 +355,21 @@ private struct AppCommands: Commands {
.disabled(appState?.searchResults.isEmpty != false) .disabled(appState?.searchResults.isEmpty != false)
} }
CommandMenu("View") { CommandGroup(after: .toolbar) {
Button("Toggle Page Sidebar") { Button("Toggle Page Sidebar") {
appState?.showLeftSidebar.toggle() appState?.togglePageSidebar()
} }
.keyboardShortcut("0", modifiers: [.command, .option]) .keyboardShortcut("0", modifiers: [.command, .option])
.disabled(!hasDocument) .disabled(!hasDocument)
Button("Toggle Comments Sidebar") { Button("Toggle Annotation List") {
appState?.showCommentsSidebar.toggle() appState?.toggleAnnotationSidebar()
}
.keyboardShortcut("2", modifiers: [.command, .option])
.disabled(!hasDocument)
Button("Toggle Right Sidebar") {
appState?.toggleRightSidebarVisibility()
} }
.keyboardShortcut("1", modifiers: [.command, .option]) .keyboardShortcut("1", modifiers: [.command, .option])
.disabled(!hasDocument) .disabled(!hasDocument)
@@ -343,11 +408,11 @@ private struct AppCommands: Commands {
} }
CommandMenu("Annotate") { CommandMenu("Annotate") {
Button("Highlight Selection") { Button(isHighlighterModeActive ? "Turn Highlighter Off" : "Turn Highlighter On") {
appState?.addHighlight() appState?.toggleHighlighterMode()
} }
.keyboardShortcut("h", modifiers: [.command, .shift]) .keyboardShortcut("h", modifiers: [.command, .shift])
.disabled(!hasDocument || !hasTextSelection) .disabled(!hasDocument)
Button("Underline Selection") { Button("Underline Selection") {
appState?.addUnderline() appState?.addUnderline()
@@ -368,6 +433,20 @@ private struct AppCommands: Commands {
.disabled(!hasDocument) .disabled(!hasDocument)
} }
CommandMenu("Bookmark") {
Button(appState?.bookmarkActionTitle ?? "Add Bookmark") {
appState?.toggleBookmarkForCurrentPage()
}
.keyboardShortcut("b", modifiers: [.command])
.disabled(!hasDocument)
Button("Go to Bookmark") {
appState?.goToSavedBookmark()
}
.keyboardShortcut("b", modifiers: [.command, .option])
.disabled(appState?.savedBookmark == nil)
}
CommandGroup(after: .windowArrangement) { CommandGroup(after: .windowArrangement) {
Button("Minimize") { Button("Minimize") {
appState?.minimizeWindow() appState?.minimizeWindow()

View File

@@ -1,13 +1,17 @@
import IHatePDFsCore import AppKit
import SwiftUI import SwiftUI
import UniformTypeIdentifiers import UniformTypeIdentifiers
struct MainView: View { struct MainView: View {
@EnvironmentObject private var appState: AppState @EnvironmentObject private var appState: AppState
@State private var leftSidebarWidth: CGFloat = ReaderAdaptiveLayout(sizeClass: .regular).leftSidebarIdealWidth
@State private var rightSidebarWidth: CGFloat = ReaderAdaptiveLayout(sizeClass: .regular).rightSidebarIdealWidth
@State private var leftSidebarDragStartWidth: CGFloat?
@State private var rightSidebarDragStartWidth: CGFloat?
var body: some View { var body: some View {
GeometryReader { proxy in GeometryReader { proxy in
content content(availableWidth: proxy.size.width)
.onAppear { .onAppear {
appState.updateWindowWidth(proxy.size.width) appState.updateWindowWidth(proxy.size.width)
} }
@@ -16,81 +20,159 @@ struct MainView: View {
} }
} }
.navigationTitle(appState.displayTitle) .navigationTitle(appState.displayTitle)
.frame(minWidth: 820, minHeight: 620) .frame(
minWidth: ReaderAdaptiveLayout.minimumWindowWidth,
minHeight: ReaderAdaptiveLayout.minimumWindowHeight
)
.toolbar { .toolbar {
ReaderToolbar() ReaderToolbar()
} }
} }
private var content: some View { private func content(availableWidth: CGFloat) -> some View {
VStack(spacing: 0) { let layout = ReaderAdaptiveLayout(width: availableWidth)
let showsRightSidebar = appState.showCommentsSidebar
let showsLeftSidebar = appState.showLeftSidebar && (layout.allowsDualSidebars || !showsRightSidebar)
let sidebarWidths = layout.resolvedSidebarWidths(
availableWidth: availableWidth,
requestedLeft: leftSidebarWidth,
requestedRight: rightSidebarWidth,
showLeft: showsLeftSidebar,
showRight: showsRightSidebar
)
return VStack(spacing: 0) {
if appState.document == nil { if appState.document == nil {
EmptyDocumentView() EmptyDocumentView()
} else { } else {
HSplitView { VStack(spacing: 0) {
if appState.showLeftSidebar { HStack(spacing: 0) {
if showsLeftSidebar {
LeftSidebarView() LeftSidebarView()
.frame(minWidth: 170, idealWidth: 210, maxWidth: 280) .frame(width: sidebarWidths.left)
.transition(
.asymmetric(
insertion: .move(edge: .leading).combined(with: .opacity),
removal: .opacity
)
)
SidebarResizeHandle()
.gesture(leftSidebarResizeGesture(for: layout))
} }
PDFReaderView() PDFReaderView()
.frame(minWidth: 420) .frame(minWidth: layout.documentMinWidth, maxWidth: .infinity)
if appState.showCommentsSidebar { if showsRightSidebar {
CommentsReviewSidebar() SidebarResizeHandle()
.frame(minWidth: 260, idealWidth: 310, maxWidth: 400) .gesture(rightSidebarResizeGesture(for: layout))
RightSidebarView()
.frame(width: sidebarWidths.right)
.transition(
.asymmetric(
insertion: .move(edge: .trailing).combined(with: .opacity),
removal: .opacity
)
)
} }
} }
.animation(.easeInOut(duration: 0.18), value: appState.showLeftSidebar)
.animation(.easeInOut(duration: 0.18), value: appState.showCommentsSidebar)
.animation(.easeInOut(duration: 0.18), value: appState.readerSizeClass)
}
} }
StatusBarView() StatusBarView()
} }
} }
private func leftSidebarResizeGesture(for layout: ReaderAdaptiveLayout) -> some Gesture {
DragGesture(minimumDistance: 0)
.onChanged { value in
if leftSidebarDragStartWidth == nil {
leftSidebarDragStartWidth = leftSidebarWidth
}
let proposedWidth = (leftSidebarDragStartWidth ?? leftSidebarWidth) + value.translation.width
leftSidebarWidth = layout.clampedLeftWidth(proposedWidth)
}
.onEnded { _ in
leftSidebarDragStartWidth = nil
}
}
private func rightSidebarResizeGesture(for layout: ReaderAdaptiveLayout) -> some Gesture {
DragGesture(minimumDistance: 0)
.onChanged { value in
if rightSidebarDragStartWidth == nil {
rightSidebarDragStartWidth = rightSidebarWidth
}
let proposedWidth = (rightSidebarDragStartWidth ?? rightSidebarWidth) - value.translation.width
rightSidebarWidth = layout.clampedRightWidth(proposedWidth)
}
.onEnded { _ in
rightSidebarDragStartWidth = nil
}
}
}
private struct SidebarResizeHandle: View {
@Environment(\.colorScheme) private var colorScheme
@State private var isHovering = false
var body: some View {
Rectangle()
.fill(Color.clear)
.frame(width: ReaderAdaptiveLayout.resizeHandleWidth)
.frame(maxHeight: .infinity)
.overlay(alignment: .center) {
Capsule()
.fill(InterfacePalette.hairline(for: colorScheme).opacity(isHovering ? 0.95 : 0.58))
.frame(width: isHovering ? 2 : 1, height: isHovering ? 42 : 30)
.animation(.easeInOut(duration: 0.12), value: isHovering)
}
.background {
Rectangle()
.fill(Color.accentColor.opacity(isHovering ? 0.05 : 0))
}
.contentShape(Rectangle())
.onHover { hovering in
isHovering = hovering
if hovering {
NSCursor.resizeLeftRight.push()
} else {
NSCursor.pop()
}
}
.onDisappear {
if isHovering {
NSCursor.pop()
}
}
.help("Resize Sidebar")
}
} }
private struct PDFReaderView: View { private struct PDFReaderView: View {
var body: some View {
PDFKitRepresentedView()
.background(Color(nsColor: .windowBackgroundColor))
}
}
private struct EmptyDocumentView: View {
@EnvironmentObject private var appState: AppState @EnvironmentObject private var appState: AppState
@State private var isDropTargeted = false @State private var isDropTargeted = false
var body: some View { var body: some View {
VStack(spacing: 16) { ZStack(alignment: .bottom) {
Image(systemName: isDropTargeted ? "tray.and.arrow.down" : "doc.richtext") PDFKitRepresentedView()
.font(.system(size: 48, weight: .regular))
.foregroundColor(isDropTargeted ? .accentColor : .secondary)
Text("Open a PDF")
.font(.title2)
Text("Use standard PDF annotations for selected-text comments, highlights, underlines, and free text.")
.font(.callout)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.frame(maxWidth: 420)
Button {
appState.openDocument()
} label: {
Label("Open PDF", systemImage: "folder")
}
.keyboardShortcut("o")
.controlSize(.large)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color(nsColor: .windowBackgroundColor)) .background(Color(nsColor: .windowBackgroundColor))
.overlay {
RoundedRectangle(cornerRadius: 8) if appState.canShowSelectionActions {
.stroke( SelectionActionBar()
isDropTargeted ? Color.accentColor : Color.clear, .padding(.bottom, 14)
style: StrokeStyle(lineWidth: 2, dash: [8, 6]) .transition(.opacity.combined(with: .move(edge: .bottom)))
) }
.padding(18)
if isDropTargeted {
DropTargetOverlay()
.transition(.opacity.combined(with: .scale(scale: 0.985)))
}
} }
.onDrop( .onDrop(
of: [UTType.fileURL.identifier], of: [UTType.fileURL.identifier],
@@ -98,13 +180,277 @@ private struct EmptyDocumentView: View {
) { providers in ) { providers in
appState.openDroppedDocument(from: providers) appState.openDroppedDocument(from: providers)
} }
.animation(.easeInOut(duration: 0.14), value: appState.canShowSelectionActions)
.animation(.easeInOut(duration: 0.16), value: isDropTargeted)
}
}
private struct SelectionActionBar: View {
@EnvironmentObject private var appState: AppState
@Environment(\.colorScheme) private var colorScheme
@AppStorage(AppSettings.highlightColorStorageKey)
private var storedHighlightColor = AppSettings.defaultHighlightColorStorageValue
private var activeHighlightDisplayColor: Color {
Color(nsColor: AppSettings.displayColor(forHighlightColor: AppSettings.highlightColor(from: storedHighlightColor)))
}
var body: some View {
HStack(spacing: 2) {
actionButton(
"Highlight (H)",
systemImage: "highlighter",
foregroundStyle: activeHighlightDisplayColor
) {
appState.addHighlight()
}
actionButton(
"Underline (U)",
systemImage: "underline",
foregroundStyle: InterfacePalette.primaryText(for: colorScheme)
) {
appState.addUnderline()
}
actionButton(
"Comment (C)",
systemImage: "text.bubble",
foregroundStyle: InterfacePalette.primaryText(for: colorScheme)
) {
appState.addComment()
}
}
.controlSize(.small)
.padding(5)
.background(.regularMaterial, in: Capsule())
.overlay {
Capsule()
.stroke(InterfacePalette.hairline(for: colorScheme), lineWidth: 1)
}
.shadow(color: Color.black.opacity(colorScheme == .dark ? 0.28 : 0.12), radius: 12, y: 5)
}
private func actionButton(
_ title: String,
systemImage: String,
foregroundStyle: Color,
action: @escaping () -> Void
) -> some View {
Button(action: action) {
Image(systemName: systemImage)
.font(.system(size: 14, weight: .semibold))
.symbolRenderingMode(.hierarchical)
.foregroundStyle(foregroundStyle)
.frame(width: 30, height: 28)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.help(title)
.accessibilityLabel(title)
}
}
private struct EmptyDocumentView: View {
@EnvironmentObject private var appState: AppState
@Environment(\.colorScheme) private var colorScheme
@State private var isDropTargeted = false
var body: some View {
ZStack {
VStack(spacing: 24) {
VStack(spacing: 12) {
Image(systemName: "doc.text.magnifyingglass")
.font(.system(size: 46, weight: .regular))
.symbolRenderingMode(.hierarchical)
.foregroundStyle(InterfacePalette.quietText(for: colorScheme))
.frame(width: 62, height: 62)
Text("Open a PDF")
.font(.title2.weight(.semibold))
Button {
appState.openDocument()
} label: {
Label("Open PDF", systemImage: "doc")
}
.keyboardShortcut("o")
.keyboardShortcut(.defaultAction)
.controlSize(.large)
}
RecentPDFsView()
.frame(maxWidth: 420)
}
.padding(.horizontal, 28)
.padding(.vertical, 36)
.frame(maxWidth: .infinity, maxHeight: .infinity)
if isDropTargeted {
DropTargetOverlay()
.transition(.opacity.combined(with: .scale(scale: 0.985)))
}
}
.background(Color(nsColor: .windowBackgroundColor))
.onDrop(
of: [UTType.fileURL.identifier],
isTargeted: $isDropTargeted
) { providers in
appState.openDroppedDocument(from: providers)
}
.animation(.easeInOut(duration: 0.16), value: isDropTargeted)
.onAppear {
appState.refreshRecentDocuments()
}
}
}
private struct RecentPDFsView: View {
@EnvironmentObject private var appState: AppState
@Environment(\.colorScheme) private var colorScheme
private var recentPDFs: [RecentDocumentItem] {
Array(appState.recentDocuments.prefix(5))
}
var body: some View {
if !recentPDFs.isEmpty {
VStack(alignment: .leading, spacing: 8) {
HStack {
Text("Recent")
.font(.callout.weight(.semibold))
Spacer()
Button {
appState.clearRecentDocuments()
} label: {
Label("Clear Recent PDFs", systemImage: "xmark.circle")
}
.labelStyle(.iconOnly)
.buttonStyle(.plain)
.foregroundStyle(InterfacePalette.secondaryText(for: colorScheme))
.help("Clear Recent PDFs")
}
ForEach(recentPDFs, id: \.id) { url in
Button {
appState.openRecentDocument(url.url)
} label: {
RecentPDFRow(item: url)
}
.buttonStyle(.plain)
.help(url.url.path)
}
}
.padding(.top, 2)
}
}
}
private struct RecentPDFRow: View {
@Environment(\.colorScheme) private var colorScheme
let item: RecentDocumentItem
private var detailText: String {
let pieces = [item.pageText, item.openedAt.map { Self.relativeDateFormatter.localizedString(for: $0, relativeTo: Date()) }]
.compactMap { $0 }
if pieces.isEmpty {
return item.folderName
}
return pieces.joined(separator: " - ")
}
var body: some View {
HStack(spacing: 10) {
Image(systemName: "doc.text")
.font(.system(size: 16, weight: .regular))
.symbolRenderingMode(.hierarchical)
.foregroundStyle(InterfacePalette.secondaryText(for: colorScheme))
.frame(width: 24, height: 24)
VStack(alignment: .leading, spacing: 2) {
Text(item.title)
.font(.callout)
.foregroundStyle(InterfacePalette.primaryText(for: colorScheme))
.lineLimit(1)
.truncationMode(.middle)
Text(detailText)
.font(.caption)
.foregroundStyle(InterfacePalette.secondaryText(for: colorScheme))
.lineLimit(1)
.truncationMode(.middle)
}
Spacer(minLength: 8)
Image(systemName: "chevron.right")
.font(.caption.weight(.semibold))
.foregroundStyle(InterfacePalette.quietText(for: colorScheme))
}
.padding(.horizontal, 10)
.frame(height: 46)
.contentShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
.background {
RoundedRectangle(cornerRadius: 8, style: .continuous)
.fill(InterfacePalette.subtleFill(for: colorScheme))
}
.overlay {
RoundedRectangle(cornerRadius: 8, style: .continuous)
.stroke(InterfacePalette.hairline(for: colorScheme), lineWidth: 1)
}
}
private static let relativeDateFormatter: RelativeDateTimeFormatter = {
let formatter = RelativeDateTimeFormatter()
formatter.unitsStyle = .abbreviated
formatter.dateTimeStyle = .named
return formatter
}()
}
private struct DropTargetOverlay: View {
@Environment(\.colorScheme) private var colorScheme
var body: some View {
VStack(spacing: 10) {
Image(systemName: "tray.and.arrow.down.fill")
.font(.system(size: 36, weight: .regular))
.symbolRenderingMode(.hierarchical)
Text("Drop to Open")
.font(.title3.weight(.semibold))
}
.foregroundStyle(Color.accentColor)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color(nsColor: .windowBackgroundColor).opacity(colorScheme == .dark ? 0.72 : 0.82))
.overlay {
RoundedRectangle(cornerRadius: 14, style: .continuous)
.stroke(
Color.accentColor.opacity(colorScheme == .dark ? 0.68 : 0.58),
style: StrokeStyle(lineWidth: 2, dash: [8, 6])
)
.padding(18)
}
} }
} }
private struct StatusBarView: View { private struct StatusBarView: View {
@EnvironmentObject private var appState: AppState @EnvironmentObject private var appState: AppState
private var annotationStatusText: String {
let count = appState.annotations.count
return count == 1 ? "1 annotation" : "\(count) annotations"
}
var body: some View { var body: some View {
if appState.isCompactWindow {
compactStatus
} else {
regularStatus
}
}
private var regularStatus: some View {
HStack(spacing: 12) { HStack(spacing: 12) {
Text(appState.statusMessage) Text(appState.statusMessage)
.lineLimit(1) .lineLimit(1)
@@ -116,7 +462,7 @@ private struct StatusBarView: View {
if appState.hasUnsentSidebarReplyDraft { if appState.hasUnsentSidebarReplyDraft {
Text("Reply draft") Text("Reply draft")
} }
Text("\(appState.annotations.count) annotations") Text(annotationStatusText)
Text("Page \(appState.currentPageIndex + 1) of \(max(appState.pageCount, 1))") Text("Page \(appState.currentPageIndex + 1) of \(max(appState.pageCount, 1))")
} }
} }
@@ -126,195 +472,390 @@ private struct StatusBarView: View {
.frame(height: 26) .frame(height: 26)
.background(.bar) .background(.bar)
} }
private var compactStatus: some View {
HStack(spacing: 8) {
Text(appState.statusMessage)
.lineLimit(1)
.truncationMode(.middle)
Spacer(minLength: 8)
if appState.document != nil {
if appState.hasUnsentSidebarReplyDraft {
Image(systemName: "text.bubble")
.help("Reply draft")
.accessibilityLabel("Reply draft")
}
Text("\(appState.currentPageIndex + 1)/\(max(appState.pageCount, 1))")
.font(.caption.monospacedDigit())
.lineLimit(1)
.fixedSize()
}
}
.font(.caption)
.foregroundStyle(.secondary)
.padding(.horizontal, 10)
.frame(height: 24)
.background(.bar)
}
} }
private struct ReaderToolbar: ToolbarContent { private struct ReaderToolbar: ToolbarContent {
@EnvironmentObject private var appState: AppState @EnvironmentObject private var appState: AppState
@AppStorage(AppSettings.highlightColorStorageKey)
private var storedHighlightColor = AppSettings.defaultHighlightColorStorageValue
@State private var showsHighlightPalette = false
@FocusState private var searchFocused: Bool @FocusState private var searchFocused: Bool
private var activeHighlightColor: NSColor {
AppSettings.highlightColor(from: storedHighlightColor)
}
private var activeHighlightDisplayColor: Color {
Color(nsColor: AppSettings.displayColor(forHighlightColor: activeHighlightColor))
}
private var pageNumberWidth: CGFloat {
CGFloat(max(2, String(max(appState.pageCount, 1)).count)) * (appState.isCompactWindow ? 8 : 8.4)
+ (appState.isCompactWindow ? 16 : 20)
}
private var totalPageNumberWidth: CGFloat {
CGFloat(max(1, String(max(appState.pageCount, 1)).count)) * (appState.isCompactWindow ? 7.8 : 8.2)
+ (appState.isCompactWindow ? 18 : 21)
}
private var pageSeparatorSpacing: CGFloat {
appState.isCompactWindow ? 28 : 46
}
private var pageControlWidth: CGFloat {
pageNumberWidth + totalPageNumberWidth + (appState.isCompactWindow ? 46 : 56)
}
private var compactToolbarControlsEnabled: Bool {
appState.isCompactWindow
}
private var annotationToolsMenu: some View {
Menu {
Button {
appState.toggleHighlighterMode()
} label: {
Label(
appState.isHighlighterModeActive
? "Turn Highlighter Off (H)"
: "Turn Highlighter On (H)",
systemImage: "highlighter"
)
}
.disabled(appState.document == nil)
Button {
appState.addUnderline()
} label: {
Label("Underline Selection (U)", systemImage: "underline")
}
.disabled(appState.document == nil || !appState.hasTextSelection)
Button {
appState.addComment()
} label: {
Label("Comment (C)", systemImage: "text.bubble")
}
.disabled(appState.document == nil || !appState.hasTextSelection)
} label: {
Image(systemName: "pencil.tip.crop.circle")
}
.help("Annotation Tools")
.disabled(appState.document == nil)
}
private var highlightColorButton: some View {
Button {
showsHighlightPalette.toggle()
} label: {
Image(systemName: "circle.fill")
.foregroundStyle(activeHighlightDisplayColor)
}
.popover(isPresented: $showsHighlightPalette, arrowEdge: .bottom) {
HighlightPalettePopover(
storedHighlightColor: $storedHighlightColor,
onSelect: { color in
showsHighlightPalette = false
appState.selectHighlightColor(color, applyToSelection: appState.hasTextSelection)
}
)
}
.help("Highlight Color")
.accessibilityLabel("Highlight Color Palette")
.disabled(appState.document == nil)
}
var body: some ToolbarContent { var body: some ToolbarContent {
ToolbarItemGroup(placement: .navigation) { if appState.document == nil {
ToolbarItemGroup(placement: .primaryAction) {
Button { Button {
appState.openDocument() appState.openDocument()
} label: { } label: {
Label("Open", systemImage: "folder") Label("Open", systemImage: "doc")
} }
.help("Open PDF") .help("Open PDF")
}
} else {
ToolbarItemGroup(placement: .navigation) {
Button {
appState.togglePageSidebar()
} label: {
Image(systemName: "square.grid.2x2")
}
.labelStyle(.iconOnly)
.disabled(appState.document == nil)
.help("Toggle Page Thumbnails")
Button { Button {
appState.showLeftSidebar.toggle() appState.toggleBookmarkForCurrentPage()
} label: { } label: {
Label("Pages", systemImage: "sidebar.left") Image(systemName: appState.currentPageBookmark == nil ? "bookmark" : "bookmark.fill")
} }
.labelStyle(.iconOnly)
.disabled(appState.document == nil) .disabled(appState.document == nil)
.help("Toggle Page Sidebar") .help(appState.bookmarkActionHelpText)
Button {
appState.showCommentsSidebar.toggle()
} label: {
Label("Comments Sidebar", systemImage: "sidebar.right")
}
.disabled(appState.document == nil)
.help(appState.showCommentsSidebar ? "Hide Comments Sidebar" : "Show Comments Sidebar")
.accessibilityLabel("Toggle Comments Sidebar")
} }
ToolbarItemGroup(placement: .principal) { ToolbarItem(placement: .principal) {
HStack(spacing: 5) {
Button { Button {
appState.goToPreviousPage() appState.goToPreviousPage()
} label: { } label: {
Label("Previous Page", systemImage: "chevron.up") Image(systemName: "chevron.up")
} }
.disabled(!appState.canGoToPreviousPage) .disabled(!appState.canGoToPreviousPage)
.help("Previous Page") .help("Previous Page")
TextField("Page", text: $appState.pageText) TextField("Page", text: $appState.pageText)
.textFieldStyle(.roundedBorder) .textFieldStyle(.plain)
.frame(width: 52) .multilineTextAlignment(.center)
.font(.system(size: 13, weight: .semibold, design: .rounded))
.frame(width: pageNumberWidth, height: compactToolbarControlsEnabled ? 21 : 22)
.onSubmit { .onSubmit {
appState.goToPageFromField() appState.goToPageFromField()
} }
.disabled(appState.document == nil) .disabled(appState.document == nil)
Text("/ \(max(appState.pageCount, 1))") HStack(spacing: pageSeparatorSpacing) {
Text("/")
Text("\(max(appState.pageCount, 1))")
}
.font(.system(size: 13, weight: .regular, design: .rounded).monospacedDigit())
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.frame(width: totalPageNumberWidth + pageSeparatorSpacing, alignment: .leading)
Button { Button {
appState.goToNextPage() appState.goToNextPage()
} label: { } label: {
Label("Next Page", systemImage: "chevron.down") Image(systemName: "chevron.down")
} }
.disabled(!appState.canGoToNextPage) .disabled(!appState.canGoToNextPage)
.help("Next Page") .help("Next Page")
} }
.controlSize(.small)
.frame(width: pageControlWidth)
}
ToolbarItemGroup { ToolbarItemGroup(placement: .primaryAction) {
if appState.showToolbarSearch { if appState.showToolbarSearch {
HStack(spacing: 7) {
ZStack(alignment: .trailing) {
TextField("Search", text: $appState.searchText) TextField("Search", text: $appState.searchText)
.textFieldStyle(.roundedBorder) .textFieldStyle(.plain)
.frame(width: 150) .font(.system(size: 13))
.padding(.leading, 10)
.padding(.trailing, appState.canClearSearchQuery ? 28 : 10)
.frame(height: compactToolbarControlsEnabled ? 26 : 28)
.background {
RoundedRectangle(cornerRadius: 14, style: .continuous)
.fill(Color(nsColor: .textBackgroundColor))
}
.overlay {
RoundedRectangle(cornerRadius: 14, style: .continuous)
.stroke(
searchFocused ? Color.accentColor : Color(nsColor: .separatorColor),
lineWidth: searchFocused ? 2 : 1
)
}
.focused($searchFocused) .focused($searchFocused)
.onChange(of: appState.toolbarSearchFocusRequest) { _ in
searchFocused = true
}
.onSubmit { .onSubmit {
appState.runSearch() appState.runSearch()
} }
.onAppear {
DispatchQueue.main.async {
searchFocused = true
}
}
.disabled(appState.document == nil) .disabled(appState.document == nil)
if appState.canClearSearchQuery {
Button {
appState.clearSearchQuery()
} label: {
Image(systemName: "xmark.circle.fill")
.font(.system(size: 12, weight: .semibold))
.symbolRenderingMode(.hierarchical)
}
.buttonStyle(.plain)
.foregroundStyle(.secondary)
.padding(.trailing, 9)
.disabled(appState.document == nil)
.help("Clear Search")
.accessibilityLabel("Clear Search")
}
}
.frame(width: compactToolbarControlsEnabled ? 138 : 154, height: compactToolbarControlsEnabled ? 26 : 28)
if let searchSummaryText = appState.searchSummaryText {
Text(searchSummaryText)
.font(.system(size: 12, weight: .medium, design: .rounded).monospacedDigit())
.foregroundStyle(.secondary)
.lineLimit(1)
.frame(width: searchSummaryText == "No match" ? 58 : 34, alignment: .leading)
.layoutPriority(1)
.accessibilityLabel(searchSummaryText)
}
}
Button { Button {
appState.previousSearchResult() appState.previousSearchResult()
} label: { } label: {
Label("Previous Match", systemImage: "chevron.left") Image(systemName: "chevron.left")
} }
.labelStyle(.iconOnly)
.disabled(appState.searchResults.isEmpty) .disabled(appState.searchResults.isEmpty)
.help("Previous Search Match") .help("Previous Search Match")
Button { Button {
appState.nextSearchResult() appState.nextSearchResult()
} label: { } label: {
Label("Next Match", systemImage: "chevron.right") Image(systemName: "chevron.right")
} }
.labelStyle(.iconOnly)
.disabled(appState.searchResults.isEmpty) .disabled(appState.searchResults.isEmpty)
.help("Next Search Match") .help("Next Search Match")
Button { Button {
appState.hideSearch() appState.hideSearch()
} label: { } label: {
Label("Close Search", systemImage: "xmark") Image(systemName: "xmark")
} }
.labelStyle(.iconOnly)
.disabled(appState.document == nil) .disabled(appState.document == nil)
.help("Close Search") .help("Close Search")
} else { } else {
Button { Button {
appState.showSearch() appState.showSearch()
} label: { } label: {
Label("Search", systemImage: "magnifyingglass") Image(systemName: "magnifyingglass")
} }
.labelStyle(.iconOnly)
.disabled(appState.document == nil) .disabled(appState.document == nil)
.help("Search") .help("Search")
} }
} }
ToolbarItemGroup { ToolbarItem(placement: .primaryAction) {
Button { annotationToolsMenu
appState.addHighlight()
} label: {
Label("Highlight", systemImage: "highlighter")
}
.disabled(appState.document == nil || !appState.hasTextSelection)
.help("Highlight Selection")
Button {
appState.addUnderline()
} label: {
Label("Underline", systemImage: "underline")
}
.disabled(appState.document == nil || !appState.hasTextSelection)
.help("Underline Selection")
Button {
appState.addComment()
} label: {
Label("Comment", systemImage: "text.bubble")
}
.accessibilityLabel("Comment on Selection")
.help("Comment on Selection")
.disabled(appState.document == nil || !appState.hasTextSelection)
} }
ToolbarItemGroup { ToolbarItem(placement: .primaryAction) {
Button { highlightColorButton
appState.zoomOut()
} label: {
Label("Zoom Out", systemImage: "minus.magnifyingglass")
} }
.disabled(appState.document == nil)
.help("Zoom Out")
Button {
appState.zoomIn()
} label: {
Label("Zoom In", systemImage: "plus.magnifyingglass")
}
.disabled(appState.document == nil)
.help("Zoom In")
ToolbarItemGroup(placement: .primaryAction) {
Button { Button {
appState.fitWidth() appState.fitWidth()
} label: { } label: {
Label("Fit Width", systemImage: "arrow.left.and.right") Image(systemName: "arrow.left.and.right")
} }
.labelStyle(.iconOnly)
.disabled(appState.document == nil) .disabled(appState.document == nil)
.help("Fit to Width") .help("Fit to Width")
Button { Button {
appState.fitPage() appState.toggleRightSidebarVisibility()
} label: { } label: {
Label("Fit Page", systemImage: "arrow.up.left.and.down.right.magnifyingglass") Image(systemName: "sidebar.right")
.foregroundStyle(appState.showCommentsSidebar ? Color.accentColor : Color.primary)
} }
.labelStyle(.iconOnly)
.disabled(appState.document == nil) .disabled(appState.document == nil)
.help("Fit Page") .help(appState.showCommentsSidebar ? "Hide Right Sidebar" : "Show Right Sidebar")
} .accessibilityLabel("Toggle Right Sidebar")
ToolbarItemGroup {
Button {
appState.saveDocument()
} label: {
Label("Save", systemImage: "square.and.arrow.down")
}
.disabled(!appState.canSaveDocument)
.help(appState.saveHelpText)
Button { Button {
appState.shareDocument() appState.shareDocument()
} label: { } label: {
Label("Share", systemImage: "square.and.arrow.up") Image(systemName: "square.and.arrow.up")
} }
.labelStyle(.iconOnly)
.disabled(appState.document == nil) .disabled(appState.document == nil)
.help("Share PDF") .help("Share PDF")
} }
} }
} }
}
private struct HighlightPalettePopover: View {
@Binding var storedHighlightColor: String
let onSelect: (NSColor) -> Void
var body: some View {
VStack(alignment: .leading, spacing: 12) {
HStack(spacing: 8) {
Image(systemName: "highlighter")
.foregroundStyle(Color(nsColor: AppSettings.displayColor(
forHighlightColor: AppSettings.highlightColor(from: storedHighlightColor)
)))
Text("Highlight")
.font(.headline)
Spacer(minLength: 0)
}
HStack(spacing: 8) {
ForEach(Array(AppSettings.highlightSwatches.enumerated()), id: \.offset) { _, swatch in
Button {
storedHighlightColor = AppSettings.storageString(forHighlightColor: swatch.color)
onSelect(swatch.color)
} label: {
ZStack {
Circle()
.fill(Color(nsColor: AppSettings.displayColor(forHighlightColor: swatch.color)))
.frame(width: 24, height: 24)
.overlay {
Circle()
.stroke(
isSelected(swatch.color) ? Color.accentColor : Color(nsColor: .separatorColor),
lineWidth: isSelected(swatch.color) ? 2 : 0.8
)
}
if isSelected(swatch.color) {
Image(systemName: "checkmark")
.font(.system(size: 9, weight: .bold))
.foregroundStyle(Color(nsColor: .labelColor))
}
}
}
.buttonStyle(.plain)
.help(swatch.name)
.accessibilityLabel("Highlight \(swatch.name)")
}
}
}
.padding(14)
.frame(width: 284)
}
private func isSelected(_ color: NSColor) -> Bool {
AppSettings.storageString(forHighlightColor: color) == storedHighlightColor
}
}

View File

@@ -8,6 +8,10 @@ final class AcademicPDFView: PDFView {
var onPlacementClick: ((PDFPage, CGPoint) -> Void)? var onPlacementClick: ((PDFPage, CGPoint) -> Void)?
var onCancelPlacement: (() -> Void)? var onCancelPlacement: (() -> Void)?
var onSelectionComment: (() -> Void)? var onSelectionComment: (() -> Void)?
var onHighlighterSelection: (() -> Void)?
var onToggleHighlighterKey: (() -> Void)?
var onUnderlineSelectionKey: (() -> Void)?
var onCommentSelectionKey: (() -> Void)?
var onPreviousPageKey: (() -> Void)? var onPreviousPageKey: (() -> Void)?
var onNextPageKey: (() -> Void)? var onNextPageKey: (() -> Void)?
var placementTool: AnnotationPlacementTool? { var placementTool: AnnotationPlacementTool? {
@@ -16,10 +20,25 @@ final class AcademicPDFView: PDFView {
window?.invalidateCursorRects(for: self) window?.invalidateCursorRects(for: self)
} }
} }
var isHighlighterModeActive = false {
didSet {
guard oldValue != isHighlighterModeActive else { return }
window?.invalidateCursorRects(for: self)
if mouseIsInside {
applyToolCursorIfNeeded()
}
}
}
private var handledAnnotationMouseDown = false private var handledAnnotationMouseDown = false
override var acceptsFirstResponder: Bool { true } override var acceptsFirstResponder: Bool { true }
override func viewDidChangeEffectiveAppearance() {
super.viewDidChangeEffectiveAppearance()
backgroundColor = NSColor.underPageBackgroundColor
needsDisplay = true
}
override func mouseDown(with event: NSEvent) { override func mouseDown(with event: NSEvent) {
handledAnnotationMouseDown = false handledAnnotationMouseDown = false
let point = convert(event.locationInWindow, from: nil) let point = convert(event.locationInWindow, from: nil)
@@ -27,7 +46,6 @@ final class AcademicPDFView: PDFView {
if let page = page(for: point, nearest: false) ?? page(for: point, nearest: true) { if let page = page(for: point, nearest: false) ?? page(for: point, nearest: true) {
closeNativePopups(on: page) closeNativePopups(on: page)
let pagePoint = convert(point, to: page) let pagePoint = convert(point, to: page)
if placementTool != nil { if placementTool != nil {
onPlacementClick?(page, pagePoint) onPlacementClick?(page, pagePoint)
return return
@@ -65,6 +83,14 @@ final class AcademicPDFView: PDFView {
super.mouseUp(with: event) super.mouseUp(with: event)
if isHighlighterModeActive, hasCommentableSelection {
DispatchQueue.main.async { [weak self] in
guard self?.hasCommentableSelection == true else { return }
self?.onHighlighterSelection?()
}
return
}
guard let page else { return } guard let page else { return }
DispatchQueue.main.async { [weak self] in DispatchQueue.main.async { [weak self] in
guard let self else { return } guard let self else { return }
@@ -97,6 +123,24 @@ final class AcademicPDFView: PDFView {
return return
} }
if !event.isARepeat,
event.modifierFlags.intersection([.command, .control, .option]).isEmpty,
let key = event.charactersIgnoringModifiers?.lowercased() {
switch key {
case "h":
onToggleHighlighterKey?()
return
case "u":
onUnderlineSelectionKey?()
return
case "c":
onCommentSelectionKey?()
return
default:
break
}
}
let pageNavigationModifiers: NSEvent.ModifierFlags = [.command, .control, .option, .shift] let pageNavigationModifiers: NSEvent.ModifierFlags = [.command, .control, .option, .shift]
guard event.modifierFlags.intersection(pageNavigationModifiers).isEmpty else { guard event.modifierFlags.intersection(pageNavigationModifiers).isEmpty else {
super.keyDown(with: event) super.keyDown(with: event)
@@ -116,15 +160,182 @@ final class AcademicPDFView: PDFView {
override func resetCursorRects() { override func resetCursorRects() {
super.resetCursorRects() super.resetCursorRects()
if placementTool != nil { if isHighlighterModeActive {
addCursorRect(bounds, cursor: Self.highlighterCursor)
} else if placementTool != nil {
addCursorRect(bounds, cursor: .crosshair) addCursorRect(bounds, cursor: .crosshair)
} }
} }
override func cursorUpdate(with event: NSEvent) {
if applyToolCursorIfNeeded() {
return
}
super.cursorUpdate(with: event)
}
override func mouseMoved(with event: NSEvent) {
super.mouseMoved(with: event)
applyToolCursorIfNeeded()
}
override func mouseDragged(with event: NSEvent) {
super.mouseDragged(with: event)
applyToolCursorIfNeeded()
}
override func menu(for event: NSEvent) -> NSMenu? { override func menu(for event: NSEvent) -> NSMenu? {
commentMenu(from: super.menu(for: event)) commentMenu(from: super.menu(for: event))
} }
@discardableResult
private func applyToolCursorIfNeeded() -> Bool {
if isHighlighterModeActive {
Self.highlighterCursor.set()
return true
}
if placementTool != nil {
NSCursor.crosshair.set()
return true
}
return false
}
private var mouseIsInside: Bool {
guard let window else { return false }
return bounds.contains(convert(window.mouseLocationOutsideOfEventStream, from: nil))
}
private static let highlighterCursor: NSCursor = {
let size = NSSize(width: 36, height: 36)
let image = highResolutionCursorImage(size: size) {
NSGraphicsContext.current?.imageInterpolation = .high
NSGraphicsContext.current?.shouldAntialias = true
let outline = NSColor.black.withAlphaComponent(0.58)
let bodyTint = NSColor(red: 1.0, green: 0.86, blue: 0.28, alpha: 0.98)
let bodyShade = NSColor(red: 1.0, green: 0.58, blue: 0.12, alpha: 0.98)
let nibColor = NSColor(red: 0.16, green: 0.07, blue: 0.02, alpha: 0.96)
let highlightTrail = NSBezierPath(roundedRect: NSRect(x: 4.5, y: 3.6, width: 17.5, height: 5.2), xRadius: 2.6, yRadius: 2.6)
NSColor(red: 1.0, green: 0.93, blue: 0.28, alpha: 0.34).setFill()
highlightTrail.fill()
let shadow = NSShadow()
shadow.shadowOffset = NSSize(width: 0.7, height: -1.1)
shadow.shadowBlurRadius = 2.4
shadow.shadowColor = NSColor.black.withAlphaComponent(0.24)
let body = NSBezierPath()
body.move(to: NSPoint(x: 10.4, y: 9.2))
body.line(to: NSPoint(x: 21.9, y: 23.5))
body.curve(
to: NSPoint(x: 25.3, y: 23.8),
controlPoint1: NSPoint(x: 22.8, y: 24.4),
controlPoint2: NSPoint(x: 24.2, y: 24.5)
)
body.line(to: NSPoint(x: 28.8, y: 20.8))
body.curve(
to: NSPoint(x: 28.1, y: 17.5),
controlPoint1: NSPoint(x: 29.7, y: 20.0),
controlPoint2: NSPoint(x: 29.3, y: 18.4)
)
body.line(to: NSPoint(x: 15.2, y: 4.1))
body.curve(
to: NSPoint(x: 11.7, y: 4.3),
controlPoint1: NSPoint(x: 13.9, y: 3.1),
controlPoint2: NSPoint(x: 12.4, y: 3.0)
)
body.line(to: NSPoint(x: 8.7, y: 6.8))
body.curve(
to: NSPoint(x: 10.4, y: 9.2),
controlPoint1: NSPoint(x: 8.9, y: 7.7),
controlPoint2: NSPoint(x: 9.5, y: 8.6)
)
body.close()
body.lineJoinStyle = .round
NSGraphicsContext.saveGraphicsState()
shadow.set()
NSGradient(colors: [bodyTint, bodyShade])?.draw(in: body, angle: 38)
NSGraphicsContext.restoreGraphicsState()
outline.setStroke()
body.lineWidth = 1.05
body.stroke()
let grip = NSBezierPath()
grip.move(to: NSPoint(x: 17.2, y: 10.4))
grip.line(to: NSPoint(x: 25.0, y: 18.6))
grip.lineWidth = 1.0
NSColor(red: 0.58, green: 0.27, blue: 0.02, alpha: 0.22).setStroke()
grip.stroke()
let shine = NSBezierPath()
shine.move(to: NSPoint(x: 15.2, y: 9.5))
shine.curve(
to: NSPoint(x: 25.0, y: 20.7),
controlPoint1: NSPoint(x: 18.8, y: 12.6),
controlPoint2: NSPoint(x: 22.1, y: 17.8)
)
shine.lineWidth = 1.05
NSColor.white.withAlphaComponent(0.46).setStroke()
shine.stroke()
let nib = NSBezierPath()
nib.move(to: NSPoint(x: 5.3, y: 6.0))
nib.line(to: NSPoint(x: 10.6, y: 9.2))
nib.curve(
to: NSPoint(x: 14.4, y: 4.6),
controlPoint1: NSPoint(x: 11.8, y: 9.4),
controlPoint2: NSPoint(x: 13.6, y: 4.9)
)
nib.line(to: NSPoint(x: 8.6, y: 3.1))
nib.close()
nibColor.setFill()
nib.fill()
outline.setStroke()
nib.lineWidth = 0.9
nib.stroke()
}
return NSCursor(image: image, hotSpot: NSPoint(x: 8, y: 29))
}()
private static func highResolutionCursorImage(
size: NSSize,
scale: CGFloat = 2,
draw: () -> Void
) -> NSImage {
let image = NSImage(size: size)
guard let representation = NSBitmapImageRep(
bitmapDataPlanes: nil,
pixelsWide: Int(size.width * scale),
pixelsHigh: Int(size.height * scale),
bitsPerSample: 8,
samplesPerPixel: 4,
hasAlpha: true,
isPlanar: false,
colorSpaceName: .deviceRGB,
bytesPerRow: 0,
bitsPerPixel: 0
),
let context = NSGraphicsContext(bitmapImageRep: representation)
else {
return image
}
representation.size = size
let previousContext = NSGraphicsContext.current
NSGraphicsContext.current = context
NSGraphicsContext.saveGraphicsState()
context.cgContext.scaleBy(x: scale, y: scale)
draw()
NSGraphicsContext.restoreGraphicsState()
NSGraphicsContext.current = previousContext
image.addRepresentation(representation)
return image
}
private var hasCommentableSelection: Bool { private var hasCommentableSelection: Bool {
guard let selection = currentSelection, guard let selection = currentSelection,
!selection.pages.isEmpty, !selection.pages.isEmpty,
@@ -305,6 +516,26 @@ struct PDFKitRepresentedView: NSViewRepresentable {
appState.addComment() appState.addComment()
} }
} }
view.onHighlighterSelection = {
Task { @MainActor in
appState.addHighlightFromHighlighterMode()
}
}
view.onToggleHighlighterKey = {
Task { @MainActor in
appState.toggleHighlighterMode()
}
}
view.onUnderlineSelectionKey = {
Task { @MainActor in
appState.addUnderline()
}
}
view.onCommentSelectionKey = {
Task { @MainActor in
appState.addComment()
}
}
view.onPreviousPageKey = { view.onPreviousPageKey = {
Task { @MainActor in Task { @MainActor in
appState.goToPreviousPage() appState.goToPreviousPage()
@@ -324,16 +555,27 @@ struct PDFKitRepresentedView: NSViewRepresentable {
view.document = appState.document view.document = appState.document
} }
view.placementTool = appState.placementTool view.placementTool = appState.placementTool
view.isHighlighterModeActive = appState.isHighlighterModeActive
view.highlightedSelections = appState.searchResults.isEmpty ? nil : appState.searchResults view.highlightedSelections = appState.searchResults.isEmpty ? nil : appState.searchResults
context.coordinator.sync(editor: appState.activeEditor, in: view, appState: appState) context.coordinator.sync(
editor: appState.activeEditor,
in: view,
appState: appState
)
} }
@MainActor @MainActor
final class Coordinator: NSObject, NSPopoverDelegate { final class Coordinator: NSObject, NSPopoverDelegate {
private enum PopoverKind {
case comment
}
private var popover: NSPopover? private var popover: NSPopover?
private var model: CommentPopoverModel? private var model: CommentPopoverModel?
private var editorID: UUID? private var editorID: UUID?
private var popoverKind: PopoverKind?
private var isClosing = false private var isClosing = false
private var commitsCommentOnClose = true
private weak var appState: AppState? private weak var appState: AppState?
func sync( func sync(
@@ -343,19 +585,21 @@ struct PDFKitRepresentedView: NSViewRepresentable {
) { ) {
self.appState = appState self.appState = appState
guard let context else { if let context {
if !isClosing { if popoverKind == .comment,
dismissCurrent(commit: false) editorID == context.id,
} popover?.isShown == true {
return
}
if editorID == context.id, popover?.isShown == true {
return return
} }
dismissCurrent(commit: true) dismissCurrent(commit: true)
show(context, in: view, appState: appState) show(context, in: view, appState: appState)
return
}
if !isClosing {
dismissCurrent(commit: false)
}
} }
private func show( private func show(
@@ -377,7 +621,9 @@ struct PDFKitRepresentedView: NSViewRepresentable {
self.model = model self.model = model
self.popover = popover self.popover = popover
self.editorID = context.id self.editorID = context.id
self.popoverKind = .comment
self.isClosing = false self.isClosing = false
self.commitsCommentOnClose = true
let anchor = anchorRect(for: context, in: view) let anchor = anchorRect(for: context, in: view)
popover.show( popover.show(
@@ -435,6 +681,7 @@ struct PDFKitRepresentedView: NSViewRepresentable {
if commit { if commit {
model?.commit() model?.commit()
} }
commitsCommentOnClose = commit
if popover.isShown { if popover.isShown {
popover.performClose(nil) popover.performClose(nil)
@@ -445,15 +692,19 @@ struct PDFKitRepresentedView: NSViewRepresentable {
func popoverWillClose(_ notification: Notification) { func popoverWillClose(_ notification: Notification) {
isClosing = true isClosing = true
if popoverKind == .comment, commitsCommentOnClose {
model?.commit() model?.commit()
} }
}
func popoverDidClose(_ notification: Notification) { func popoverDidClose(_ notification: Notification) {
let closedEditorID = editorID let closedEditorID = editorID
let closedPopoverKind = popoverKind
let currentAppState = appState let currentAppState = appState
cleanup() cleanup()
if currentAppState?.activeEditor?.id == closedEditorID { if closedPopoverKind == .comment,
currentAppState?.activeEditor?.id == closedEditorID {
currentAppState?.activeEditor = nil currentAppState?.activeEditor = nil
} }
} }
@@ -463,7 +714,9 @@ struct PDFKitRepresentedView: NSViewRepresentable {
popover = nil popover = nil
model = nil model = nil
editorID = nil editorID = nil
popoverKind = nil
isClosing = false isClosing = false
commitsCommentOnClose = true
} }
private func anchorRect(for context: AnnotationEditorContext, in view: AcademicPDFView) -> NSRect { private func anchorRect(for context: AnnotationEditorContext, in view: AcademicPDFView) -> NSRect {

View File

@@ -0,0 +1,180 @@
import CoreGraphics
struct ReaderAdaptiveLayout: Equatable {
enum SizeClass: String, CaseIterable {
case compact
case regular
case wide
init(width: CGFloat) {
if width < 960 {
self = .compact
} else if width < 1280 {
self = .regular
} else {
self = .wide
}
}
}
struct SidebarWidths: Equatable {
var left: CGFloat
var right: CGFloat
}
static let minimumWindowWidth: CGFloat = 820
static let minimumWindowHeight: CGFloat = 620
static let resizeHandleWidth: CGFloat = 16
let sizeClass: SizeClass
init(width: CGFloat) {
sizeClass = SizeClass(width: width)
}
init(sizeClass: SizeClass) {
self.sizeClass = sizeClass
}
var usesCompactToolbar: Bool {
sizeClass == .compact
}
var allowsDualSidebars: Bool {
sizeClass != .compact
}
var leftSidebarMinWidth: CGFloat {
switch sizeClass {
case .compact:
return 208
case .regular:
return 196
case .wide:
return 220
}
}
var leftSidebarIdealWidth: CGFloat {
switch sizeClass {
case .compact:
return 236
case .regular:
return 215
case .wide:
return 248
}
}
var leftSidebarMaxWidth: CGFloat {
switch sizeClass {
case .compact:
return 300
case .regular:
return 280
case .wide:
return 340
}
}
var rightSidebarMinWidth: CGFloat {
switch sizeClass {
case .compact:
return 280
case .regular:
return 280
case .wide:
return 300
}
}
var rightSidebarIdealWidth: CGFloat {
switch sizeClass {
case .compact:
return 292
case .regular:
return 300
case .wide:
return 340
}
}
var rightSidebarMaxWidth: CGFloat {
switch sizeClass {
case .compact:
return 340
case .regular:
return 360
case .wide:
return 420
}
}
var documentMinWidth: CGFloat {
switch sizeClass {
case .compact:
return 320
case .regular:
return 420
case .wide:
return 560
}
}
func clampedLeftWidth(_ width: CGFloat) -> CGFloat {
clamped(width, lower: leftSidebarMinWidth, upper: leftSidebarMaxWidth)
}
func clampedRightWidth(_ width: CGFloat) -> CGFloat {
clamped(width, lower: rightSidebarMinWidth, upper: rightSidebarMaxWidth)
}
func resolvedSidebarWidths(
availableWidth: CGFloat,
requestedLeft: CGFloat,
requestedRight: CGFloat,
showLeft: Bool,
showRight: Bool
) -> SidebarWidths {
let leftHandle = showLeft ? Self.resizeHandleWidth : 0
let rightHandle = showRight ? Self.resizeHandleWidth : 0
let maxSidebarTotal = max(0, availableWidth - documentMinWidth - leftHandle - rightHandle)
var left = showLeft ? clampedLeftWidth(requestedLeft) : 0
var right = showRight ? clampedRightWidth(requestedRight) : 0
guard left + right > maxSidebarTotal else {
return SidebarWidths(left: left, right: right)
}
var overflow = left + right - maxSidebarTotal
if showRight {
let reduction = min(overflow, max(0, right - rightSidebarMinWidth))
right -= reduction
overflow -= reduction
}
if showLeft, overflow > 0 {
let reduction = min(overflow, max(0, left - leftSidebarMinWidth))
left -= reduction
}
return SidebarWidths(left: left, right: right)
}
func visibleContentWidth(
availableWidth: CGFloat,
leftWidth: CGFloat,
rightWidth: CGFloat,
showLeft: Bool,
showRight: Bool
) -> CGFloat {
let leftHandle = showLeft ? Self.resizeHandleWidth : 0
let rightHandle = showRight ? Self.resizeHandleWidth : 0
return availableWidth - leftWidth - rightWidth - leftHandle - rightHandle
}
private func clamped(_ value: CGFloat, lower: CGFloat, upper: CGFloat) -> CGFloat {
min(max(value, lower), upper)
}
}

View File

@@ -1,106 +0,0 @@
import AppKit
import IHatePDFsCore
import SwiftUI
extension View {
func commitOnPlainReturn(isEnabled: Bool = true, _ action: @escaping () -> Void) -> some View {
modifier(ReturnKeyCommitMonitor(isEnabled: isEnabled, action: action))
}
}
private struct ReturnKeyCommitMonitor: ViewModifier {
let isEnabled: Bool
let action: () -> Void
@State private var monitor: Any?
@State private var eventWindowBox = EventWindowBox()
func body(content: Content) -> some View {
content
.background(
EventWindowReader { window in
eventWindowBox.windowID = window.map(ObjectIdentifier.init)
}
)
.onAppear {
eventWindowBox.isEnabled = isEnabled
installMonitor()
}
.onChange(of: isEnabled) { value in
eventWindowBox.isEnabled = value
}
.onDisappear {
removeMonitor()
}
}
private func installMonitor() {
removeMonitor()
let eventWindowBox = eventWindowBox
monitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in
guard eventWindowBox.isEnabled,
shouldCommit(event),
eventWindowBox.windowID.map({ event.window.map(ObjectIdentifier.init) == $0 }) == true
else {
return event
}
action()
return nil
}
}
private func removeMonitor() {
guard let monitor else { return }
NSEvent.removeMonitor(monitor)
self.monitor = nil
}
private func shouldCommit(_ event: NSEvent) -> Bool {
let textView = event.window?.firstResponder as? NSTextView
let isEditableMultilineText = textView?.isEditable == true && textView?.isFieldEditor == false
return ReturnKeyCommitPolicy.shouldCommit(
keyCode: UInt16(event.keyCode),
shift: event.modifierFlags.contains(.shift),
option: event.modifierFlags.contains(.option),
command: event.modifierFlags.contains(.command),
control: event.modifierFlags.contains(.control),
isEditableMultilineText: isEditableMultilineText
)
}
}
private final class EventWindowBox {
var windowID: ObjectIdentifier?
var isEnabled = true
}
private struct EventWindowReader: NSViewRepresentable {
let onWindowChange: (NSWindow?) -> Void
func makeNSView(context: Context) -> WindowReportingView {
let view = WindowReportingView()
view.onWindowChange = onWindowChange
return view
}
func updateNSView(_ view: WindowReportingView, context: Context) {
view.onWindowChange = onWindowChange
view.reportWindow()
}
}
private final class WindowReportingView: NSView {
var onWindowChange: ((NSWindow?) -> Void)?
override func viewDidMoveToWindow() {
super.viewDidMoveToWindow()
reportWindow()
}
func reportWindow() {
DispatchQueue.main.async { [weak self] in
guard let self else { return }
onWindowChange?(window)
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -103,17 +103,21 @@ public enum AnnotationFactory {
date: Date = Date() date: Date = Date()
) -> [AnnotationInsertion] { ) -> [AnnotationInsertion] {
let lineSelections = selection.selectionsByLine() let lineSelections = selection.selectionsByLine()
var groups: [(page: PDFPage, rects: [CGRect])] = [] var groups: [(page: PDFPage, rects: [CGRect], text: [String])] = []
for lineSelection in lineSelections { for lineSelection in lineSelections {
let lineText = lineSelection.string?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
for page in lineSelection.pages { for page in lineSelection.pages {
let rect = lineSelection.bounds(for: page).insetBy(dx: -1.5, dy: -1.0) let rect = lineSelection.bounds(for: page).insetBy(dx: -1.5, dy: -1.0)
guard !rect.isNull, rect.width > 0, rect.height > 0 else { continue } guard !rect.isNull, rect.width > 0, rect.height > 0 else { continue }
if let index = groups.firstIndex(where: { $0.page === page }) { if let index = groups.firstIndex(where: { $0.page === page }) {
groups[index].rects.append(rect) groups[index].rects.append(rect)
if !lineText.isEmpty {
groups[index].text.append(lineText)
}
} else { } else {
groups.append((page: page, rects: [rect])) groups.append((page: page, rects: [rect], text: lineText.isEmpty ? [] : [lineText]))
} }
} }
} }
@@ -130,6 +134,12 @@ public enum AnnotationFactory {
quadPoints(for: rect, relativeTo: unionRect) quadPoints(for: rect, relativeTo: unionRect)
} }
standardize(annotation, comment: comment, author: author, date: date) standardize(annotation, comment: comment, author: author, date: date)
if style == .highlight {
let highlightText = group.text.joined(separator: " ")
if !highlightText.isEmpty {
_ = annotation.setValue(highlightText, forAnnotationKey: AnnotationKeys.appHighlightText)
}
}
if style == .comment { if style == .comment {
_ = annotation.setValue(AnnotationKeys.appKindComment, forAnnotationKey: AnnotationKeys.appKind) _ = annotation.setValue(AnnotationKeys.appKindComment, forAnnotationKey: AnnotationKeys.appKind)
} }

View File

@@ -2,7 +2,7 @@ import AppKit
import Foundation import Foundation
import PDFKit import PDFKit
public enum AcademicAnnotationKind: String, CaseIterable, Identifiable { public enum AcademicAnnotationKind: String, CaseIterable {
case comment case comment
case highlight case highlight
case underline case underline
@@ -11,8 +11,6 @@ public enum AcademicAnnotationKind: String, CaseIterable, Identifiable {
case reply case reply
case other case other
public var id: String { rawValue }
public init(annotation: PDFAnnotation) { public init(annotation: PDFAnnotation) {
if annotation.value(forAnnotationKey: AnnotationKeys.appKind) as? String == AnnotationKeys.appKindComment { if annotation.value(forAnnotationKey: AnnotationKeys.appKind) as? String == AnnotationKeys.appKindComment {
self = .comment self = .comment
@@ -62,7 +60,7 @@ public enum AcademicAnnotationKind: String, CaseIterable, Identifiable {
} }
} }
public struct AnnotationSnapshot: Identifiable, Equatable { public struct AnnotationSnapshot: Identifiable {
public let id: String public let id: String
public let pageIndex: Int public let pageIndex: Int
public let pageLabel: String public let pageLabel: String
@@ -73,6 +71,7 @@ public struct AnnotationSnapshot: Identifiable, Equatable {
public let modifiedAt: Date? public let modifiedAt: Date?
public let status: String public let status: String
public let contents: String public let contents: String
public let highlightText: String
public let bounds: CGRect public let bounds: CGRect
public let annotation: PDFAnnotation public let annotation: PDFAnnotation
public let page: PDFPage public let page: PDFPage
@@ -89,6 +88,7 @@ public struct AnnotationSnapshot: Identifiable, Equatable {
modifiedAt: Date?, modifiedAt: Date?,
status: String, status: String,
contents: String, contents: String,
highlightText: String,
bounds: CGRect, bounds: CGRect,
annotation: PDFAnnotation, annotation: PDFAnnotation,
page: PDFPage, page: PDFPage,
@@ -104,6 +104,7 @@ public struct AnnotationSnapshot: Identifiable, Equatable {
self.modifiedAt = modifiedAt self.modifiedAt = modifiedAt
self.status = status self.status = status
self.contents = contents self.contents = contents
self.highlightText = highlightText
self.bounds = bounds self.bounds = bounds
self.annotation = annotation self.annotation = annotation
self.page = page self.page = page
@@ -126,23 +127,22 @@ public struct AnnotationSnapshot: Identifiable, Equatable {
!contents.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty !contents.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
} }
public var isReply: Bool { public var highlightExcerpt: String {
parentID != nil let stored = highlightText.trimmingCharacters(in: .whitespacesAndNewlines)
if !stored.isEmpty {
return stored
} }
public static func == (lhs: AnnotationSnapshot, rhs: AnnotationSnapshot) -> Bool { let fallback = contents.trimmingCharacters(in: .whitespacesAndNewlines)
lhs.id == rhs.id if !fallback.isEmpty {
&& lhs.pageIndex == rhs.pageIndex return fallback
&& lhs.pageLabel == rhs.pageLabel }
&& lhs.annotationIndex == rhs.annotationIndex
&& lhs.kind == rhs.kind return "Highlight on page \(pageLabel)"
&& lhs.author == rhs.author }
&& lhs.createdAt == rhs.createdAt
&& lhs.modifiedAt == rhs.modifiedAt public var isReply: Bool {
&& lhs.status == rhs.status parentID != nil
&& lhs.contents == rhs.contents
&& lhs.bounds == rhs.bounds
&& lhs.parentID == rhs.parentID
} }
} }
@@ -155,6 +155,7 @@ public enum AnnotationKeys {
public static let appKind = PDFAnnotationKey(rawValue: "IHatePDFsKind") public static let appKind = PDFAnnotationKey(rawValue: "IHatePDFsKind")
public static let appKindComment = "Comment" public static let appKindComment = "Comment"
public static let appCommentText = PDFAnnotationKey(rawValue: "IHatePDFsCommentText") public static let appCommentText = PDFAnnotationKey(rawValue: "IHatePDFsCommentText")
public static let appHighlightText = PDFAnnotationKey(rawValue: "IHatePDFsHighlightText")
public static func commentText(for annotation: PDFAnnotation) -> String { public static func commentText(for annotation: PDFAnnotation) -> String {
if let value = annotation.value(forAnnotationKey: appCommentText) as? String, if let value = annotation.value(forAnnotationKey: appCommentText) as? String,
@@ -351,6 +352,7 @@ public enum AnnotationReader {
let kind = AcademicAnnotationKind(annotation: annotation) let kind = AcademicAnnotationKind(annotation: annotation)
let contents = AnnotationKeys.commentText(for: annotation) let contents = AnnotationKeys.commentText(for: annotation)
let highlightText = annotation.value(forAnnotationKey: AnnotationKeys.appHighlightText) as? String ?? ""
guard kind != .other || !contents.isEmpty else { continue } guard kind != .other || !contents.isEmpty else { continue }
let id = AnnotationKeys.stableID( let id = AnnotationKeys.stableID(
@@ -384,6 +386,7 @@ public enum AnnotationReader {
modifiedAt: annotation.modificationDate, modifiedAt: annotation.modificationDate,
status: status, status: status,
contents: contents, contents: contents,
highlightText: highlightText,
bounds: annotation.bounds, bounds: annotation.bounds,
annotation: annotation, annotation: annotation,
page: page, page: page,

View File

@@ -0,0 +1,61 @@
import Foundation
public struct PDFDocumentBookmark {
public var id: String
public var pageIndex: Int
public var pageLabel: String
public var title: String
public var createdAt: Date
public init(
id: String = UUID().uuidString,
pageIndex: Int,
pageLabel: String,
title: String,
createdAt: Date = Date()
) {
self.id = id
self.pageIndex = pageIndex
self.pageLabel = pageLabel
self.title = title
self.createdAt = createdAt
}
}
public enum PDFDocumentBookmarks {
public static func sorted(_ bookmarks: [PDFDocumentBookmark]) -> [PDFDocumentBookmark] {
preferredBookmark(in: bookmarks).map { [$0] } ?? []
}
public static func upsert(
_ bookmark: PDFDocumentBookmark,
in _: [PDFDocumentBookmark]
) -> [PDFDocumentBookmark] {
[bookmark]
}
public static func removing(id: String, from bookmarks: [PDFDocumentBookmark]) -> [PDFDocumentBookmark] {
sorted(bookmarks.filter { $0.id != id })
}
public static func bookmark(on pageIndex: Int, in bookmarks: [PDFDocumentBookmark]) -> PDFDocumentBookmark? {
sorted(bookmarks).first { $0.pageIndex == pageIndex }
}
public static func clamped(
_ bookmarks: [PDFDocumentBookmark],
pageCount: Int
) -> [PDFDocumentBookmark] {
guard pageCount > 0 else { return [] }
return sorted(bookmarks.filter { (0..<pageCount).contains($0.pageIndex) })
}
private static func preferredBookmark(in bookmarks: [PDFDocumentBookmark]) -> PDFDocumentBookmark? {
bookmarks.max {
if $0.createdAt != $1.createdAt {
return $0.createdAt < $1.createdAt
}
return $0.pageIndex < $1.pageIndex
}
}
}

View File

@@ -0,0 +1,82 @@
import Foundation
public struct PDFRecentDocumentProgress {
public var key: String
public var pageIndex: Int
public var openedAt: Date
public init(key: String, pageIndex: Int, openedAt: Date) {
self.key = key
self.pageIndex = pageIndex
self.openedAt = openedAt
}
}
public enum PDFRecentDocuments {
public static func filteredPDFs(
from urls: [URL],
currentURL: URL? = nil,
limit: Int,
fileExists: (URL) -> Bool = { FileManager.default.fileExists(atPath: $0.path) }
) -> [URL] {
guard limit > 0 else { return [] }
var result: [URL] = []
var seen = Set<URL>()
let current = currentURL.map(normalized)
for url in urls {
let normalizedURL = normalized(url)
guard normalizedURL != current,
seen.insert(normalizedURL).inserted,
PDFFileSelection.isPDFFileURL(normalizedURL),
fileExists(normalizedURL)
else {
continue
}
result.append(normalizedURL)
if result.count == limit {
break
}
}
return result
}
public static func documentKey(for url: URL) -> String {
normalized(url).path
}
public static func progress(
for url: URL,
in records: [String: PDFRecentDocumentProgress]
) -> PDFRecentDocumentProgress? {
records[documentKey(for: url)]
}
public static func updatedProgress(
_ records: [String: PDFRecentDocumentProgress],
url: URL,
pageIndex: Int,
openedAt: Date
) -> [String: PDFRecentDocumentProgress] {
let key = documentKey(for: url)
var copy = records
copy[key] = PDFRecentDocumentProgress(
key: key,
pageIndex: max(0, pageIndex),
openedAt: openedAt
)
return copy
}
public static func clampedPageIndex(_ pageIndex: Int?, pageCount: Int) -> Int {
guard pageCount > 0, let pageIndex else { return 0 }
return min(max(0, pageIndex), pageCount - 1)
}
static func normalized(_ url: URL) -> URL {
url.standardizedFileURL
}
}

View File

@@ -7,10 +7,14 @@ public enum ReturnKeyCommitPolicy {
option: Bool, option: Bool,
command: Bool, command: Bool,
control: Bool, control: Bool,
isEditableMultilineText: Bool isEditableMultilineText: Bool,
commandReturnOnly: Bool = false
) -> Bool { ) -> Bool {
guard isEditableMultilineText else { return false } guard isEditableMultilineText else { return false }
guard keyCode == 36 || keyCode == 76 else { return false } guard keyCode == 36 || keyCode == 76 else { return false }
if commandReturnOnly {
return command && !shift && !option && !control
}
return !shift && !option && !command && !control return !shift && !option && !command && !control
} }
} }

View File

@@ -0,0 +1,52 @@
import XCTest
@testable import IHatePDFsCore
final class PDFDocumentBookmarksTests: XCTestCase {
func testUpsertReplacesExistingBookmark() {
let first = PDFDocumentBookmark(id: "first", pageIndex: 4, pageLabel: "5", title: "Old")
let second = PDFDocumentBookmark(id: "second", pageIndex: 1, pageLabel: "2", title: "Second")
let replacement = PDFDocumentBookmark(id: "replacement", pageIndex: 4, pageLabel: "5", title: "New")
let result = PDFDocumentBookmarks.upsert(replacement, in: [first, second])
XCTAssertEqual(result.map(\.id), ["replacement"])
XCTAssertEqual(result.first?.title, "New")
}
func testRemovingBookmarkCollapsesDirtyMultipleBookmarkData() {
let first = PDFDocumentBookmark(id: "first", pageIndex: 0, pageLabel: "1", title: "First")
let second = PDFDocumentBookmark(id: "second", pageIndex: 1, pageLabel: "2", title: "Second")
let result = PDFDocumentBookmarks.removing(id: "first", from: [first, second])
XCTAssertEqual(result.map(\.id), ["second"])
}
func testClampedDropsInvalidBookmarksAndKeepsOneBookmark() {
let older = PDFDocumentBookmark(
id: "older",
pageIndex: 0,
pageLabel: "1",
title: "Older",
createdAt: Date(timeIntervalSince1970: 100)
)
let newer = PDFDocumentBookmark(
id: "newer",
pageIndex: 1,
pageLabel: "2",
title: "Newer",
createdAt: Date(timeIntervalSince1970: 200)
)
let invalid = PDFDocumentBookmark(
id: "invalid",
pageIndex: 4,
pageLabel: "5",
title: "Invalid",
createdAt: Date(timeIntervalSince1970: 300)
)
let result = PDFDocumentBookmarks.clamped([invalid, older, newer], pageCount: 2)
XCTAssertEqual(result.map(\.id), ["newer"])
}
}

View File

@@ -0,0 +1,58 @@
import XCTest
@testable import IHatePDFsCore
final class PDFRecentDocumentsTests: XCTestCase {
func testFilteredPDFsKeepsExistingPDFsOnly() {
let pdf = URL(fileURLWithPath: "/Users/test/Documents/reading.pdf")
let upperPDF = URL(fileURLWithPath: "/Users/test/Documents/report.PDF")
let text = URL(fileURLWithPath: "/Users/test/Documents/notes.txt")
let result = PDFRecentDocuments.filteredPDFs(
from: [pdf, text, upperPDF],
limit: 10,
fileExists: { $0 == pdf || $0 == upperPDF }
)
XCTAssertEqual(result, [pdf, upperPDF])
}
func testFilteredPDFsDeduplicatesExcludesCurrentAndHonorsLimit() {
let first = URL(fileURLWithPath: "/Users/test/Documents/first.pdf")
let second = URL(fileURLWithPath: "/Users/test/Documents/second.pdf")
let third = URL(fileURLWithPath: "/Users/test/Documents/third.pdf")
let result = PDFRecentDocuments.filteredPDFs(
from: [first, second, first, third],
currentURL: second,
limit: 1,
fileExists: { _ in true }
)
XCTAssertEqual(result, [first])
}
func testProgressStoresPageByNormalizedDocumentKey() {
let url = URL(fileURLWithPath: "/Users/test/Documents/../Documents/reading.pdf")
let openedAt = Date(timeIntervalSince1970: 42)
let records = PDFRecentDocuments.updatedProgress(
[:],
url: url,
pageIndex: 8,
openedAt: openedAt
)
let progress = PDFRecentDocuments.progress(for: url, in: records)
XCTAssertEqual(progress?.pageIndex, 8)
XCTAssertEqual(progress?.openedAt, openedAt)
XCTAssertEqual(progress?.key, PDFRecentDocuments.documentKey(for: url))
}
func testProgressClampsSavedPageToAvailablePageCount() {
XCTAssertEqual(PDFRecentDocuments.clampedPageIndex(nil, pageCount: 20), 0)
XCTAssertEqual(PDFRecentDocuments.clampedPageIndex(-4, pageCount: 20), 0)
XCTAssertEqual(PDFRecentDocuments.clampedPageIndex(4, pageCount: 20), 4)
XCTAssertEqual(PDFRecentDocuments.clampedPageIndex(99, pageCount: 20), 19)
XCTAssertEqual(PDFRecentDocuments.clampedPageIndex(4, pageCount: 0), 0)
}
}

View File

@@ -0,0 +1,46 @@
import AppKit
import PDFKit
import XCTest
@testable import IHatePDFsCore
final class PerformanceBudgetTests: XCTestCase {
func testLargeDocumentFullAnnotationSnapshotPerformance() {
let document = makeLargeAnnotatedDocument(pageCount: 500, annotationEvery: 10)
measure {
let snapshots = AnnotationReader.snapshots(in: document)
XCTAssertEqual(snapshots.count, 50)
}
}
func testLargeDocumentPageScopedAnnotationRefreshPerformance() throws {
let document = makeLargeAnnotatedDocument(pageCount: 500, annotationEvery: 10)
let targetPage = try XCTUnwrap(document.page(at: 250))
measure {
let snapshots = AnnotationReader.snapshots(in: document, pages: [targetPage])
XCTAssertEqual(snapshots.count, 1)
XCTAssertEqual(snapshots.first?.pageIndex, 250)
}
}
private func makeLargeAnnotatedDocument(pageCount: Int, annotationEvery stride: Int) -> PDFDocument {
let document = PDFDocument()
for pageIndex in 0..<pageCount {
let page = PDFPage()
document.insert(page, at: pageIndex)
guard pageIndex.isMultiple(of: stride) else { continue }
let insertion = AnnotationFactory.noteInsertion(
on: page,
near: CGPoint(x: 120, y: 160),
comment: "Note on page \(pageIndex + 1)",
author: "Professor"
)
page.addAnnotation(insertion.annotation)
}
return document
}
}

View File

@@ -13,6 +13,30 @@ final class ReturnKeyCommitPolicyTests: XCTestCase {
)) ))
} }
func testCommandReturnCommitsWhenCommandReturnOnlyModeEnabled() {
XCTAssertTrue(ReturnKeyCommitPolicy.shouldCommit(
keyCode: 36,
shift: false,
option: false,
command: true,
control: false,
isEditableMultilineText: true,
commandReturnOnly: true
))
}
func testPlainReturnDoesNotCommitWhenCommandReturnOnlyModeEnabled() {
XCTAssertFalse(ReturnKeyCommitPolicy.shouldCommit(
keyCode: 36,
shift: false,
option: false,
command: false,
control: false,
isEditableMultilineText: true,
commandReturnOnly: true
))
}
func testKeypadEnterCommitsInEditableMultilineText() { func testKeypadEnterCommitsInEditableMultilineText() {
XCTAssertTrue(ReturnKeyCommitPolicy.shouldCommit( XCTAssertTrue(ReturnKeyCommitPolicy.shouldCommit(
keyCode: 76, keyCode: 76,

View File

@@ -0,0 +1,290 @@
import CoreGraphics
import Foundation
import IHatePDFsCore
import PDFKit
import XCTest
@testable import IHatePDFs
@MainActor
final class AppStateWorkflowTests: XCTestCase {
func testOpeningDocumentStartsInFocusedReadingWorkflow() throws {
let url = try makeTemporaryPDF()
defer { try? FileManager.default.removeItem(at: url) }
let appState = AppState()
appState.updateWindowWidth(1_280)
appState.showLeftSidebar = true
appState.leftSidebarMode = .annotations
appState.showCommentsSidebar = true
appState.sidebarMode = .highlights
appState.commentSearchText = "draft"
appState.commentFilter = .withComments
appState.selectedKindFilter = .comment
appState.selectedAuthorFilter = "Someone"
appState.selectedStatusFilter = ReviewState.reviewed
appState.collapsedPageIndexes = [0]
appState.loadDocument(from: url)
XCTAssertNotNil(appState.document)
XCTAssertEqual(appState.documentURL, url)
XCTAssertFalse(appState.showLeftSidebar)
XCTAssertEqual(appState.leftSidebarMode, .pages)
XCTAssertFalse(appState.showCommentsSidebar)
XCTAssertEqual(appState.sidebarMode, .annotations)
XCTAssertEqual(appState.commentSearchText, "")
XCTAssertEqual(appState.commentFilter, .all)
XCTAssertNil(appState.selectedKindFilter)
XCTAssertEqual(appState.selectedAuthorFilter, "All Authors")
XCTAssertEqual(appState.selectedStatusFilter, ReviewState.allStatuses)
XCTAssertTrue(appState.collapsedPageIndexes.isEmpty)
XCTAssertFalse(appState.hasUnsavedChanges)
}
func testOpeningDocumentCollapsesSidebars() throws {
let url = try makeTemporaryPDF()
defer { try? FileManager.default.removeItem(at: url) }
let appState = AppState()
appState.updateWindowWidth(1_280)
appState.showLeftSidebar = true
appState.leftSidebarMode = .annotations
appState.showCommentsSidebar = true
appState.sidebarMode = .highlights
appState.loadDocument(from: url)
XCTAssertFalse(appState.showLeftSidebar)
XCTAssertFalse(appState.showCommentsSidebar)
}
func testDroppingPDFWhileDocumentOpenReplacesThroughAppState() async throws {
let firstURL = try makeTemporaryPDF()
let secondURL = try makeTemporaryPDF()
defer {
try? FileManager.default.removeItem(at: firstURL)
try? FileManager.default.removeItem(at: secondURL)
}
let appState = AppState()
appState.loadDocument(from: firstURL)
XCTAssertEqual(appState.documentURL, firstURL)
let provider = try XCTUnwrap(NSItemProvider(contentsOf: secondURL))
XCTAssertTrue(appState.openDroppedDocument(from: [provider]))
try await waitUntil {
appState.documentURL == secondURL
}
XCTAssertEqual(appState.documentURL, secondURL)
XCTAssertNotNil(appState.document)
XCTAssertFalse(appState.hasUnsavedChanges)
}
func testClosingDocumentReturnsToEmptyWindowWorkflow() throws {
let url = try makeTemporaryPDF()
defer { try? FileManager.default.removeItem(at: url) }
let appState = AppState()
appState.loadDocument(from: url)
appState.showLeftSidebar = true
appState.leftSidebarMode = .annotations
appState.showCommentsSidebar = true
appState.sidebarMode = .highlights
appState.searchText = "draft"
appState.showToolbarSearch = true
appState.collapsedPageIndexes = [0]
appState.closeDocument()
XCTAssertNil(appState.document)
XCTAssertNil(appState.documentURL)
XCTAssertFalse(appState.showLeftSidebar)
XCTAssertEqual(appState.leftSidebarMode, .pages)
XCTAssertFalse(appState.showCommentsSidebar)
XCTAssertEqual(appState.sidebarMode, .annotations)
XCTAssertEqual(appState.searchText, "")
XCTAssertFalse(appState.showToolbarSearch)
XCTAssertTrue(appState.annotations.isEmpty)
XCTAssertTrue(appState.bookmarks.isEmpty)
XCTAssertEqual(appState.currentPageIndex, 0)
XCTAssertEqual(appState.pageText, "1")
XCTAssertFalse(appState.hasUnsavedWork)
XCTAssertEqual(appState.statusMessage, "Closed PDF.")
}
func testCompactWorkflowShowsOnlyOneSidebarAtATime() {
let appState = AppState()
appState.updateWindowWidth(ReaderAdaptiveLayout.minimumWindowWidth)
appState.toggleRightSidebar(mode: .highlights)
XCTAssertTrue(appState.showCommentsSidebar)
XCTAssertEqual(appState.sidebarMode, .highlights)
XCTAssertFalse(appState.showLeftSidebar)
appState.togglePageSidebar()
XCTAssertTrue(appState.showLeftSidebar)
XCTAssertEqual(appState.leftSidebarMode, .pages)
XCTAssertFalse(appState.showCommentsSidebar)
appState.toggleRightSidebar(mode: .highlights)
XCTAssertTrue(appState.showCommentsSidebar)
XCTAssertEqual(appState.sidebarMode, .highlights)
XCTAssertFalse(appState.showLeftSidebar)
}
func testPageSidebarToggleClosesLeftSidebarEvenWhenMarksAreSelected() {
let appState = AppState()
appState.updateWindowWidth(1_280)
appState.showLeftSidebar = true
appState.leftSidebarMode = .annotations
appState.togglePageSidebar()
XCTAssertFalse(appState.showLeftSidebar)
XCTAssertEqual(appState.leftSidebarMode, .annotations)
}
func testRightSidebarToolbarToggleClosesAndReopensCurrentMode() {
let appState = AppState()
appState.updateWindowWidth(1_280)
appState.toggleRightSidebar(mode: .highlights)
XCTAssertTrue(appState.showCommentsSidebar)
XCTAssertEqual(appState.sidebarMode, .highlights)
appState.toggleRightSidebarVisibility()
XCTAssertFalse(appState.showCommentsSidebar)
XCTAssertEqual(appState.sidebarMode, .highlights)
appState.toggleRightSidebarVisibility()
XCTAssertTrue(appState.showCommentsSidebar)
XCTAssertEqual(appState.sidebarMode, .highlights)
}
func testRightSidebarToggleClosesFromDifferentOpenMode() {
let appState = AppState()
appState.updateWindowWidth(1_280)
appState.toggleRightSidebar(mode: .highlights)
XCTAssertTrue(appState.showCommentsSidebar)
XCTAssertEqual(appState.sidebarMode, .highlights)
appState.toggleRightSidebar(mode: .annotations)
XCTAssertFalse(appState.showCommentsSidebar)
XCTAssertEqual(appState.sidebarMode, .highlights)
appState.toggleRightSidebar(mode: .annotations)
XCTAssertTrue(appState.showCommentsSidebar)
XCTAssertEqual(appState.sidebarMode, .annotations)
}
func testRightSidebarToggleFromHighlightsDoesNotSwitchModeWhenClosing() {
let appState = AppState()
appState.updateWindowWidth(1_280)
appState.toggleRightSidebar(mode: .highlights)
XCTAssertTrue(appState.showCommentsSidebar)
XCTAssertEqual(appState.sidebarMode, .highlights)
appState.toggleRightSidebarVisibility()
XCTAssertFalse(appState.showCommentsSidebar)
XCTAssertEqual(appState.sidebarMode, .highlights)
appState.toggleRightSidebarVisibility()
XCTAssertTrue(appState.showCommentsSidebar)
XCTAssertEqual(appState.sidebarMode, .highlights)
}
func testRightSidebarToolbarToggleDefaultsToCommentsWhenNoModeWasChosen() {
let appState = AppState()
appState.updateWindowWidth(1_280)
XCTAssertFalse(appState.showCommentsSidebar)
XCTAssertEqual(appState.sidebarMode, .annotations)
appState.toggleRightSidebarVisibility()
XCTAssertTrue(appState.showCommentsSidebar)
XCTAssertEqual(appState.sidebarMode, .annotations)
}
func testRegularWorkflowAllowsNavigationAndReviewSidebarsTogether() {
let appState = AppState()
appState.updateWindowWidth(1_000)
appState.togglePageSidebar()
appState.toggleRightSidebar(mode: .annotations)
XCTAssertTrue(appState.showLeftSidebar)
XCTAssertEqual(appState.leftSidebarMode, .pages)
XCTAssertTrue(appState.showCommentsSidebar)
XCTAssertEqual(appState.sidebarMode, .annotations)
}
func testSaveAvailabilityTracksReplyDraftAsUnsavedWork() throws {
let url = try makeTemporaryPDF()
defer { try? FileManager.default.removeItem(at: url) }
let appState = AppState()
appState.loadDocument(from: url)
XCTAssertFalse(appState.hasUnsavedWork)
XCTAssertFalse(appState.canSaveDocument)
XCTAssertEqual(appState.saveHelpText, "No unsaved changes.")
appState.sidebarReplyDraft = "Need to verify this quote."
XCTAssertTrue(appState.hasUnsavedWork)
XCTAssertTrue(appState.hasUnsentSidebarReplyDraft)
XCTAssertTrue(appState.canSaveDocument)
XCTAssertEqual(appState.saveHelpText, "Send or cancel the reply draft before saving.")
appState.hasUnsavedChanges = true
XCTAssertTrue(appState.canSaveDocument)
XCTAssertEqual(appState.saveHelpText, "Save PDF")
}
private func makeTemporaryPDF() throws -> URL {
let url = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString)
.appendingPathExtension("pdf")
let data = NSMutableData()
guard let consumer = CGDataConsumer(data: data) else {
throw TestPDFError.couldNotCreateConsumer
}
var mediaBox = CGRect(x: 0, y: 0, width: 612, height: 792)
guard let context = CGContext(consumer: consumer, mediaBox: &mediaBox, nil) else {
throw TestPDFError.couldNotCreateContext
}
context.beginPDFPage(nil)
context.endPDFPage()
context.closePDF()
try data.write(to: url, options: .atomic)
return url
}
private enum TestPDFError: Error {
case couldNotCreateConsumer
case couldNotCreateContext
}
private func waitUntil(
timeout: TimeInterval = 2,
condition: @MainActor @escaping () -> Bool
) async throws {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
if condition() {
return
}
try await Task.sleep(nanoseconds: 20_000_000)
}
XCTFail("Timed out waiting for condition.")
}
}

View File

@@ -0,0 +1,122 @@
import XCTest
@testable import IHatePDFs
final class ReaderAdaptiveLayoutTests: XCTestCase {
func testSizeClassBreakpointsMatchMacWindowProfiles() {
XCTAssertEqual(ReaderAdaptiveLayout(width: 759).sizeClass, .compact)
XCTAssertEqual(ReaderAdaptiveLayout(width: 959).sizeClass, .compact)
XCTAssertEqual(ReaderAdaptiveLayout(width: 960).sizeClass, .regular)
XCTAssertEqual(ReaderAdaptiveLayout(width: 1_279).sizeClass, .regular)
XCTAssertEqual(ReaderAdaptiveLayout(width: 1_280).sizeClass, .wide)
}
func testCompactProfileKeepsSingleSidebarAndDocumentReadableAtMinimumWidth() {
let layout = ReaderAdaptiveLayout(width: ReaderAdaptiveLayout.minimumWindowWidth)
XCTAssertEqual(layout.sizeClass, .compact)
XCTAssertFalse(layout.allowsDualSidebars)
XCTAssertTrue(layout.usesCompactToolbar)
assertDocumentWidthIsPreserved(
layout: layout,
availableWidth: ReaderAdaptiveLayout.minimumWindowWidth,
requestedLeft: layout.leftSidebarMaxWidth,
requestedRight: 0,
showLeft: true,
showRight: false
)
assertDocumentWidthIsPreserved(
layout: layout,
availableWidth: ReaderAdaptiveLayout.minimumWindowWidth,
requestedLeft: 0,
requestedRight: layout.rightSidebarMaxWidth,
showLeft: false,
showRight: true
)
}
func testRegularProfilePreservesDocumentWidthWithBothSidebarsAtBreakpoint() {
let layout = ReaderAdaptiveLayout(width: 960)
XCTAssertEqual(layout.sizeClass, .regular)
XCTAssertTrue(layout.allowsDualSidebars)
XCTAssertFalse(layout.usesCompactToolbar)
assertDocumentWidthIsPreserved(
layout: layout,
availableWidth: 960,
requestedLeft: layout.leftSidebarIdealWidth,
requestedRight: layout.rightSidebarIdealWidth,
showLeft: true,
showRight: true
)
}
func testExpandedRegularSidebarsShrinkBeforeTheDocumentDoes() {
let layout = ReaderAdaptiveLayout(width: 960)
let widths = layout.resolvedSidebarWidths(
availableWidth: 960,
requestedLeft: layout.leftSidebarMaxWidth,
requestedRight: layout.rightSidebarMaxWidth,
showLeft: true,
showRight: true
)
XCTAssertGreaterThanOrEqual(widths.left, layout.leftSidebarMinWidth - 0.001)
XCTAssertGreaterThanOrEqual(widths.right, layout.rightSidebarMinWidth - 0.001)
XCTAssertLessThanOrEqual(widths.left, layout.leftSidebarMaxWidth + 0.001)
XCTAssertLessThanOrEqual(widths.right, layout.rightSidebarMaxWidth + 0.001)
assertDocumentWidthIsPreserved(
layout: layout,
availableWidth: 960,
requestedLeft: layout.leftSidebarMaxWidth,
requestedRight: layout.rightSidebarMaxWidth,
showLeft: true,
showRight: true
)
}
func testWideProfileUsesRoomierSidebarsWhilePreservingDocumentWidth() {
let layout = ReaderAdaptiveLayout(width: 1_280)
XCTAssertEqual(layout.sizeClass, .wide)
XCTAssertGreaterThan(layout.rightSidebarIdealWidth, ReaderAdaptiveLayout(width: 960).rightSidebarIdealWidth)
XCTAssertGreaterThan(layout.documentMinWidth, ReaderAdaptiveLayout(width: 960).documentMinWidth)
assertDocumentWidthIsPreserved(
layout: layout,
availableWidth: 1_280,
requestedLeft: layout.leftSidebarIdealWidth,
requestedRight: layout.rightSidebarIdealWidth,
showLeft: true,
showRight: true
)
}
private func assertDocumentWidthIsPreserved(
layout: ReaderAdaptiveLayout,
availableWidth: CGFloat,
requestedLeft: CGFloat,
requestedRight: CGFloat,
showLeft: Bool,
showRight: Bool,
file: StaticString = #filePath,
line: UInt = #line
) {
let widths = layout.resolvedSidebarWidths(
availableWidth: availableWidth,
requestedLeft: requestedLeft,
requestedRight: requestedRight,
showLeft: showLeft,
showRight: showRight
)
let documentWidth = layout.visibleContentWidth(
availableWidth: availableWidth,
leftWidth: widths.left,
rightWidth: widths.right,
showLeft: showLeft,
showRight: showRight
)
XCTAssertGreaterThanOrEqual(documentWidth, layout.documentMinWidth - 0.001, file: file, line: line)
}
}

View File

@@ -4,9 +4,13 @@ Bundle ID: `net.akkolli.ihatepdfs`
Current App Store build values: Current App Store build values:
- `CFBundleShortVersionString`: `0.3.0` - `CFBundleShortVersionString`: `0.4.0`
- `CFBundleVersion`: `4` - `CFBundleVersion`: `6`
- Privacy policy URL: `https://www.akkolli.net/ihatepdfs/privacy` - Privacy policy URL: `https://www.akkolli.net/ihatepdfs/privacy`
- Marketing/support URL: `https://www.akkolli.net/ihatepdfs`
Paste-ready metadata, review notes, privacy answers, and screenshot guidance live in `docs/APP_STORE_COPY.md`.
The general release checklist is in `docs/RELEASE.md`.
## Required Apple Developer Items ## Required Apple Developer Items
@@ -33,12 +37,24 @@ PROVISIONING_PROFILE="$HOME/Downloads/IHatePDFs_AppStore.provisionprofile" \
scripts/make-app-store-pkg.sh scripts/make-app-store-pkg.sh
``` ```
The package is written to `dist/IHatePDFs-v0.3-macos-appstore.pkg`. The package is written to `dist/IHatePDFs-v0.4-macos-appstore.pkg`.
The script derives the App Store application identifier and team identifier from the provisioning profile before signing. It also clears download quarantine metadata from the bundle before packaging, because App Store Connect rejects packages that contain quarantine extended attributes. The script derives the App Store application identifier and team identifier from the provisioning profile before signing. It builds the App Store app in a temporary staging directory, so the direct-download `dist/I Hate PDFs.app` remains a clean app bundle without an embedded provisioning profile. It also clears download quarantine metadata from the staged bundle before packaging, because App Store Connect rejects packages that contain quarantine extended attributes.
If macOS opens a Keychain private-key access prompt during `codesign`, approve it, preferably with Always Allow for the selected signing certificate, and rerun the command. The build cannot finish unattended until the private key for the selected application signing certificate is allowed.
Before uploading, verify that the package matches the current build number:
```sh
REQUIRE_APP_STORE_PKG=1 scripts/verify-release-artifacts.sh
```
This catches stale package files, bundle-ID mismatches, missing embedded provisioning profiles, missing sandbox/user-selected-file entitlements, and app/package version mismatches.
Use `pkgutil --check-signature` and App Store Connect or Transporter validation for this App Store package. A local `spctl -t install` assessment is a Developer ID distribution check and may reject a package signed with the Mac App Store `3rd Party Mac Developer Installer` identity even when the package signature is valid for App Store upload.
## Upload ## Upload
Upload the `.pkg` with Transporter. You can also set `VALIDATE_WITH_ALTOOL=1` when running `scripts/make-app-store-pkg.sh` if you want the script to perform an `altool` validation after packaging. After App Store Connect processes the build, select it in the app version, finish metadata, answer App Privacy, fill review notes, and submit for review. Upload the `.pkg` with Transporter. You can also set `VALIDATE_WITH_ALTOOL=1` when running `scripts/make-app-store-pkg.sh` if you want the script to perform an `altool` validation after packaging. After App Store Connect processes the build, select it in the app version, finish metadata, answer App Privacy, fill review notes, and submit for review.
Keep `CFBundleShortVersionString` as `0.3.0` and `CFBundleVersion` as `4` for this upload. Increment `BUILD_NUMBER` in `scripts/release-version.sh` before uploading another build for the same version. Keep `CFBundleShortVersionString` as `0.4.0` and `CFBundleVersion` as `6` for the next upload. Increment `BUILD_NUMBER` in `scripts/release-version.sh` before uploading another build for the same version.

97
docs/APP_STORE_COPY.md Normal file
View File

@@ -0,0 +1,97 @@
# App Store Copy
Use this as the source of truth when filling out App Store Connect for `net.akkolli.ihatepdfs`.
## URLs
- Marketing URL: `https://www.akkolli.net/ihatepdfs`
- Support URL: `https://www.akkolli.net/ihatepdfs`
- Privacy Policy URL: `https://www.akkolli.net/ihatepdfs/privacy`
## Copyright
`2026 Akshay Kolli`
## App Information
- Name: `I Hate PDFs`
- Subtitle: `A small PDF review app`
- Category: `Productivity`
- Secondary category: `Education`
## Promotional Text
Read, highlight, comment, and review local PDFs without accounts, cloud upload, or heavyweight document management.
## Description
I Hate PDFs is a small native macOS app for reading and reviewing local PDF files.
Open a PDF, highlight important text, add comments, write free-text notes, and review everything from a compact comments sidebar. The app writes standard PDF annotations so your saved files can be opened in common PDF readers like Preview and Adobe Acrobat.
The app is intentionally lightweight. It uses native macOS document behavior, keeps your PDFs on your Mac, and does not require an account.
Features:
- Open local PDF files from disk, Finder, drag and drop, or recent documents.
- Read with native PDFKit scrolling, zoom, fit-to-width, fit-to-page, page navigation, and search.
- Highlight selected text without opening an unnecessary comment box.
- Add selected-text comments, underline comments, and free-text notes.
- Press Return to save comments and Shift-Return to insert a new line.
- Review annotations in a comments sidebar with search, filters, replies, edit, delete, and click-to-navigate.
- Customize highlight and comment colors from Settings.
- Save annotations directly into a PDF after an overwrite warning, or use Save As for a separate annotated copy.
- Share the saved PDF through the native macOS share sheet.
Privacy:
I Hate PDFs does not collect analytics, does not require sign-in, and does not upload your documents. Your PDFs stay on your Mac unless you choose to share them.
## Keywords
pdf,reader,annotate,highlight,comments,review,professor,academic,documents,notes
## What's New
Keeps the app focused on fast local PDF reading and annotation, with recent PDFs, focused open behavior, sidebar polish, bookmarks, highlight sorting, and Settings for annotation colors.
## App Review Notes
I Hate PDFs is a local macOS PDF reader and annotation utility.
No account is required. The reviewer can test with any local `.pdf` file. The app asks for user-selected file access only when opening, saving, or sharing a PDF.
Suggested review path:
1. Launch the app.
2. Open or drag in any PDF.
3. Select text and add a highlight or comment.
4. Open the comments sidebar to review annotations.
5. Save As to create an annotated copy.
6. Open Settings with Command-, or File > Settings... to change annotation colors.
7. Open Bookmarks and Highlights from the sidebar controls to verify the review views.
## App Privacy Answers
Recommended App Privacy summary:
- Data collection: no data collected.
- Tracking: no tracking.
- Third-party advertising: no.
- User account required: no.
The app works with user-selected local PDF files. It does not transmit documents to a server and does not include analytics.
## Screenshot Checklist
Use real bitmap screenshots, not drawn SVG mockups.
Required minimum set:
- Empty window with the Open PDF action and compact Recent PDFs area.
- Main reading view with a PDF page dominant.
- Highlight or comment editor popover on selected text.
- Comments sidebar with several annotations and at least one reply.
- Settings window showing color controls.
Capture light-mode screenshots first. Add dark-mode screenshots if they make the product quality clearer.

View File

@@ -21,7 +21,8 @@ Status: Pass for the current version 1 implementation direction, with manual vis
- Platform fit: The app is macOS-only, targets macOS 13 or newer, uses SwiftUI/AppKit/PDFKit, and ships as a normal `.app` bundle inside a `.dmg`. - Platform fit: The app is macOS-only, targets macOS 13 or newer, uses SwiftUI/AppKit/PDFKit, and ships as a normal `.app` bundle inside a `.dmg`.
- Window and toolbar: Primary document controls live in the titlebar toolbar, grouped by opening/sharing, navigation, zoom, annotation, search, and saving. - Window and toolbar: Primary document controls live in the titlebar toolbar, grouped by opening/sharing, navigation, zoom, annotation, search, and saving.
- Menus and shortcuts: File, View, and Annotate commands are available through native command menus with standard keyboard shortcuts where appropriate. - Menus and shortcuts: File, View, and Annotate commands are available through native command menus with standard keyboard shortcuts where appropriate.
- Sidebars: Page thumbnails, annotation list, and comments review are optional sidebars. The default open-PDF state is single-pane reading, and sidebars open only when requested or restored from user preference. - Sidebars: Page thumbnails, annotation list, and comments review are optional sidebars. The default open-PDF state is single-pane reading, and sidebars open only when requested.
- Responsive layout: Compact windows use a compact toolbar/status treatment and keep sidebars mutually exclusive; regular and wide windows can show both sidebars while preserving a usable PDF reading width.
- Comments review: The comments sidebar uses a compact review-stream layout with a visible total count, add-comment affordance, collapsible page groups, hidden search/filter controls, and connected reply threads. - Comments review: The comments sidebar uses a compact review-stream layout with a visible total count, add-comment affordance, collapsible page groups, hidden search/filter controls, and connected reply threads.
- Color and appearance: The UI uses system colors and materials, so light mode, dark mode, and automatic appearance inherit from macOS. - Color and appearance: The UI uses system colors and materials, so light mode, dark mode, and automatic appearance inherit from macOS.
- Typography: Text uses system fonts and native SwiftUI controls; no custom brand typography is used in the reading interface. - Typography: Text uses system fonts and native SwiftUI controls; no custom brand typography is used in the reading interface.
@@ -33,6 +34,6 @@ Status: Pass for the current version 1 implementation direction, with manual vis
- Run the app on both Apple Silicon and Intel hardware. - Run the app on both Apple Silicon and Intel hardware.
- Verify contrast and focus states in light and dark mode. - Verify contrast and focus states in light and dark mode.
- Verify toolbar and sidebar behavior at narrow and wide window sizes. - Verify toolbar and sidebar behavior at compact, regular, and wide window sizes.
- Verify keyboard-only operation for opening, searching, navigating, annotating, saving, and reviewing comments. - Verify keyboard-only operation for opening, searching, navigating, annotating, saving, and reviewing comments.
- Verify VoiceOver labels for toolbar buttons and sidebar controls. - Verify VoiceOver labels for toolbar buttons and sidebar controls.

View File

@@ -24,6 +24,8 @@ Every change should aim for the smallest final app that still delivers the requi
The release DMG should stay as small as practical. Treat size growth as a product regression, not just a packaging detail. The release DMG should stay as small as practical. Treat size growth as a product regression, not just a packaging detail.
Hard release-size budget: each direct-download per-architecture installer must be under 400 KB, measured as fewer than 400,000 bytes. Universal builds may be larger, but they do not satisfy the small-download budget. Run `scripts/make-tiny-archives.sh` before release; it builds and checks `IHatePDFs-v<version>-macos-arm64.tar.xz` and `IHatePDFs-v<version>-macos-x86_64.tar.xz` by default.
Before merging release-impacting work, compare: Before merging release-impacting work, compare:
```sh ```sh

View File

@@ -0,0 +1,35 @@
# Functionality Audit
Date: 2026-06-29
## Current Build Scope
I Hate PDFs v0.4 is a small native macOS PDF reader and annotation app. The shipping scope is:
- Local PDF opening, recent documents, drag/drop, close-current-PDF, and focused reader startup.
- PDFKit reading, page navigation, zoom, fit controls, search, and responsive sidebars.
- Highlights, underline comments, selected-text comments, free text, replies, review state, filters, grouped comments, bookmarks, and highlight sorting.
- Settings for highlight and comment colors.
- Save, Save As, native Share, overwrite warnings, unsent reply-draft warnings, and empty temporary annotation cleanup.
- Lightweight release packaging with `.app`, `.dmg`, tiny per-architecture archives, and App Store package scripts.
## Removed Scope
The experimental Fill & Sign and PDF signing work has been removed from source, tests, scripts, settings, menus, and release docs for v0.4. This includes custom flat fill marks, form-field scanning/navigation, form choice editing, Keychain-backed PDF signing, signature validation/inspection, and signed-document save branching.
## Verification
Run before release:
```sh
swift build
swift test
swift scripts/verify-sample-pdf.swift
swift scripts/verify-pdf-annotations.swift
scripts/build-app.sh
scripts/make-dmg.sh
scripts/make-tiny-archives.sh
scripts/verify-release-artifacts.sh
```
Manual QA remains documented in `docs/QA.md`.

View File

@@ -2,6 +2,25 @@
Run this checklist before tagging a public release. Run this checklist before tagging a public release.
## Latest v0.4 Automated QA Run
Completed on 2026-06-29:
- `swift build`
- `swift test`
Before release, also run:
```sh
swift scripts/verify-sample-pdf.swift
swift scripts/verify-pdf-annotations.swift
swift build -c release --product IHatePDFs
scripts/build-app.sh
scripts/make-dmg.sh
scripts/make-tiny-archives.sh
scripts/verify-release-artifacts.sh
```
## Test Files ## Test Files
Use at least: Use at least:
@@ -14,51 +33,36 @@ Use at least:
## App Workflow ## App Workflow
1. Open the PDF in I Hate PDFs. 1. Open a PDF and verify it starts in focused single-pane reading: the PDF is fit to the available window width and all sidebars are hidden.
2. Close the PDF, then drag a `.pdf` file onto the empty no-document window and verify it opens. 2. Close the PDF, then drag a `.pdf` file onto the empty window and verify it opens.
3. Open Settings from File > Settings... and with Command-, then verify highlight color, comment color, and opacity changes can be edited and reset. 3. Open one or more PDFs, close the current PDF, and verify recent PDFs appear in the empty window and File > Open Recent.
4. Select text and add a highlight; verify no comment popover opens. 4. Open Settings from File > Settings... and with Command-, then verify highlight and comment colors can be edited and reset.
5. Select text and add a comment; verify the comment color matches the Settings value. 5. Select text and add a highlight; verify no comment popover opens.
6. In the comment box, press Shift-Return and verify it inserts a new line, then press Return and verify the comment is saved. 6. Select text and add a comment; verify the comment color matches the Settings value.
7. Add an underline with a comment. 7. In the comment box, press Shift-Return and verify it inserts a new line, then press Return and verify the comment is saved.
8. Select text, right-click, and add a comment from the context menu. 8. Add an underline with a comment.
9. Add free text directly on the page. 9. Select text, right-click, and add a comment from the context menu.
10. Open the comments sidebar and verify count, grouping, search, filters, edit, delete, reply, and click-to-navigate. 10. Add free text directly on the page.
11. Quit and reopen the same PDF at the same approximate window width and verify the app restores that PDF's sidebar state; then open a different PDF and verify it starts in focused single-pane reading unless that document has its own saved state. 11. Open the comments sidebar and verify count, grouping, search, filters, edit, delete, reply, and click-to-navigate.
12. Add at least one reply and verify the comments sidebar presents the thread like a clean review/chat stream, with a visible connector line from the parent comment to the reply. 12. Open Pages, Marks, Comments, Highlights, and Bookmarks sidebars, then close and reopen the PDF; verify the reopened document returns to focused single-pane reading with sidebars hidden.
13. Hover a comment row and verify the corresponding PDF text is highlighted; click both the parent comment text and the reply text in the sidebar and verify the PDF view navigates to and selects the corresponding annotation. 13. Open Bookmarks, click the right-sidebar toolbar icon, and verify the right sidebar closes instead of switching to Comments; click it again and verify Bookmarks reopens.
14. Click on commented text and underlined text and verify the comment popover opens; then click the line below or nearby whitespace and verify no popover opens. 14. Resize the same document through compact, regular, and wide widths; verify compact windows keep sidebars mutually exclusive while regular and wide windows can show both sidebars without shrinking the PDF below a usable reading width.
15. Verify highlights, comment markers, hidden page-level replies, and selected sidebar rows use muted native-feeling colors in light mode and do not visually overpower the document. 15. Hover and drag the divider between the PDF and each sidebar and verify the resize cursor/hover affordance is easy to grab without an oversized visual handle.
16. Switch the app to dark mode and verify the reading background, comments sidebar, editor popover, connector lines, selected rows, text fields, and annotation markers remain legible and restrained. 16. Add at least one reply and verify the comments sidebar presents the thread clearly.
17. Save As an annotated copy. 17. Hover a comment row and verify the corresponding PDF text is highlighted; click parent and reply rows and verify the PDF navigates correctly.
18. Reopen the annotated copy in I Hate PDFs and verify the annotations and comments remain. 18. Click commented text and underlined text and verify the comment popover opens; click nearby whitespace and verify no popover opens.
19. Save over a disposable original and verify the overwrite warning appears. 19. Switch the app to dark mode and verify the reading background, sidebars, editor popover, selected rows, text fields, and annotation markers remain legible.
20. Add an annotation and verify the window shows the native macOS unsaved/edited document indicator until the PDF is saved. 20. Save As an annotated copy.
21. Search for a word, close the search toolbar, and verify the match highlights disappear; repeat after opening a different PDF to confirm stale search highlights do not carry over. 21. Reopen the annotated copy in I Hate PDFs and verify annotations and comments remain.
22. Type an invalid page number and an out-of-range page number in the page field, and verify the app restores the current page number with a clear status message; also verify previous/next page controls disable at the first and last pages. 22. Save over a disposable original and verify the overwrite warning appears.
23. Apply comment filters or search text that hide every comment, verify the empty state offers Clear Filters, and verify page counts include visible replies. 23. Add an annotation and verify the window shows the native macOS unsaved/edited document indicator until the PDF is saved.
24. Collapse a page group in the comments sidebar, search for a comment on that page, and verify the matching results are shown while the filter is active. 24. Search for a word, close the search toolbar, and verify match highlights disappear.
25. Start typing a sidebar reply, click Reply on a different comment, and verify the original draft remains until you send or cancel it. 25. Type invalid and out-of-range page numbers and verify the app restores the current page number with a clear status message.
26. Click one comment row, then click Reply on a different comment or reply, and verify the sidebar selection and PDF highlight move to the reply target. 26. Apply comment filters or search text that hide every comment, verify the empty state offers Clear Filters, and verify page counts include visible replies.
27. Click one comment row, then click Edit or the review-status chip on a different row, and verify the sidebar selection and PDF highlight move to the edited or reviewed row. 27. Start typing a sidebar reply, click Reply on a different comment, and verify the original draft remains until sent or canceled.
28. Set a comments-sidebar filter and collapse a page group, then open another PDF and verify the comments sidebar starts unfiltered with page groups expanded. 28. Hide a sidebar or apply a filter that removes the selected row and verify the PDF selection highlight clears.
29. In Settings, choose very low-opacity highlight and comment colors, add each annotation type, and verify saved annotations remain visibly readable. 29. Start typing a sidebar reply without sending it, then close, replace, save, or share the PDF and verify the app warns before omitting or discarding the draft.
30. Start typing a sidebar reply without sending it, then close or replace the PDF and verify the app asks before discarding the draft and the window shows the edited indicator while the draft exists. 30. Create a new selected-text comment or free text, leave its popover empty, choose Save or Share, and verify the temporary empty annotation is discarded.
31. Start typing a sidebar reply without sending it, choose Share, and verify the app warns that the draft will not be included unless it is sent first.
32. Start typing a sidebar reply, delete the comment thread it belongs to, and verify the app asks before discarding the reply draft.
33. Add replies to a comment, delete the parent comment from both the sidebar and the popover path in separate runs, and verify the whole thread is removed each time.
34. Hover a comment row until the matching PDF annotation highlights, then hide the comments sidebar or apply a filter that removes the row and verify the hover highlight clears.
35. Hover and click a sidebar reply, and verify the PDF scrolls to and highlights the visible parent annotation rather than jumping to a hidden reply marker.
36. Search for a word with matches, edit the search field without pressing Return, and verify old PDF match highlights clear and previous/next search buttons disable until the new query is submitted.
37. Start typing a sidebar reply without sending it, choose Save As, and verify the app warns that the draft will not be included unless it is sent first.
38. Create a new selected-text comment or free text, leave its popover empty, choose Save before closing the popover, and verify the temporary empty annotation is discarded instead of saved.
39. Start typing a sidebar reply with no other unsaved annotation changes and verify the status bar shows a reply draft instead of presenting the PDF as clean.
40. Search for a word with matches, step through results, and verify the status bar reports the current match position; then search for text that is not present and verify PDF match highlights clear.
41. Open comments search and verify the field is focused immediately; enter a search, hide the search controls, and verify the search icon still indicates an active hidden filter.
42. Select a comment row, apply a comments-sidebar filter or search that hides that row, and verify the PDF selection highlight clears instead of lingering on the page.
43. Create a new selected-text comment or free text, leave its popover empty, choose Share, and verify the temporary empty annotation is discarded before any Save and Share output is written.
44. Select a comment or annotation row, hide the only sidebar that shows that row, and verify the PDF selection highlight clears; repeat while the left Annotations sidebar is visible and verify the selection stays visible there.
45. Select a comment row, collapse its page group in the comments sidebar, and verify the PDF selection highlight clears; then search/filter comments and verify matching page groups expand while filtering.
## External Readers ## External Readers
@@ -69,36 +73,6 @@ swift scripts/verify-sample-pdf.swift
swift scripts/verify-pdf-annotations.swift swift scripts/verify-pdf-annotations.swift
``` ```
These checks generate an annotated PDF, reopen it with PDFKit, and inspect the raw PDF annotation dictionaries for standard `/Highlight`, `/Underline`, `/Text`, `/FreeText`, `/Contents`, `/QuadPoints`, `/IRT`, `/RT`, and `/Parent` entries. These checks generate an annotated PDF, reopen it with PDFKit, and inspect raw PDF annotation dictionaries for standard `/Highlight`, `/Underline`, `/Text`, `/FreeText`, `/Contents`, `/QuadPoints`, `/IRT`, `/RT`, and `/Parent` entries.
For Preview interoperability, exported markup comments should keep the comment text on the parent annotation's standard `/Contents` key and should not depend on PDFKit-generated `/Popup` links for highlights or underlines. For Preview, Acrobat Reader, and browser PDF viewers, verify exported markup comments keep their comment text on the parent annotation's standard `/Contents` key and do not depend on PDFKit-generated `/Popup` links for highlights or underlines.
Open the saved annotated copy in:
- macOS Preview
- Adobe Acrobat Reader
- Safari, Chrome, and Firefox PDF viewers where annotations are supported
Verify:
- Highlighted text remains highlighted.
- Underlined text remains underlined.
- Selected-text comments remain attached to the referenced text.
- Highlight and selected-text comments can be opened.
- Free text remains visible on the page.
- Existing text, images, layout, bookmarks, and prior annotations remain intact.
## Visual QA Screenshots
Capture current screenshots in `docs/screenshots` for:
- `no-document.png`
- `default-reading.png`
- `highlight-comment-popover.png`
- `selected-text-comment-popover.png`
- `comments-sidebar.png`, including at least one reply thread with a visible connector line
- `dark-mode-reading.png`
## Known Version 1 Limitation
PDFKit rejects object-valued `/IRT` reply relationships through its public API. Replies created in this app are saved as standard `/Text` annotations with string `/IRT` and `/RT` reply keys, but the reply annotation is hidden on the PDF page so it appears as a threaded sidebar reply instead of a second page icon. Full cross-reader reply-thread presentation must be verified and improved with a lower-level PDF writer if needed.

68
docs/RELEASE.md Normal file
View File

@@ -0,0 +1,68 @@
# Release Workflow
Use this checklist when preparing a new public version.
## Version Source
The release version lives in one place:
```sh
scripts/release-version.sh
```
For a new app version, update `APP_VERSION` and reset or increment `BUILD_NUMBER`.
For another upload of the same app version, leave `APP_VERSION` alone and increment
`BUILD_NUMBER`.
`RELEASE_VERSION` defaults to the app version without a trailing `.0`, so `0.4.0`
produces release artifacts named with `v0.4`.
## Required Checks
Run these before tagging or uploading:
```sh
swift test
swift scripts/verify-sample-pdf.swift
swift scripts/verify-pdf-annotations.swift
scripts/build-app.sh
BUILD_APP=0 scripts/make-dmg.sh
scripts/make-tiny-archives.sh
scripts/verify-release-artifacts.sh
```
`scripts/make-tiny-archives.sh` builds per-architecture direct-download archives and
fails if either archive is `>= 400,000` bytes.
For signature changes, also run:
```sh
USE_TEMP_SIGNING_KEYCHAIN=1 scripts/verify-pdf-signatures.sh
scripts/prepare-acrobat-qa.sh
```
Finish the manual reader checks in `docs/QA.md` before public distribution.
## Direct Download
The direct-download release artifacts are generated under `dist/`:
- `I Hate PDFs.app`
- `IHatePDFs-v<release>-macos.dmg`
- `IHatePDFs-v<release>-macos-arm64.tar.xz`
- `IHatePDFs-v<release>-macos-x86_64.tar.xz`
`dist/` and root package/archive extensions are ignored so generated outputs do not
pollute source control.
## App Store
Use `docs/APP_STORE.md` for signing identities, provisioning profile setup, and the
upload package command. After building the App Store package, run:
```sh
REQUIRE_APP_STORE_PKG=1 scripts/verify-release-artifacts.sh
```
Do not change App Store entitlements unless a shipped feature requires the new
capability.

53
docs/WORKFLOW_AUDIT.md Normal file
View File

@@ -0,0 +1,53 @@
# Workflow Audit
Date: 2026-06-29
This file records the intended v0.4 user flow. It is the source of truth when checking whether a feature matches the product workflow before changing or releasing it.
## Current Capabilities
1. Open local PDFs from an open panel, drag/drop, recent documents, and file URLs.
2. Start each opened PDF in focused reading: sidebars hidden, PDF fit to available width, previous document sidebar state ignored.
3. Read with PDFKit scrolling, page navigation, zoom, fit width, fit page, two-page continuous view, and search.
4. Use a responsive layout across compact, regular, and wide Mac windows.
5. Use the left sidebar for page thumbnails and annotation marks.
6. Use the right sidebar for Comments, Highlights, and Bookmarks; the right-sidebar toolbar button is a visibility toggle for the active right mode.
7. Highlight selected text, use highlighter mode, and choose highlight colors.
8. Add selected-text comments, underline comments, and free-text annotations.
9. Use PDF-view shortcuts `H`, `U`, and `C` without conflicting with Command-C.
10. Edit/delete annotations and comment threads through anchored popovers and sidebar controls.
11. Review comments with search, filters, page grouping, collapsed groups, review state, replies, hover highlighting, edit/delete, and navigation.
12. Review highlights sorted by color or page.
13. Add, remove, and navigate per-document bookmarks.
14. Configure highlight and comment colors in Settings.
15. Save, Save As, Share, warn for unsent reply drafts, discard empty temporary editors, and warn before overwriting originals.
16. Package a small native app through the release scripts, DMG script, tiny archive script, and App Store package script.
## Removed From v0.4
The experimental Fill & Sign, custom form-field navigation, form choice popover, PDF signing, signature inspection, signed-document save safeguards, signature QA scripts, and related tests were removed from v0.4. Revisit that work later as a smaller design from scratch.
## Workflow Decisions
- Opened PDFs intentionally reset to focused single-pane reading with sidebars hidden. Opening a PDF should maximize the reading area and leave comments, highlights, bookmarks, page thumbnails, and annotation marks closed until the user asks for them.
- Plain Highlight is standalone. Selected-text Comment and Underline open the anchored editor.
- The visible Save toolbar icon is intentionally absent. Save remains available from the File menu and keyboard shortcut; Share remains visible.
- Compact windows intentionally make left and right sidebars mutually exclusive to preserve usable PDF width.
- Sidebar resize handles should be easy to grab through hover/cursor affordance without becoming large visual dividers.
## Regression Coverage
- Focused reading layout after opening a PDF.
- Returning to the empty-window workflow after closing a PDF.
- Compact one-sidebar-at-a-time behavior.
- Page sidebar toggle closing the active Marks sidebar instead of switching to Pages first.
- Right-sidebar toolbar toggle closing and reopening the current right mode without switching tabs.
- Regular-width ability to show navigation and review sidebars together.
- Save availability for clean, dirty, and reply-draft-only states.
## Manual QA Gaps
- Real Finder drag/drop, menu disabled states, visual Settings interaction, alert button flows, native share picker, popover text focus, and sidebar resize affordances need UI automation or manual QA.
- Preview, Acrobat Reader, and browser PDF-viewer checks remain external interoperability gates even though raw PDF structure checks exist.
- Cross-reader reply-thread display is not fully proven because PDFKit public APIs do not provide reliable object-valued `/IRT` writing. Primary annotation comments remain standard `/Contents`.
- Screenshot docs still need recapture before public marketing or release use.

View File

@@ -1,22 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="760" height="920" viewBox="0 0 760 920" role="img" aria-label="I Hate PDFs comments sidebar screenshot">
<rect width="760" height="920" fill="#f5f5f7"/>
<rect x="80" y="60" width="600" height="800" rx="10" fill="#fbfbfd" stroke="#c9c9ce"/>
<text x="118" y="118" font-family="-apple-system, BlinkMacSystemFont, sans-serif" font-size="26" fill="#202124">Comments</text>
<text x="118" y="146" font-family="-apple-system, BlinkMacSystemFont, sans-serif" font-size="14" fill="#606166">4 total</text>
<rect x="118" y="176" width="524" height="34" rx="7" fill="#ffffff" stroke="#d1d1d6"/>
<text x="136" y="198" font-family="-apple-system, BlinkMacSystemFont, sans-serif" font-size="13" fill="#7a7a80">Search comments</text>
<rect x="118" y="242" width="524" height="1" fill="#d8d8dd"/>
<text x="118" y="282" font-family="-apple-system, BlinkMacSystemFont, sans-serif" font-size="17" font-weight="600" fill="#202124">Page 2</text>
<rect x="118" y="306" width="524" height="154" rx="8" fill="#ffffff" stroke="#d5d5da"/>
<text x="140" y="344" font-family="-apple-system, BlinkMacSystemFont, sans-serif" font-size="14" font-weight="600" fill="#202124">Highlight</text>
<text x="140" y="374" font-family="-apple-system, BlinkMacSystemFont, sans-serif" font-size="13" fill="#606166">Professor</text>
<text x="140" y="406" font-family="-apple-system, BlinkMacSystemFont, sans-serif" font-size="15" fill="#202124">This is the core passage for Friday's discussion.</text>
<rect x="150" y="480" width="492" height="86" rx="8" fill="#f1f8f8" stroke="#c8dddd"/>
<text x="172" y="516" font-family="-apple-system, BlinkMacSystemFont, sans-serif" font-size="13" font-weight="600" fill="#202124">Reply</text>
<text x="172" y="546" font-family="-apple-system, BlinkMacSystemFont, sans-serif" font-size="13" fill="#606166">Add this to the lecture notes.</text>
<text x="118" y="624" font-family="-apple-system, BlinkMacSystemFont, sans-serif" font-size="17" font-weight="600" fill="#202124">Page 5</text>
<rect x="118" y="648" width="524" height="118" rx="8" fill="#ffffff" stroke="#d5d5da"/>
<text x="140" y="686" font-family="-apple-system, BlinkMacSystemFont, sans-serif" font-size="14" font-weight="600" fill="#202124">Note</text>
<text x="140" y="716" font-family="-apple-system, BlinkMacSystemFont, sans-serif" font-size="13" fill="#606166">Professor</text>
<text x="140" y="748" font-family="-apple-system, BlinkMacSystemFont, sans-serif" font-size="15" fill="#202124">Compare this footnote with the appendix.</text>
</svg>

Before

Width:  |  Height:  |  Size: 2.5 KiB

View File

@@ -1,23 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1440" height="920" viewBox="0 0 1440 920" role="img" aria-label="I Hate PDFs main window screenshot">
<rect width="1440" height="920" fill="#f5f5f7"/>
<rect x="40" y="40" width="1360" height="840" rx="10" fill="#fbfbfd" stroke="#c9c9ce"/>
<rect x="40" y="40" width="1360" height="52" rx="10" fill="#ececf0"/>
<circle cx="68" cy="66" r="7" fill="#ff5f57"/>
<circle cx="92" cy="66" r="7" fill="#febc2e"/>
<circle cx="116" cy="66" r="7" fill="#28c840"/>
<rect x="64" y="116" width="210" height="724" fill="#f1f1f4"/>
<rect x="308" y="132" width="650" height="692" fill="#ffffff" stroke="#d1d1d6"/>
<rect x="358" y="190" width="550" height="14" fill="#d8d8dd"/>
<rect x="358" y="228" width="480" height="14" fill="#d8d8dd"/>
<rect x="358" y="266" width="520" height="14" fill="#f7dc55" opacity="0.75"/>
<rect x="358" y="304" width="470" height="14" fill="#d8d8dd"/>
<rect x="358" y="342" width="510" height="14" fill="#d8d8dd"/>
<rect x="996" y="116" width="364" height="724" fill="#f7f7f9"/>
<text x="1020" y="158" font-family="-apple-system, BlinkMacSystemFont, sans-serif" font-size="24" fill="#202124">Comments</text>
<rect x="1020" y="190" width="316" height="118" rx="8" fill="#ffffff" stroke="#d5d5da"/>
<text x="1040" y="226" font-family="-apple-system, BlinkMacSystemFont, sans-serif" font-size="15" fill="#202124">Highlight</text>
<text x="1040" y="254" font-family="-apple-system, BlinkMacSystemFont, sans-serif" font-size="13" fill="#606166">Discuss this claim in class.</text>
<rect x="1020" y="326" width="316" height="118" rx="8" fill="#ffffff" stroke="#d5d5da"/>
<text x="1040" y="362" font-family="-apple-system, BlinkMacSystemFont, sans-serif" font-size="15" fill="#202124">Note</text>
<text x="1040" y="390" font-family="-apple-system, BlinkMacSystemFont, sans-serif" font-size="13" fill="#606166">Connect to the seminar reading.</text>
</svg>

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 522 KiB

10
goal.md
View File

@@ -40,9 +40,7 @@ Minimum supported version should be explicitly chosen before development. Recomm
## License ## License
The project should use a permissive open-source license unless there is a specific reason not to. Recommended: The project is licensed under the GNU General Public License version 2.
- MIT License or Apache 2.0
## Required Features ## Required Features
@@ -101,7 +99,7 @@ The current UI is wrong in these specific ways:
- The app exposes a refresh button in the comments panel, which makes comments feel stale or manually synchronized. Comments must update live when annotations are created, edited, deleted, or selected. - The app exposes a refresh button in the comments panel, which makes comments feel stale or manually synchronized. Comments must update live when annotations are created, edited, deleted, or selected.
- The comment creation flow feels like filling out a form. Adding a comment to selected text or a highlight should feel anchored, immediate, lightweight, and dismissible. - The comment creation flow feels like filling out a form. Adding a comment to selected text or a highlight should feel anchored, immediate, lightweight, and dismissible.
- It is unclear where to type a comment after selecting text. The app needs an obvious anchored comment popover, not a separate form-like editor whose relationship to the highlighted text is ambiguous. - It is unclear where to type a comment after selecting text. The app needs an obvious anchored comment popover, not a separate form-like editor whose relationship to the highlighted text is ambiguous.
- The highlight/comment workflow is too indirect. A professor should be able to select text, click highlight, immediately type a comment in context, press Command-Return or click away, and continue reading. - The comment workflow is too indirect. A professor should be able to select text, click Comment, immediately type in context, press Return or click away, and continue reading. Plain Highlight should stay a fast standalone marking action.
- The comments sidebar does not visibly update as part of the annotation action. A newly created or edited comment should appear immediately without requiring refresh. - The comments sidebar does not visibly update as part of the annotation action. A newly created or edited comment should appear immediately without requiring refresh.
- The PDF margins and surrounding gray space feel accidental and oversized. Page spacing should be tuned for reading: enough separation to orient the user, but not large dead zones. - The PDF margins and surrounding gray space feel accidental and oversized. Page spacing should be tuned for reading: enough separation to orient the user, but not large dead zones.
- Toolbar controls are too dense and visually equal. Primary actions, reading controls, annotation tools, search, and save are competing for attention. - Toolbar controls are too dense and visually equal. Primary actions, reading controls, annotation tools, search, and save are competing for attention.
@@ -254,7 +252,7 @@ The UI must be fixed in this order:
2. Add a compact comments button that toggles the comments review sidebar. 2. Add a compact comments button that toggles the comments review sidebar.
3. Remove the visible comments refresh button and make annotation changes update the sidebar automatically. 3. Remove the visible comments refresh button and make annotation changes update the sidebar automatically.
4. Replace the form-like comment editor with an anchored popover tied to the selected highlight, selection-bound comment, underline, or free-text annotation. 4. Replace the form-like comment editor with an anchored popover tied to the selected highlight, selection-bound comment, underline, or free-text annotation.
5. Make highlight creation open the comment popover immediately after creating the PDF annotation. 5. Keep highlight creation as a standalone action that does not open an editor.
6. Make selected-text comment creation available from the toolbar, comments sidebar, and right-click menu, then open the anchored comment popover immediately. 6. Make selected-text comment creation available from the toolbar, comments sidebar, and right-click menu, then open the anchored comment popover immediately.
7. Tune PDF page margins, page-break spacing, and fit behavior so documents use available space well. 7. Tune PDF page margins, page-break spacing, and fit behavior so documents use available space well.
8. Simplify and regroup the toolbar around reading and annotation tasks. 8. Simplify and regroup the toolbar around reading and annotation tasks.
@@ -434,7 +432,7 @@ The following are explicitly not required:
The project is complete when: The project is complete when:
1. A professor can open a PDF on macOS, highlight text, add a comment, save the file, and reopen it with the annotation still present. 1. A professor can open a PDF on macOS, highlight text, add a comment, save the file, and reopen it with the annotation still present.
2. A second person can open that saved PDF in macOS Preview or Adobe Acrobat Reader and see the highlight and open the comment popup. 2. A second person can open that saved PDF in macOS Preview or Adobe Acrobat Reader and see the highlight and any selected-text comment text.
3. Selection-bound comments created in the app remain visible as standard PDF annotations in other readers. 3. Selection-bound comments created in the app remain visible as standard PDF annotations in other readers.
4. Existing PDF text, images, layout, bookmarks, and prior annotations are not destroyed during save. 4. Existing PDF text, images, layout, bookmarks, and prior annotations are not destroyed during save.
5. The app can be built from source using documented commands. 5. The app can be built from source using documented commands.

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 8.7 KiB

View File

@@ -7,7 +7,9 @@ ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
source "$ROOT_DIR/scripts/release-version.sh" source "$ROOT_DIR/scripts/release-version.sh"
CONFIGURATION="${CONFIGURATION:-release}" CONFIGURATION="${CONFIGURATION:-release}"
BUNDLE_ID="${BUNDLE_ID:-net.akkolli.ihatepdfs}" BUNDLE_ID="${BUNDLE_ID:-net.akkolli.ihatepdfs}"
SIZE_OPTIMIZED="${SIZE_OPTIMIZED:-0}"
STRIP_RELEASE="${STRIP_RELEASE:-1}" STRIP_RELEASE="${STRIP_RELEASE:-1}"
ICON_MAX_SIZE="${ICON_MAX_SIZE:-1024}"
SIGNING_IDENTITY="${SIGNING_IDENTITY:-}" SIGNING_IDENTITY="${SIGNING_IDENTITY:-}"
ENTITLEMENTS_PATH="${ENTITLEMENTS_PATH:-}" ENTITLEMENTS_PATH="${ENTITLEMENTS_PATH:-}"
PROVISIONING_PROFILE="${PROVISIONING_PROFILE:-}" PROVISIONING_PROFILE="${PROVISIONING_PROFILE:-}"
@@ -20,19 +22,33 @@ else
ARCHS="${ARCHS:-}" ARCHS="${ARCHS:-}"
fi fi
DIST_DIR="$ROOT_DIR/dist" DIST_DIR="$ROOT_DIR/dist"
APP_DIR="$DIST_DIR/$APP_NAME.app" APP_DIR="${APP_DIR:-$DIST_DIR/$APP_NAME.app}"
CONTENTS_DIR="$APP_DIR/Contents" CONTENTS_DIR="$APP_DIR/Contents"
MACOS_DIR="$CONTENTS_DIR/MacOS" MACOS_DIR="$CONTENTS_DIR/MacOS"
RESOURCES_DIR="$CONTENTS_DIR/Resources" RESOURCES_DIR="$CONTENTS_DIR/Resources"
ICON_SOURCE="$ROOT_DIR/ihatepdf.png" ICON_SOURCE="${ICON_SOURCE:-$ROOT_DIR/ihatepdf-profile-transparent.png}"
if [[ ! -f "$ICON_SOURCE" ]]; then
echo "Missing app icon source: $ICON_SOURCE" >&2
echo "Set ICON_SOURCE to the path of a transparent PNG icon (for example: $ROOT_DIR/ihatepdf-profile-transparent.png)." >&2
exit 1
fi
if ! sips -g hasAlpha "$ICON_SOURCE" 2>/dev/null | grep -q "hasAlpha: yes"; then
echo "App icon source must include an alpha channel for transparent rendering: $ICON_SOURCE" >&2
exit 1
fi
ICON_NAME="AppIcon" ICON_NAME="AppIcon"
DERIVED_ENTITLEMENTS_PATH="" DERIVED_ENTITLEMENTS_PATH=""
PROFILE_PLIST_PATH="" PROFILE_PLIST_PATH=""
NORMALIZED_ICON_SOURCE=""
cleanup() { cleanup() {
if [[ -n "$DERIVED_ENTITLEMENTS_PATH" ]]; then if [[ -n "$DERIVED_ENTITLEMENTS_PATH" ]]; then
rm -f "$DERIVED_ENTITLEMENTS_PATH" rm -f "$DERIVED_ENTITLEMENTS_PATH"
fi fi
if [[ -n "$NORMALIZED_ICON_SOURCE" ]]; then
rm -f "$NORMALIZED_ICON_SOURCE"
fi
if [[ -n "$PROFILE_PLIST_PATH" ]]; then if [[ -n "$PROFILE_PLIST_PATH" ]]; then
rm -f "$PROFILE_PLIST_PATH" rm -f "$PROFILE_PLIST_PATH"
fi fi
@@ -55,6 +71,13 @@ SWIFT_BUILD_ARGS=(-c "$CONFIGURATION")
for ARCH in $ARCHS; do for ARCH in $ARCHS; do
SWIFT_BUILD_ARGS+=(--arch "$ARCH") SWIFT_BUILD_ARGS+=(--arch "$ARCH")
done done
if [[ "$CONFIGURATION" == "release" && "$SIZE_OPTIMIZED" == "1" ]]; then
SWIFT_BUILD_ARGS+=(
-Xswiftc -Osize
-Xswiftc -Xfrontend -Xswiftc -disable-reflection-metadata
-Xswiftc -Xfrontend -Xswiftc -remove-runtime-asserts
)
fi
BUILD_DIR="$(swift build "${SWIFT_BUILD_ARGS[@]}" --show-bin-path)" BUILD_DIR="$(swift build "${SWIFT_BUILD_ARGS[@]}" --show-bin-path)"
swift build "${SWIFT_BUILD_ARGS[@]}" swift build "${SWIFT_BUILD_ARGS[@]}"
@@ -64,8 +87,21 @@ mkdir -p "$MACOS_DIR" "$RESOURCES_DIR"
cp "$BUILD_DIR/$EXECUTABLE_NAME" "$MACOS_DIR/$EXECUTABLE_NAME" cp "$BUILD_DIR/$EXECUTABLE_NAME" "$MACOS_DIR/$EXECUTABLE_NAME"
if [[ "$CONFIGURATION" == "release" && "$STRIP_RELEASE" != "0" ]]; then if [[ "$CONFIGURATION" == "release" && "$STRIP_RELEASE" != "0" ]]; then
if [[ "$SIZE_OPTIMIZED" == "1" ]]; then
strip -u -r "$MACOS_DIR/$EXECUTABLE_NAME"
else
strip -x "$MACOS_DIR/$EXECUTABLE_NAME" strip -x "$MACOS_DIR/$EXECUTABLE_NAME"
fi fi
fi
NORMALIZED_ICON_SOURCE="$(mktemp /tmp/ihatepdf-appicon-XXXXXX.png)"
if ! sips -s format png "$ICON_SOURCE" --out "$NORMALIZED_ICON_SOURCE" >/dev/null; then
rm -f "$NORMALIZED_ICON_SOURCE"
NORMALIZED_ICON_SOURCE=""
echo "Failed to normalize icon source: $ICON_SOURCE" >&2
exit 1
fi
ICON_SOURCE="$NORMALIZED_ICON_SOURCE"
if [[ -n "$PROVISIONING_PROFILE" ]]; then if [[ -n "$PROVISIONING_PROFILE" ]]; then
if [[ ! -f "$PROVISIONING_PROFILE" ]]; then if [[ ! -f "$PROVISIONING_PROFILE" ]]; then
@@ -88,19 +124,31 @@ mkdir -p "$ICONSET_DIR"
make_icon() { make_icon() {
local pixels="$1" local pixels="$1"
local output="$2" local output="$2"
sips -s format png --resampleHeightWidth "$pixels" "$pixels" "$ICON_SOURCE" --out "$ICONSET_DIR/$output" >/dev/null local output_path="$ICONSET_DIR/$output"
sips -s format png --resampleHeightWidth "$pixels" "$pixels" "$ICON_SOURCE" --out "$output_path" >/dev/null
} }
make_icon 16 "icon_16x16.png" make_icon 16 "icon_16x16.png"
make_icon 32 "icon_16x16@2x.png" make_icon 32 "icon_16x16@2x.png"
make_icon 32 "icon_32x32.png" make_icon 32 "icon_32x32.png"
make_icon 64 "icon_32x32@2x.png" make_icon 64 "icon_32x32@2x.png"
if (( ICON_MAX_SIZE >= 128 )); then
make_icon 128 "icon_128x128.png" make_icon 128 "icon_128x128.png"
fi
if (( ICON_MAX_SIZE >= 128 )); then
make_icon 256 "icon_128x128@2x.png" make_icon 256 "icon_128x128@2x.png"
fi
if (( ICON_MAX_SIZE >= 256 )); then
make_icon 256 "icon_256x256.png" make_icon 256 "icon_256x256.png"
fi
if (( ICON_MAX_SIZE >= 512 )); then
make_icon 512 "icon_256x256@2x.png" make_icon 512 "icon_256x256@2x.png"
make_icon 512 "icon_512x512.png" make_icon 512 "icon_512x512.png"
fi
if (( ICON_MAX_SIZE >= 1024 )); then
make_icon 1024 "icon_512x512@2x.png" make_icon 1024 "icon_512x512@2x.png"
fi
iconutil -c icns "$ICONSET_DIR" -o "$RESOURCES_DIR/$ICON_NAME.icns" iconutil -c icns "$ICONSET_DIR" -o "$RESOURCES_DIR/$ICON_NAME.icns"
rm -rf "$ICONSET_DIR" rm -rf "$ICONSET_DIR"
@@ -156,7 +204,7 @@ cat > "$CONTENTS_DIR/Info.plist" <<PLIST
<key>LSSupportsOpeningDocumentsInPlace</key> <key>LSSupportsOpeningDocumentsInPlace</key>
<true/> <true/>
<key>NSHumanReadableCopyright</key> <key>NSHumanReadableCopyright</key>
<string>MIT License</string> <string>GNU General Public License version 2</string>
</dict> </dict>
</plist> </plist>
PLIST PLIST

View File

@@ -11,9 +11,16 @@ INSTALLER_SIGNING_IDENTITY="${INSTALLER_SIGNING_IDENTITY:-}"
PROVISIONING_PROFILE="${PROVISIONING_PROFILE:-}" PROVISIONING_PROFILE="${PROVISIONING_PROFILE:-}"
ENTITLEMENTS_PATH="${ENTITLEMENTS_PATH:-$ROOT_DIR/Signing/IHatePDFs-AppStore.entitlements}" ENTITLEMENTS_PATH="${ENTITLEMENTS_PATH:-$ROOT_DIR/Signing/IHatePDFs-AppStore.entitlements}"
DIST_DIR="$ROOT_DIR/dist" DIST_DIR="$ROOT_DIR/dist"
APP_DIR="$DIST_DIR/$APP_NAME.app"
PKG_PATH="${PKG_PATH:-$DIST_DIR/IHatePDFs-v$RELEASE_VERSION-macos-appstore.pkg}" PKG_PATH="${PKG_PATH:-$DIST_DIR/IHatePDFs-v$RELEASE_VERSION-macos-appstore.pkg}"
VALIDATE_WITH_ALTOOL="${VALIDATE_WITH_ALTOOL:-0}" VALIDATE_WITH_ALTOOL="${VALIDATE_WITH_ALTOOL:-0}"
STAGING_DIR=""
cleanup() {
if [[ -n "$STAGING_DIR" ]]; then
rm -rf "$STAGING_DIR"
fi
}
trap cleanup EXIT
require_value() { require_value() {
local name="$1" local name="$1"
@@ -35,17 +42,20 @@ require_value "PROVISIONING_PROFILE" "$PROVISIONING_PROFILE" \
"Download an App Store provisioning profile for $BUNDLE_ID and pass its local path." "Download an App Store provisioning profile for $BUNDLE_ID and pass its local path."
mkdir -p "$DIST_DIR" mkdir -p "$DIST_DIR"
rm -f "$PKG_PATH"
STAGING_DIR="$(mktemp -d "$DIST_DIR/appstore-pkg.XXXXXX")"
APP_DIR="$STAGING_DIR/$APP_NAME.app"
BUNDLE_ID="$BUNDLE_ID" \ BUNDLE_ID="$BUNDLE_ID" \
APP_VERSION="$APP_VERSION" \ APP_VERSION="$APP_VERSION" \
BUILD_NUMBER="$BUILD_NUMBER" \ BUILD_NUMBER="$BUILD_NUMBER" \
APP_DIR="$APP_DIR" \
SIGNING_IDENTITY="$APP_SIGNING_IDENTITY" \ SIGNING_IDENTITY="$APP_SIGNING_IDENTITY" \
ENTITLEMENTS_PATH="$ENTITLEMENTS_PATH" \ ENTITLEMENTS_PATH="$ENTITLEMENTS_PATH" \
PROVISIONING_PROFILE="$PROVISIONING_PROFILE" \ PROVISIONING_PROFILE="$PROVISIONING_PROFILE" \
"$ROOT_DIR/scripts/build-app.sh" "$ROOT_DIR/scripts/build-app.sh"
xattr -cr "$APP_DIR" 2>/dev/null || true xattr -cr "$APP_DIR" 2>/dev/null || true
rm -f "$PKG_PATH"
productbuild \ productbuild \
--component "$APP_DIR" /Applications \ --component "$APP_DIR" /Applications \
--sign "$INSTALLER_SIGNING_IDENTITY" \ --sign "$INSTALLER_SIGNING_IDENTITY" \
@@ -63,7 +73,7 @@ if [[ "$VALIDATE_WITH_ALTOOL" == "1" ]]; then
--type macos \ --type macos \
--file "$PKG_PATH" \ --file "$PKG_PATH" \
--username "$ASC_USERNAME" \ --username "$ASC_USERNAME" \
--password "$ASC_PASSWORD" --password "@env:ASC_PASSWORD"
fi fi
echo "Created App Store package: $PKG_PATH" echo "Created App Store package: $PKG_PATH"

View File

@@ -14,11 +14,19 @@ if [[ "$BUILD_APP" != "0" || ! -d "$APP_DIR" ]]; then
fi fi
rm -f "$DMG_PATH" rm -f "$DMG_PATH"
if diskutil image create from --help >/dev/null 2>&1; then
diskutil image create from \
--format UDZO \
--volumeName "$APP_NAME" \
"$APP_DIR" \
"$DMG_PATH"
else
hdiutil create \ hdiutil create \
-volname "$APP_NAME" \ -volname "$APP_NAME" \
-srcfolder "$APP_DIR" \ -srcfolder "$APP_DIR" \
-ov \ -ov \
-format UDZO \ -format UDZO \
"$DMG_PATH" "$DMG_PATH"
fi
echo "Created $DMG_PATH" echo "Created $DMG_PATH"

64
scripts/make-tiny-archives.sh Executable file
View File

@@ -0,0 +1,64 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
source "$ROOT_DIR/scripts/release-version.sh"
APP_NAME="I Hate PDFs"
DIST_DIR="$ROOT_DIR/dist"
STAGING_DIR="$DIST_DIR/tiny"
ARCHS_TO_BUILD="${ARCHS_TO_BUILD:-arm64 x86_64}"
if ! command -v xz >/dev/null 2>&1; then
echo "xz is required to build size-gated tiny archives with architecture filters." >&2
exit 1
fi
rm -rf "$STAGING_DIR"
mkdir -p "$STAGING_DIR"
compression_args_for_arch() {
local arch="$1"
if [[ -n "${XZ_OPT:-}" ]]; then
# Preserve explicit caller overrides.
echo "$XZ_OPT"
return
fi
case "$arch" in
arm64)
echo "--arm64 --lzma2=preset=9e"
;;
x86_64)
echo "--x86 --lzma2=preset=9e"
;;
*)
echo "--lzma2=preset=9e"
;;
esac
}
for ARCH in $ARCHS_TO_BUILD; do
APP_DIR="$STAGING_DIR/$ARCH/$APP_NAME.app"
ARCHIVE_PATH="$DIST_DIR/IHatePDFs-v$RELEASE_VERSION-macos-$ARCH.tar.xz"
rm -f "$ARCHIVE_PATH"
mkdir -p "$(dirname "$APP_DIR")"
ARCHS="$ARCH" \
SIZE_OPTIMIZED=1 \
ICON_MAX_SIZE="${ICON_MAX_SIZE:-32}" \
APP_VERSION="$APP_VERSION" \
BUILD_NUMBER="$BUILD_NUMBER" \
APP_DIR="$APP_DIR" \
"$ROOT_DIR/scripts/build-app.sh"
read -r -a XZ_ARGS <<< "$(compression_args_for_arch "$ARCH")"
COPYFILE_DISABLE=1 tar -C "$(dirname "$APP_DIR")" -cf - "$APP_NAME.app" \
| env XZ_OPT= xz "${XZ_ARGS[@]}" -c > "$ARCHIVE_PATH"
echo "Created $ARCHIVE_PATH"
done
"$ROOT_DIR/scripts/verify-release-size.sh"

View File

@@ -1,5 +1,5 @@
#!/usr/bin/env bash #!/usr/bin/env bash
APP_VERSION="${APP_VERSION:-0.3.0}" APP_VERSION="${APP_VERSION:-0.4.0}"
BUILD_NUMBER="${BUILD_NUMBER:-4}" BUILD_NUMBER="${BUILD_NUMBER:-6}"
RELEASE_VERSION="${RELEASE_VERSION:-${APP_VERSION%.0}}" RELEASE_VERSION="${RELEASE_VERSION:-${APP_VERSION%.0}}"

View File

@@ -0,0 +1,129 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
source "$ROOT_DIR/scripts/release-version.sh"
APP_NAME="I Hate PDFs"
BUNDLE_ID="${BUNDLE_ID:-net.akkolli.ihatepdfs}"
DIST_DIR="$ROOT_DIR/dist"
APP_DIR="$DIST_DIR/$APP_NAME.app"
DMG_PATH="$DIST_DIR/IHatePDFs-v$RELEASE_VERSION-macos.dmg"
PKG_PATH="${PKG_PATH:-$DIST_DIR/IHatePDFs-v$RELEASE_VERSION-macos-appstore.pkg}"
REQUIRE_APP_STORE_PKG="${REQUIRE_APP_STORE_PKG:-0}"
PLISTBUDDY="/usr/libexec/PlistBuddy"
TEMP_PATHS=()
cleanup() {
((${#TEMP_PATHS[@]})) || return 0
for path in "${TEMP_PATHS[@]}"; do
rm -rf "$path"
done
}
trap cleanup EXIT
fail() {
echo "release artifact verification failed: $*" >&2
exit 1
}
require_file() {
local path="$1"
[[ -e "$path" ]] || fail "missing $path"
}
plist_value() {
local plist="$1"
local key="$2"
"$PLISTBUDDY" -c "Print :$key" "$plist"
}
verify_app_bundle() {
require_file "$APP_DIR/Contents/Info.plist"
local version
local build
local bundle_id
version="$(plist_value "$APP_DIR/Contents/Info.plist" "CFBundleShortVersionString")"
build="$(plist_value "$APP_DIR/Contents/Info.plist" "CFBundleVersion")"
bundle_id="$(plist_value "$APP_DIR/Contents/Info.plist" "CFBundleIdentifier")"
[[ "$bundle_id" == "$BUNDLE_ID" ]] || fail "$APP_DIR bundle id is $bundle_id, expected $BUNDLE_ID"
[[ "$version" == "$APP_VERSION" ]] || fail "$APP_DIR version is $version, expected $APP_VERSION"
[[ "$build" == "$BUILD_NUMBER" ]] || fail "$APP_DIR build is $build, expected $BUILD_NUMBER"
[[ ! -e "$APP_DIR/Contents/embedded.provisionprofile" ]] \
|| fail "$APP_DIR contains an embedded provisioning profile; direct DMG app should not"
}
verify_dmg() {
require_file "$DMG_PATH"
if command -v diskutil >/dev/null 2>&1 &&
diskutil image info "$DMG_PATH" 2>/dev/null | grep -q "Image Format: UDZO"; then
return
fi
if command -v hdiutil >/dev/null 2>&1 &&
hdiutil imageinfo "$DMG_PATH" 2>/dev/null | grep -q "Format: UDZO"; then
return
fi
fail "$DMG_PATH is not a compressed read-only UDZO image"
}
verify_pkg() {
require_file "$PKG_PATH"
pkgutil --check-signature "$PKG_PATH" >/dev/null
local expanded_parent
local expanded
expanded_parent="$(mktemp -d "$DIST_DIR/pkg-verify.XXXXXX")"
TEMP_PATHS+=("$expanded_parent")
expanded="$expanded_parent/expanded.pkg"
pkgutil --expand-full "$PKG_PATH" "$expanded" >/dev/null
local plist
plist="$(find "$expanded" -path "*/I Hate PDFs.app/Contents/Info.plist" -print -quit)"
[[ -n "$plist" ]] || fail "$PKG_PATH does not contain I Hate PDFs.app"
local app_contents
app_contents="$(dirname "$plist")"
local version
local build
local bundle_id
version="$(plist_value "$plist" "CFBundleShortVersionString")"
build="$(plist_value "$plist" "CFBundleVersion")"
bundle_id="$(plist_value "$plist" "CFBundleIdentifier")"
[[ "$bundle_id" == "$BUNDLE_ID" ]] || fail "$PKG_PATH app bundle id is $bundle_id, expected $BUNDLE_ID"
[[ "$version" == "$APP_VERSION" ]] || fail "$PKG_PATH app version is $version, expected $APP_VERSION"
[[ "$build" == "$BUILD_NUMBER" ]] || fail "$PKG_PATH app build is $build, expected $BUILD_NUMBER"
[[ -e "$app_contents/embedded.provisionprofile" ]] \
|| fail "$PKG_PATH app is missing embedded.provisionprofile"
local app_bundle
local entitlements
app_bundle="$(dirname "$app_contents")"
entitlements="$expanded/entitlements.plist"
codesign -d --entitlements :- "$app_bundle" > "$entitlements" 2>/dev/null \
|| fail "$PKG_PATH app entitlements could not be read"
[[ "$(plist_value "$entitlements" "com.apple.security.app-sandbox")" == "true" ]] \
|| fail "$PKG_PATH app is missing com.apple.security.app-sandbox"
[[ "$(plist_value "$entitlements" "com.apple.security.files.user-selected.read-write")" == "true" ]] \
|| fail "$PKG_PATH app is missing com.apple.security.files.user-selected.read-write"
rm -rf "$expanded_parent"
}
verify_app_bundle
verify_dmg
if [[ "$REQUIRE_APP_STORE_PKG" == "1" || -e "$PKG_PATH" ]]; then
verify_pkg
elif [[ "$REQUIRE_APP_STORE_PKG" == "0" ]]; then
echo "Skipping App Store pkg verification because $PKG_PATH does not exist."
else
fail "invalid REQUIRE_APP_STORE_PKG=$REQUIRE_APP_STORE_PKG"
fi
echo "Verified release artifacts for I Hate PDFs $APP_VERSION ($BUILD_NUMBER)."

40
scripts/verify-release-size.sh Executable file
View File

@@ -0,0 +1,40 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
source "$ROOT_DIR/scripts/release-version.sh"
DIST_DIR="${DIST_DIR:-$ROOT_DIR/dist}"
PER_ARCH_INSTALLER_MAX_BYTES="${PER_ARCH_INSTALLER_MAX_BYTES:-400000}"
PER_ARCH_INSTALLER_EXTENSION="${PER_ARCH_INSTALLER_EXTENSION:-tar.xz}"
fail() {
echo "release size verification failed: $*" >&2
exit 1
}
file_size() {
stat -f '%z' "$1"
}
verify_under_budget() {
local path="$1"
[[ -f "$path" ]] || fail "missing $path"
local bytes
bytes="$(file_size "$path")"
if (( bytes >= PER_ARCH_INSTALLER_MAX_BYTES )); then
fail "$path is $bytes bytes; per-architecture installer budget is < $PER_ARCH_INSTALLER_MAX_BYTES bytes"
fi
echo "OK: $path is $bytes bytes (< $PER_ARCH_INSTALLER_MAX_BYTES)."
}
if (( $# > 0 )); then
for artifact in "$@"; do
verify_under_budget "$artifact"
done
else
verify_under_budget "$DIST_DIR/IHatePDFs-v$RELEASE_VERSION-macos-arm64.$PER_ARCH_INSTALLER_EXTENSION"
verify_under_budget "$DIST_DIR/IHatePDFs-v$RELEASE_VERSION-macos-x86_64.$PER_ARCH_INSTALLER_EXTENSION"
fi