Prepare v0.4 release and open source docs
This commit is contained in:
1
.github/CODEOWNERS
vendored
Normal file
1
.github/CODEOWNERS
vendored
Normal file
@@ -0,0 +1 @@
|
||||
* @akkolli
|
||||
36
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
36
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal 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.
|
||||
27
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
27
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal 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
28
.github/pull_request_template.md
vendored
Normal 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
53
.github/workflows/media-size.yml
vendored
Normal 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
6
.gitignore
vendored
@@ -1,8 +1,14 @@
|
||||
.DS_Store
|
||||
.swiftpm/
|
||||
.build/
|
||||
DerivedData/
|
||||
dist/
|
||||
release/
|
||||
plans/
|
||||
*.dmg
|
||||
*.pkg
|
||||
*.tar.gz
|
||||
*.tar.xz
|
||||
*.zip
|
||||
*.xcuserstate
|
||||
*.xcworkspace/xcuserdata/
|
||||
|
||||
66
CHANGELOG.md
66
CHANGELOG.md
@@ -1,5 +1,71 @@
|
||||
# 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 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
25
CODE_OF_CONDUCT.md
Normal 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
74
CONTRIBUTING.md
Normal 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
351
LICENSE
@@ -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
|
||||
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:
|
||||
Preamble
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
The licenses for most software are designed to take away your
|
||||
freedom to share and change it. By contrast, the GNU General Public
|
||||
License is intended to guarantee your freedom to share and change free
|
||||
software--to make sure the software is free for all its users. This
|
||||
General Public License applies to most of the Free Software
|
||||
Foundation's software and to any other program whose authors commit to
|
||||
using it. (Some other Free Software Foundation software is covered by
|
||||
the GNU Lesser General Public License instead.) You can apply it to
|
||||
your programs, too.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
this service if you wish), that you receive source code or can get it
|
||||
if you want it, that you can change the software or use pieces of it
|
||||
in new free programs; and that you know you can do these things.
|
||||
|
||||
To protect your rights, we need to make restrictions that forbid
|
||||
anyone to deny you these rights or to ask you to surrender the rights.
|
||||
These restrictions translate to certain responsibilities for you if you
|
||||
distribute copies of the software, or if you modify it.
|
||||
|
||||
For example, if you distribute copies of such a program, whether
|
||||
gratis or for a fee, you must give the recipients all the rights that
|
||||
you have. You must make sure that they, too, receive or can get the
|
||||
source code. And you must show them these terms so they know their
|
||||
rights.
|
||||
|
||||
We protect your rights with two steps: (1) copyright the software, and
|
||||
(2) offer you this license which gives you legal permission to copy,
|
||||
distribute and/or modify the software.
|
||||
|
||||
Also, for each author's protection and ours, we want to make certain
|
||||
that everyone understands that there is no warranty for this free
|
||||
software. If the software is modified by someone else and passed on, we
|
||||
want its recipients to know that what they have is not the original, so
|
||||
that any problems introduced by others will not reflect on the original
|
||||
authors' reputations.
|
||||
|
||||
Finally, any free program is threatened constantly by software
|
||||
patents. We wish to avoid the danger that redistributors of a free
|
||||
program will individually obtain patent licenses, in effect making the
|
||||
program proprietary. To prevent this, we have made it clear that any
|
||||
patent must be licensed for everyone's free use or not licensed at all.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
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.
|
||||
|
||||
@@ -19,6 +19,10 @@ let package = Package(
|
||||
.testTarget(
|
||||
name: "IHatePDFsCoreTests",
|
||||
dependencies: ["IHatePDFsCore"]
|
||||
),
|
||||
.testTarget(
|
||||
name: "IHatePDFsTests",
|
||||
dependencies: ["IHatePDFs", "IHatePDFsCore"]
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
68
README.md
68
README.md
@@ -1,5 +1,12 @@
|
||||
# I Hate PDFs
|
||||
|
||||
[](https://github.com/akkolli/ihatepdfs/releases/latest)
|
||||
[](LICENSE)
|
||||
[](#build-from-source)
|
||||
[](#development)
|
||||
[](CONTRIBUTING.md)
|
||||
[](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.
|
||||
|
||||
Minimum supported macOS version: macOS 13 Ventura.
|
||||
@@ -8,13 +15,13 @@ Supported Mac architectures: Apple Silicon and Intel.
|
||||
|
||||
## 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`.
|
||||
|
||||
@@ -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.
|
||||
- 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.
|
||||
- Start in a focused single-pane reading layout, with thumbnail and comments sidebars hidden until requested.
|
||||
- Remember thumbnail and comments sidebar visibility per PDF and coarse window size.
|
||||
- Start each opened PDF in a focused single-pane reading layout, with the document fit to the available width and sidebars hidden until requested.
|
||||
- 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.
|
||||
- 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 selected-text comments and underline comments.
|
||||
- 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.
|
||||
- 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
|
||||
|
||||
Requirements:
|
||||
@@ -75,7 +93,15 @@ Create a downloadable `.dmg`:
|
||||
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:
|
||||
|
||||
@@ -86,11 +112,11 @@ PROVISIONING_PROFILE="$HOME/Downloads/IHatePDFs_AppStore.provisionprofile" \
|
||||
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
|
||||
|
||||
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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
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:
|
||||
|
||||
@@ -110,35 +145,34 @@ swift test
|
||||
swift build -c release
|
||||
swift scripts/verify-sample-pdf.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 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 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/highlight-comment-popover.png`
|
||||
- `docs/screenshots/main-window.png`
|
||||
- `docs/screenshots/comments-sidebar.png`
|
||||
- `docs/screenshots/dark-mode-reading.png`
|
||||
- `docs/screenshots/preview-interoperability.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.
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
## License
|
||||
|
||||
MIT. See `LICENSE`.
|
||||
GNU General Public License version 2 only. See [LICENSE](LICENSE).
|
||||
|
||||
@@ -1,30 +1,33 @@
|
||||
# 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
|
||||
|
||||
- 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.
|
||||
- Drag-and-drop opening from the empty app window.
|
||||
- Standalone highlights that do not open a comment editor.
|
||||
- Return saves a comment or reply; Shift-Return inserts a new line.
|
||||
- Better default highlight contrast.
|
||||
- Mac App Store packaging for `net.akkolli.ihatepdfs`.
|
||||
|
||||
### 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.
|
||||
- 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.
|
||||
- Search highlights clear correctly when search is closed or edited.
|
||||
- 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
|
||||
|
||||
- App version: `0.3.0`
|
||||
- Build number: `4`
|
||||
- Direct-download DMG name: `IHatePDFs-v0.3-macos.dmg`
|
||||
- Mac App Store package name: `IHatePDFs-v0.3-macos-appstore.pkg`
|
||||
- App version: `0.4.0`
|
||||
- Build number: `6`
|
||||
- Direct-download DMG name: `IHatePDFs-v0.4-macos.dmg`
|
||||
- Mac App Store package name: `IHatePDFs-v0.4-macos-appstore.pkg`
|
||||
|
||||
11
ROADMAP.md
11
ROADMAP.md
@@ -7,7 +7,7 @@
|
||||
- Reading controls: scrolling, zoom, fit width, fit page, page navigation, search.
|
||||
- Focused default reading mode with optional page thumbnail sidebar.
|
||||
- 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.
|
||||
- Optional comments review sidebar with grouping, collapsed filtering, replies, and navigation.
|
||||
- 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.
|
||||
- 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
|
||||
|
||||
- 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`.
|
||||
- Stronger interoperability test corpus covering Preview, Acrobat Reader, and browser PDF viewers.
|
||||
- Import/export verification fixtures for existing annotated PDFs.
|
||||
- Revisit form fill and signing later as a smaller, cleaner design.
|
||||
|
||||
## Later
|
||||
|
||||
|
||||
25
SECURITY.md
Normal file
25
SECURITY.md
Normal 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
19
SUPPORT.md
Normal 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`.
|
||||
@@ -11,6 +11,19 @@ enum AppSettings {
|
||||
private static let minimumHighlightAlpha: CGFloat = 0.38
|
||||
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 {
|
||||
get {
|
||||
highlightColor(from: UserDefaults.standard.string(forKey: highlightColorStorageKey))
|
||||
@@ -61,6 +74,10 @@ enum AppSettings {
|
||||
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 {
|
||||
storageString(for: commentColor(from: storageString(for: color)))
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -45,7 +45,6 @@ final class CommentPopoverModel: ObservableObject {
|
||||
struct CommentEditorView: View {
|
||||
@ObservedObject var model: CommentPopoverModel
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@FocusState private var isCommentFocused: Bool
|
||||
private let editorHorizontalInset: CGFloat = 9
|
||||
private let editorVerticalInset: CGFloat = 7
|
||||
|
||||
@@ -58,20 +57,12 @@ struct CommentEditorView: View {
|
||||
.padding(12)
|
||||
.frame(width: 340)
|
||||
.background(.regularMaterial)
|
||||
.onAppear {
|
||||
DispatchQueue.main.async {
|
||||
isCommentFocused = true
|
||||
}
|
||||
}
|
||||
.onChange(of: model.text) { _ in
|
||||
model.updateDraft()
|
||||
}
|
||||
.onChange(of: model.author) { _ in
|
||||
model.updateDraft()
|
||||
}
|
||||
.commitOnPlainReturn {
|
||||
model.commit()
|
||||
}
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
@@ -83,32 +74,23 @@ struct CommentEditorView: View {
|
||||
Text(title)
|
||||
.font(.headline)
|
||||
.lineLimit(1)
|
||||
|
||||
Spacer()
|
||||
|
||||
Button {
|
||||
model.commit()
|
||||
} label: {
|
||||
Label("Done", systemImage: "checkmark")
|
||||
}
|
||||
.labelStyle(.iconOnly)
|
||||
.keyboardShortcut(.return, modifiers: [.command])
|
||||
.help("Done")
|
||||
}
|
||||
}
|
||||
|
||||
private var commentField: some View {
|
||||
ZStack(alignment: .topLeading) {
|
||||
TextEditor(text: $model.text)
|
||||
.font(.body)
|
||||
.foregroundStyle(InterfacePalette.primaryText(for: colorScheme))
|
||||
.scrollContentBackground(.hidden)
|
||||
.focused($isCommentFocused)
|
||||
CommitTextView(
|
||||
text: $model.text,
|
||||
font: NSFont.preferredFont(forTextStyle: .body),
|
||||
onCommit: {
|
||||
model.commit()
|
||||
}
|
||||
)
|
||||
.padding(.horizontal, editorHorizontalInset)
|
||||
.padding(.vertical, editorVerticalInset)
|
||||
|
||||
if model.text.isEmpty {
|
||||
Text("Add comment")
|
||||
Text(placeholderText)
|
||||
.font(.body)
|
||||
.foregroundStyle(InterfacePalette.quietText(for: colorScheme))
|
||||
.padding(.leading, editorHorizontalInset + 7)
|
||||
@@ -130,6 +112,9 @@ struct CommentEditorView: View {
|
||||
TextField("Author", text: $model.author)
|
||||
.textFieldStyle(.plain)
|
||||
.foregroundStyle(InterfacePalette.primaryText(for: colorScheme))
|
||||
.onSubmit {
|
||||
model.commit()
|
||||
}
|
||||
.padding(.horizontal, 7)
|
||||
.frame(height: 28)
|
||||
.background(InterfacePalette.fieldFill(for: colorScheme))
|
||||
@@ -143,7 +128,8 @@ struct CommentEditorView: View {
|
||||
|
||||
Spacer()
|
||||
|
||||
if !model.context.isNewAnnotation,
|
||||
if model.context.allowsReply,
|
||||
!model.context.isNewAnnotation,
|
||||
model.context.primaryAnnotation != nil {
|
||||
Button {
|
||||
model.reply()
|
||||
@@ -157,14 +143,26 @@ struct CommentEditorView: View {
|
||||
}
|
||||
|
||||
if model.context.allowsDelete {
|
||||
Button(role: .destructive) {
|
||||
model.delete()
|
||||
} label: {
|
||||
Label("Delete Annotation", systemImage: "trash")
|
||||
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) {
|
||||
model.delete()
|
||||
} label: {
|
||||
Label("Delete Annotation", systemImage: "trash")
|
||||
}
|
||||
.labelStyle(.iconOnly)
|
||||
.frame(width: 34)
|
||||
.help("Delete Annotation")
|
||||
}
|
||||
.labelStyle(.iconOnly)
|
||||
.frame(width: 34)
|
||||
.help("Delete Annotation")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -173,6 +171,10 @@ struct CommentEditorView: View {
|
||||
model.context.title.replacingOccurrences(of: " Comment", with: "")
|
||||
}
|
||||
|
||||
private var placeholderText: String {
|
||||
model.context.allowsReply ? "Add comment" : "Edit text"
|
||||
}
|
||||
|
||||
private var symbolName: String {
|
||||
guard let annotation = model.context.primaryAnnotation else {
|
||||
return "text.bubble"
|
||||
|
||||
106
Sources/IHatePDFs/CommitTextView.swift
Normal file
106
Sources/IHatePDFs/CommitTextView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import AppKit
|
||||
import IHatePDFsCore
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
@@ -22,6 +23,10 @@ struct IHatePDFsApp: App {
|
||||
|
||||
@MainActor
|
||||
private final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
|
||||
true
|
||||
}
|
||||
|
||||
func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply {
|
||||
AppStateRegistry.shared.confirmApplicationShouldTerminate()
|
||||
? .terminateNow
|
||||
@@ -68,6 +73,21 @@ private final class AppStateRegistry {
|
||||
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() {
|
||||
appStates.removeAll { $0.value == nil }
|
||||
}
|
||||
@@ -91,6 +111,9 @@ private struct AppWindowRoot: View {
|
||||
.background(WindowCloseGuard(appState: appState))
|
||||
.onOpenURL { url in
|
||||
appState.loadDocument(from: url)
|
||||
if appState.documentURL == url {
|
||||
AppStateRegistry.shared.closeOtherEmptyWindows(keeping: appState)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
AppStateRegistry.shared.register(appState)
|
||||
@@ -144,6 +167,7 @@ private struct WindowCloseGuard: NSViewRepresentable {
|
||||
|
||||
self.window = window
|
||||
previousDelegate = window?.delegate
|
||||
appState?.hostingWindow = window
|
||||
|
||||
if window?.delegate !== self {
|
||||
window?.delegate = self
|
||||
@@ -185,6 +209,7 @@ private struct WindowCloseGuard: NSViewRepresentable {
|
||||
if window?.delegate === self {
|
||||
window?.delegate = previousDelegate
|
||||
}
|
||||
appState?.hostingWindow = nil
|
||||
window = nil
|
||||
previousDelegate = nil
|
||||
}
|
||||
@@ -226,10 +251,18 @@ private struct AppCommands: Commands {
|
||||
appState?.hasTextSelection == true
|
||||
}
|
||||
|
||||
private var isHighlighterModeActive: Bool {
|
||||
appState?.isHighlighterModeActive == true
|
||||
}
|
||||
|
||||
private var canSaveDocument: Bool {
|
||||
appState?.canSaveDocument == true
|
||||
}
|
||||
|
||||
private var recentDocumentURLs: [URL] {
|
||||
appState?.recentDocumentURLs ?? []
|
||||
}
|
||||
|
||||
private var saveHelpText: String {
|
||||
appState?.saveHelpText ?? "Open a PDF before saving."
|
||||
}
|
||||
@@ -242,6 +275,27 @@ private struct AppCommands: Commands {
|
||||
.keyboardShortcut("o")
|
||||
.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") {
|
||||
appState?.saveDocument()
|
||||
}
|
||||
@@ -266,14 +320,19 @@ private struct AppCommands: Commands {
|
||||
Button("Settings...") {
|
||||
openSettingsWindow()
|
||||
}
|
||||
.keyboardShortcut(",")
|
||||
|
||||
Divider()
|
||||
|
||||
Button("Close PDF") {
|
||||
appState?.closeDocument()
|
||||
Button(hasDocument ? "Close PDF" : "Close Window") {
|
||||
if hasDocument {
|
||||
appState?.closeDocument()
|
||||
} else {
|
||||
NSApp.keyWindow?.performClose(nil)
|
||||
}
|
||||
}
|
||||
.keyboardShortcut("w")
|
||||
.disabled(!hasDocument)
|
||||
.disabled(appState == nil)
|
||||
}
|
||||
|
||||
CommandGroup(after: .textEditing) {
|
||||
@@ -296,15 +355,21 @@ private struct AppCommands: Commands {
|
||||
.disabled(appState?.searchResults.isEmpty != false)
|
||||
}
|
||||
|
||||
CommandMenu("View") {
|
||||
CommandGroup(after: .toolbar) {
|
||||
Button("Toggle Page Sidebar") {
|
||||
appState?.showLeftSidebar.toggle()
|
||||
appState?.togglePageSidebar()
|
||||
}
|
||||
.keyboardShortcut("0", modifiers: [.command, .option])
|
||||
.disabled(!hasDocument)
|
||||
|
||||
Button("Toggle Comments Sidebar") {
|
||||
appState?.showCommentsSidebar.toggle()
|
||||
Button("Toggle Annotation List") {
|
||||
appState?.toggleAnnotationSidebar()
|
||||
}
|
||||
.keyboardShortcut("2", modifiers: [.command, .option])
|
||||
.disabled(!hasDocument)
|
||||
|
||||
Button("Toggle Right Sidebar") {
|
||||
appState?.toggleRightSidebarVisibility()
|
||||
}
|
||||
.keyboardShortcut("1", modifiers: [.command, .option])
|
||||
.disabled(!hasDocument)
|
||||
@@ -343,11 +408,11 @@ private struct AppCommands: Commands {
|
||||
}
|
||||
|
||||
CommandMenu("Annotate") {
|
||||
Button("Highlight Selection") {
|
||||
appState?.addHighlight()
|
||||
Button(isHighlighterModeActive ? "Turn Highlighter Off" : "Turn Highlighter On") {
|
||||
appState?.toggleHighlighterMode()
|
||||
}
|
||||
.keyboardShortcut("h", modifiers: [.command, .shift])
|
||||
.disabled(!hasDocument || !hasTextSelection)
|
||||
.disabled(!hasDocument)
|
||||
|
||||
Button("Underline Selection") {
|
||||
appState?.addUnderline()
|
||||
@@ -368,6 +433,20 @@ private struct AppCommands: Commands {
|
||||
.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) {
|
||||
Button("Minimize") {
|
||||
appState?.minimizeWindow()
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -8,6 +8,10 @@ final class AcademicPDFView: PDFView {
|
||||
var onPlacementClick: ((PDFPage, CGPoint) -> Void)?
|
||||
var onCancelPlacement: (() -> Void)?
|
||||
var onSelectionComment: (() -> Void)?
|
||||
var onHighlighterSelection: (() -> Void)?
|
||||
var onToggleHighlighterKey: (() -> Void)?
|
||||
var onUnderlineSelectionKey: (() -> Void)?
|
||||
var onCommentSelectionKey: (() -> Void)?
|
||||
var onPreviousPageKey: (() -> Void)?
|
||||
var onNextPageKey: (() -> Void)?
|
||||
var placementTool: AnnotationPlacementTool? {
|
||||
@@ -16,10 +20,25 @@ final class AcademicPDFView: PDFView {
|
||||
window?.invalidateCursorRects(for: self)
|
||||
}
|
||||
}
|
||||
var isHighlighterModeActive = false {
|
||||
didSet {
|
||||
guard oldValue != isHighlighterModeActive else { return }
|
||||
window?.invalidateCursorRects(for: self)
|
||||
if mouseIsInside {
|
||||
applyToolCursorIfNeeded()
|
||||
}
|
||||
}
|
||||
}
|
||||
private var handledAnnotationMouseDown = false
|
||||
|
||||
override var acceptsFirstResponder: Bool { true }
|
||||
|
||||
override func viewDidChangeEffectiveAppearance() {
|
||||
super.viewDidChangeEffectiveAppearance()
|
||||
backgroundColor = NSColor.underPageBackgroundColor
|
||||
needsDisplay = true
|
||||
}
|
||||
|
||||
override func mouseDown(with event: NSEvent) {
|
||||
handledAnnotationMouseDown = false
|
||||
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) {
|
||||
closeNativePopups(on: page)
|
||||
let pagePoint = convert(point, to: page)
|
||||
|
||||
if placementTool != nil {
|
||||
onPlacementClick?(page, pagePoint)
|
||||
return
|
||||
@@ -65,6 +83,14 @@ final class AcademicPDFView: PDFView {
|
||||
|
||||
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 }
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self else { return }
|
||||
@@ -97,6 +123,24 @@ final class AcademicPDFView: PDFView {
|
||||
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]
|
||||
guard event.modifierFlags.intersection(pageNavigationModifiers).isEmpty else {
|
||||
super.keyDown(with: event)
|
||||
@@ -116,15 +160,182 @@ final class AcademicPDFView: PDFView {
|
||||
override func resetCursorRects() {
|
||||
super.resetCursorRects()
|
||||
|
||||
if placementTool != nil {
|
||||
if isHighlighterModeActive {
|
||||
addCursorRect(bounds, cursor: Self.highlighterCursor)
|
||||
} else if placementTool != nil {
|
||||
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? {
|
||||
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 {
|
||||
guard let selection = currentSelection,
|
||||
!selection.pages.isEmpty,
|
||||
@@ -305,6 +516,26 @@ struct PDFKitRepresentedView: NSViewRepresentable {
|
||||
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 = {
|
||||
Task { @MainActor in
|
||||
appState.goToPreviousPage()
|
||||
@@ -324,16 +555,27 @@ struct PDFKitRepresentedView: NSViewRepresentable {
|
||||
view.document = appState.document
|
||||
}
|
||||
view.placementTool = appState.placementTool
|
||||
view.isHighlighterModeActive = appState.isHighlighterModeActive
|
||||
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
|
||||
final class Coordinator: NSObject, NSPopoverDelegate {
|
||||
private enum PopoverKind {
|
||||
case comment
|
||||
}
|
||||
|
||||
private var popover: NSPopover?
|
||||
private var model: CommentPopoverModel?
|
||||
private var editorID: UUID?
|
||||
private var popoverKind: PopoverKind?
|
||||
private var isClosing = false
|
||||
private var commitsCommentOnClose = true
|
||||
private weak var appState: AppState?
|
||||
|
||||
func sync(
|
||||
@@ -343,19 +585,21 @@ struct PDFKitRepresentedView: NSViewRepresentable {
|
||||
) {
|
||||
self.appState = appState
|
||||
|
||||
guard let context else {
|
||||
if !isClosing {
|
||||
dismissCurrent(commit: false)
|
||||
if let context {
|
||||
if popoverKind == .comment,
|
||||
editorID == context.id,
|
||||
popover?.isShown == true {
|
||||
return
|
||||
}
|
||||
|
||||
dismissCurrent(commit: true)
|
||||
show(context, in: view, appState: appState)
|
||||
return
|
||||
}
|
||||
|
||||
if editorID == context.id, popover?.isShown == true {
|
||||
return
|
||||
if !isClosing {
|
||||
dismissCurrent(commit: false)
|
||||
}
|
||||
|
||||
dismissCurrent(commit: true)
|
||||
show(context, in: view, appState: appState)
|
||||
}
|
||||
|
||||
private func show(
|
||||
@@ -377,7 +621,9 @@ struct PDFKitRepresentedView: NSViewRepresentable {
|
||||
self.model = model
|
||||
self.popover = popover
|
||||
self.editorID = context.id
|
||||
self.popoverKind = .comment
|
||||
self.isClosing = false
|
||||
self.commitsCommentOnClose = true
|
||||
|
||||
let anchor = anchorRect(for: context, in: view)
|
||||
popover.show(
|
||||
@@ -435,6 +681,7 @@ struct PDFKitRepresentedView: NSViewRepresentable {
|
||||
if commit {
|
||||
model?.commit()
|
||||
}
|
||||
commitsCommentOnClose = commit
|
||||
|
||||
if popover.isShown {
|
||||
popover.performClose(nil)
|
||||
@@ -445,15 +692,19 @@ struct PDFKitRepresentedView: NSViewRepresentable {
|
||||
|
||||
func popoverWillClose(_ notification: Notification) {
|
||||
isClosing = true
|
||||
model?.commit()
|
||||
if popoverKind == .comment, commitsCommentOnClose {
|
||||
model?.commit()
|
||||
}
|
||||
}
|
||||
|
||||
func popoverDidClose(_ notification: Notification) {
|
||||
let closedEditorID = editorID
|
||||
let closedPopoverKind = popoverKind
|
||||
let currentAppState = appState
|
||||
cleanup()
|
||||
|
||||
if currentAppState?.activeEditor?.id == closedEditorID {
|
||||
if closedPopoverKind == .comment,
|
||||
currentAppState?.activeEditor?.id == closedEditorID {
|
||||
currentAppState?.activeEditor = nil
|
||||
}
|
||||
}
|
||||
@@ -463,7 +714,9 @@ struct PDFKitRepresentedView: NSViewRepresentable {
|
||||
popover = nil
|
||||
model = nil
|
||||
editorID = nil
|
||||
popoverKind = nil
|
||||
isClosing = false
|
||||
commitsCommentOnClose = true
|
||||
}
|
||||
|
||||
private func anchorRect(for context: AnnotationEditorContext, in view: AcademicPDFView) -> NSRect {
|
||||
|
||||
180
Sources/IHatePDFs/ReaderAdaptiveLayout.swift
Normal file
180
Sources/IHatePDFs/ReaderAdaptiveLayout.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -103,17 +103,21 @@ public enum AnnotationFactory {
|
||||
date: Date = Date()
|
||||
) -> [AnnotationInsertion] {
|
||||
let lineSelections = selection.selectionsByLine()
|
||||
var groups: [(page: PDFPage, rects: [CGRect])] = []
|
||||
var groups: [(page: PDFPage, rects: [CGRect], text: [String])] = []
|
||||
|
||||
for lineSelection in lineSelections {
|
||||
let lineText = lineSelection.string?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
for page in lineSelection.pages {
|
||||
let rect = lineSelection.bounds(for: page).insetBy(dx: -1.5, dy: -1.0)
|
||||
guard !rect.isNull, rect.width > 0, rect.height > 0 else { continue }
|
||||
|
||||
if let index = groups.firstIndex(where: { $0.page === page }) {
|
||||
groups[index].rects.append(rect)
|
||||
if !lineText.isEmpty {
|
||||
groups[index].text.append(lineText)
|
||||
}
|
||||
} 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)
|
||||
}
|
||||
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 {
|
||||
_ = annotation.setValue(AnnotationKeys.appKindComment, forAnnotationKey: AnnotationKeys.appKind)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import AppKit
|
||||
import Foundation
|
||||
import PDFKit
|
||||
|
||||
public enum AcademicAnnotationKind: String, CaseIterable, Identifiable {
|
||||
public enum AcademicAnnotationKind: String, CaseIterable {
|
||||
case comment
|
||||
case highlight
|
||||
case underline
|
||||
@@ -11,8 +11,6 @@ public enum AcademicAnnotationKind: String, CaseIterable, Identifiable {
|
||||
case reply
|
||||
case other
|
||||
|
||||
public var id: String { rawValue }
|
||||
|
||||
public init(annotation: PDFAnnotation) {
|
||||
if annotation.value(forAnnotationKey: AnnotationKeys.appKind) as? String == AnnotationKeys.appKindComment {
|
||||
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 pageIndex: Int
|
||||
public let pageLabel: String
|
||||
@@ -73,6 +71,7 @@ public struct AnnotationSnapshot: Identifiable, Equatable {
|
||||
public let modifiedAt: Date?
|
||||
public let status: String
|
||||
public let contents: String
|
||||
public let highlightText: String
|
||||
public let bounds: CGRect
|
||||
public let annotation: PDFAnnotation
|
||||
public let page: PDFPage
|
||||
@@ -89,6 +88,7 @@ public struct AnnotationSnapshot: Identifiable, Equatable {
|
||||
modifiedAt: Date?,
|
||||
status: String,
|
||||
contents: String,
|
||||
highlightText: String,
|
||||
bounds: CGRect,
|
||||
annotation: PDFAnnotation,
|
||||
page: PDFPage,
|
||||
@@ -104,6 +104,7 @@ public struct AnnotationSnapshot: Identifiable, Equatable {
|
||||
self.modifiedAt = modifiedAt
|
||||
self.status = status
|
||||
self.contents = contents
|
||||
self.highlightText = highlightText
|
||||
self.bounds = bounds
|
||||
self.annotation = annotation
|
||||
self.page = page
|
||||
@@ -126,23 +127,22 @@ public struct AnnotationSnapshot: Identifiable, Equatable {
|
||||
!contents.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
}
|
||||
|
||||
public var isReply: Bool {
|
||||
parentID != nil
|
||||
public var highlightExcerpt: String {
|
||||
let stored = highlightText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !stored.isEmpty {
|
||||
return stored
|
||||
}
|
||||
|
||||
let fallback = contents.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !fallback.isEmpty {
|
||||
return fallback
|
||||
}
|
||||
|
||||
return "Highlight on page \(pageLabel)"
|
||||
}
|
||||
|
||||
public static func == (lhs: AnnotationSnapshot, rhs: AnnotationSnapshot) -> Bool {
|
||||
lhs.id == rhs.id
|
||||
&& lhs.pageIndex == rhs.pageIndex
|
||||
&& lhs.pageLabel == rhs.pageLabel
|
||||
&& lhs.annotationIndex == rhs.annotationIndex
|
||||
&& lhs.kind == rhs.kind
|
||||
&& lhs.author == rhs.author
|
||||
&& lhs.createdAt == rhs.createdAt
|
||||
&& lhs.modifiedAt == rhs.modifiedAt
|
||||
&& lhs.status == rhs.status
|
||||
&& lhs.contents == rhs.contents
|
||||
&& lhs.bounds == rhs.bounds
|
||||
&& lhs.parentID == rhs.parentID
|
||||
public var isReply: Bool {
|
||||
parentID != nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,6 +155,7 @@ public enum AnnotationKeys {
|
||||
public static let appKind = PDFAnnotationKey(rawValue: "IHatePDFsKind")
|
||||
public static let appKindComment = "Comment"
|
||||
public static let appCommentText = PDFAnnotationKey(rawValue: "IHatePDFsCommentText")
|
||||
public static let appHighlightText = PDFAnnotationKey(rawValue: "IHatePDFsHighlightText")
|
||||
|
||||
public static func commentText(for annotation: PDFAnnotation) -> String {
|
||||
if let value = annotation.value(forAnnotationKey: appCommentText) as? String,
|
||||
@@ -351,6 +352,7 @@ public enum AnnotationReader {
|
||||
|
||||
let kind = AcademicAnnotationKind(annotation: annotation)
|
||||
let contents = AnnotationKeys.commentText(for: annotation)
|
||||
let highlightText = annotation.value(forAnnotationKey: AnnotationKeys.appHighlightText) as? String ?? ""
|
||||
guard kind != .other || !contents.isEmpty else { continue }
|
||||
|
||||
let id = AnnotationKeys.stableID(
|
||||
@@ -384,6 +386,7 @@ public enum AnnotationReader {
|
||||
modifiedAt: annotation.modificationDate,
|
||||
status: status,
|
||||
contents: contents,
|
||||
highlightText: highlightText,
|
||||
bounds: annotation.bounds,
|
||||
annotation: annotation,
|
||||
page: page,
|
||||
|
||||
61
Sources/IHatePDFsCore/PDFDocumentBookmarks.swift
Normal file
61
Sources/IHatePDFsCore/PDFDocumentBookmarks.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
82
Sources/IHatePDFsCore/PDFRecentDocuments.swift
Normal file
82
Sources/IHatePDFsCore/PDFRecentDocuments.swift
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -7,10 +7,14 @@ public enum ReturnKeyCommitPolicy {
|
||||
option: Bool,
|
||||
command: Bool,
|
||||
control: Bool,
|
||||
isEditableMultilineText: Bool
|
||||
isEditableMultilineText: Bool,
|
||||
commandReturnOnly: Bool = false
|
||||
) -> Bool {
|
||||
guard isEditableMultilineText else { return false }
|
||||
guard keyCode == 36 || keyCode == 76 else { return false }
|
||||
if commandReturnOnly {
|
||||
return command && !shift && !option && !control
|
||||
}
|
||||
return !shift && !option && !command && !control
|
||||
}
|
||||
}
|
||||
|
||||
52
Tests/IHatePDFsCoreTests/PDFDocumentBookmarksTests.swift
Normal file
52
Tests/IHatePDFsCoreTests/PDFDocumentBookmarksTests.swift
Normal 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"])
|
||||
}
|
||||
}
|
||||
58
Tests/IHatePDFsCoreTests/PDFRecentDocumentsTests.swift
Normal file
58
Tests/IHatePDFsCoreTests/PDFRecentDocumentsTests.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
46
Tests/IHatePDFsCoreTests/PerformanceBudgetTests.swift
Normal file
46
Tests/IHatePDFsCoreTests/PerformanceBudgetTests.swift
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
XCTAssertTrue(ReturnKeyCommitPolicy.shouldCommit(
|
||||
keyCode: 76,
|
||||
|
||||
290
Tests/IHatePDFsTests/AppStateWorkflowTests.swift
Normal file
290
Tests/IHatePDFsTests/AppStateWorkflowTests.swift
Normal 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.")
|
||||
}
|
||||
}
|
||||
122
Tests/IHatePDFsTests/ReaderAdaptiveLayoutTests.swift
Normal file
122
Tests/IHatePDFsTests/ReaderAdaptiveLayoutTests.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -4,9 +4,13 @@ Bundle ID: `net.akkolli.ihatepdfs`
|
||||
|
||||
Current App Store build values:
|
||||
|
||||
- `CFBundleShortVersionString`: `0.3.0`
|
||||
- `CFBundleVersion`: `4`
|
||||
- `CFBundleShortVersionString`: `0.4.0`
|
||||
- `CFBundleVersion`: `6`
|
||||
- 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
|
||||
|
||||
@@ -33,12 +37,24 @@ PROVISIONING_PROFILE="$HOME/Downloads/IHatePDFs_AppStore.provisionprofile" \
|
||||
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 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
97
docs/APP_STORE_COPY.md
Normal 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.
|
||||
@@ -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`.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
@@ -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.
|
||||
- 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 VoiceOver labels for toolbar buttons and sidebar controls.
|
||||
|
||||
@@ -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.
|
||||
|
||||
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:
|
||||
|
||||
```sh
|
||||
|
||||
35
docs/FUNCTIONALITY_AUDIT.md
Normal file
35
docs/FUNCTIONALITY_AUDIT.md
Normal 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`.
|
||||
128
docs/QA.md
128
docs/QA.md
@@ -2,6 +2,25 @@
|
||||
|
||||
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
|
||||
|
||||
Use at least:
|
||||
@@ -14,51 +33,36 @@ Use at least:
|
||||
|
||||
## App Workflow
|
||||
|
||||
1. Open the PDF in I Hate PDFs.
|
||||
2. Close the PDF, then drag a `.pdf` file onto the empty no-document 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.
|
||||
4. Select text and add a highlight; verify no comment popover opens.
|
||||
5. Select text and add a comment; verify the comment color matches the Settings value.
|
||||
6. In the comment box, press Shift-Return and verify it inserts a new line, then press Return and verify the comment is saved.
|
||||
7. Add an underline with a comment.
|
||||
8. Select text, right-click, and add a comment from the context menu.
|
||||
9. Add free text directly on the page.
|
||||
10. Open the comments sidebar and verify count, grouping, search, filters, edit, delete, reply, and click-to-navigate.
|
||||
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.
|
||||
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.
|
||||
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.
|
||||
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.
|
||||
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.
|
||||
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.
|
||||
17. Save As an annotated copy.
|
||||
18. Reopen the annotated copy in I Hate PDFs and verify the annotations and comments remain.
|
||||
19. Save over a disposable original and verify the overwrite warning appears.
|
||||
20. Add an annotation and verify the window shows the native macOS unsaved/edited document indicator until the PDF is saved.
|
||||
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.
|
||||
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.
|
||||
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.
|
||||
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.
|
||||
25. Start typing a sidebar reply, click Reply on a different comment, and verify the original draft remains until you send or cancel it.
|
||||
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.
|
||||
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.
|
||||
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.
|
||||
29. In Settings, choose very low-opacity highlight and comment colors, add each annotation type, and verify saved annotations remain visibly readable.
|
||||
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.
|
||||
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.
|
||||
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 window and verify it opens.
|
||||
3. Open one or more PDFs, close the current PDF, and verify recent PDFs appear in the empty window and File > Open Recent.
|
||||
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 highlight; verify no comment popover opens.
|
||||
6. Select text and add a comment; verify the comment color matches the Settings value.
|
||||
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. Add an underline with a comment.
|
||||
9. Select text, right-click, and add a comment from the context menu.
|
||||
10. Add free text directly on the page.
|
||||
11. Open the comments sidebar and verify count, grouping, search, filters, edit, delete, reply, and click-to-navigate.
|
||||
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. 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. 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. 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. Add at least one reply and verify the comments sidebar presents the thread clearly.
|
||||
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. Click commented text and underlined text and verify the comment popover opens; click nearby whitespace and verify no popover opens.
|
||||
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. Save As an annotated copy.
|
||||
21. Reopen the annotated copy in I Hate PDFs and verify annotations and comments remain.
|
||||
22. Save over a disposable original and verify the overwrite warning appears.
|
||||
23. Add an annotation and verify the window shows the native macOS unsaved/edited document indicator until the PDF is saved.
|
||||
24. Search for a word, close the search toolbar, and verify match highlights disappear.
|
||||
25. Type invalid and out-of-range page numbers and verify the app restores the current page number with a clear status message.
|
||||
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. Start typing a sidebar reply, click Reply on a different comment, and verify the original draft remains until sent or canceled.
|
||||
28. Hide a sidebar or apply a filter that removes the selected row and verify the PDF selection highlight clears.
|
||||
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. 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.
|
||||
|
||||
## External Readers
|
||||
|
||||
@@ -69,36 +73,6 @@ swift scripts/verify-sample-pdf.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.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
68
docs/RELEASE.md
Normal file
68
docs/RELEASE.md
Normal 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
53
docs/WORKFLOW_AUDIT.md
Normal 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.
|
||||
@@ -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 |
@@ -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 |
BIN
docs/screenshots/preview-visible-signature.png
Normal file
BIN
docs/screenshots/preview-visible-signature.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 522 KiB |
10
goal.md
10
goal.md
@@ -40,9 +40,7 @@ Minimum supported version should be explicitly chosen before development. Recomm
|
||||
|
||||
## License
|
||||
|
||||
The project should use a permissive open-source license unless there is a specific reason not to. Recommended:
|
||||
|
||||
- MIT License or Apache 2.0
|
||||
The project is licensed under the GNU General Public License version 2.
|
||||
|
||||
## 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 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.
|
||||
- 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 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.
|
||||
@@ -254,7 +252,7 @@ The UI must be fixed in this order:
|
||||
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.
|
||||
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.
|
||||
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.
|
||||
@@ -434,7 +432,7 @@ The following are explicitly not required:
|
||||
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.
|
||||
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.
|
||||
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.
|
||||
|
||||
BIN
ihatepdf-profile-transparent.png
Normal file
BIN
ihatepdf-profile-transparent.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.1 KiB |
BIN
ihatepdf.png
BIN
ihatepdf.png
Binary file not shown.
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 8.7 KiB |
@@ -7,7 +7,9 @@ ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
source "$ROOT_DIR/scripts/release-version.sh"
|
||||
CONFIGURATION="${CONFIGURATION:-release}"
|
||||
BUNDLE_ID="${BUNDLE_ID:-net.akkolli.ihatepdfs}"
|
||||
SIZE_OPTIMIZED="${SIZE_OPTIMIZED:-0}"
|
||||
STRIP_RELEASE="${STRIP_RELEASE:-1}"
|
||||
ICON_MAX_SIZE="${ICON_MAX_SIZE:-1024}"
|
||||
SIGNING_IDENTITY="${SIGNING_IDENTITY:-}"
|
||||
ENTITLEMENTS_PATH="${ENTITLEMENTS_PATH:-}"
|
||||
PROVISIONING_PROFILE="${PROVISIONING_PROFILE:-}"
|
||||
@@ -20,19 +22,33 @@ else
|
||||
ARCHS="${ARCHS:-}"
|
||||
fi
|
||||
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"
|
||||
MACOS_DIR="$CONTENTS_DIR/MacOS"
|
||||
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"
|
||||
DERIVED_ENTITLEMENTS_PATH=""
|
||||
PROFILE_PLIST_PATH=""
|
||||
NORMALIZED_ICON_SOURCE=""
|
||||
|
||||
cleanup() {
|
||||
if [[ -n "$DERIVED_ENTITLEMENTS_PATH" ]]; then
|
||||
rm -f "$DERIVED_ENTITLEMENTS_PATH"
|
||||
fi
|
||||
if [[ -n "$NORMALIZED_ICON_SOURCE" ]]; then
|
||||
rm -f "$NORMALIZED_ICON_SOURCE"
|
||||
fi
|
||||
if [[ -n "$PROFILE_PLIST_PATH" ]]; then
|
||||
rm -f "$PROFILE_PLIST_PATH"
|
||||
fi
|
||||
@@ -55,6 +71,13 @@ SWIFT_BUILD_ARGS=(-c "$CONFIGURATION")
|
||||
for ARCH in $ARCHS; do
|
||||
SWIFT_BUILD_ARGS+=(--arch "$ARCH")
|
||||
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)"
|
||||
swift build "${SWIFT_BUILD_ARGS[@]}"
|
||||
@@ -64,9 +87,22 @@ mkdir -p "$MACOS_DIR" "$RESOURCES_DIR"
|
||||
cp "$BUILD_DIR/$EXECUTABLE_NAME" "$MACOS_DIR/$EXECUTABLE_NAME"
|
||||
|
||||
if [[ "$CONFIGURATION" == "release" && "$STRIP_RELEASE" != "0" ]]; then
|
||||
strip -x "$MACOS_DIR/$EXECUTABLE_NAME"
|
||||
if [[ "$SIZE_OPTIMIZED" == "1" ]]; then
|
||||
strip -u -r "$MACOS_DIR/$EXECUTABLE_NAME"
|
||||
else
|
||||
strip -x "$MACOS_DIR/$EXECUTABLE_NAME"
|
||||
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 [[ ! -f "$PROVISIONING_PROFILE" ]]; then
|
||||
echo "Missing provisioning profile: $PROVISIONING_PROFILE" >&2
|
||||
@@ -88,19 +124,31 @@ mkdir -p "$ICONSET_DIR"
|
||||
make_icon() {
|
||||
local pixels="$1"
|
||||
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 32 "icon_16x16@2x.png"
|
||||
make_icon 32 "icon_32x32.png"
|
||||
make_icon 64 "icon_32x32@2x.png"
|
||||
make_icon 128 "icon_128x128.png"
|
||||
make_icon 256 "icon_128x128@2x.png"
|
||||
make_icon 256 "icon_256x256.png"
|
||||
make_icon 512 "icon_256x256@2x.png"
|
||||
make_icon 512 "icon_512x512.png"
|
||||
make_icon 1024 "icon_512x512@2x.png"
|
||||
if (( ICON_MAX_SIZE >= 128 )); then
|
||||
make_icon 128 "icon_128x128.png"
|
||||
fi
|
||||
if (( ICON_MAX_SIZE >= 128 )); then
|
||||
make_icon 256 "icon_128x128@2x.png"
|
||||
fi
|
||||
if (( ICON_MAX_SIZE >= 256 )); then
|
||||
make_icon 256 "icon_256x256.png"
|
||||
fi
|
||||
if (( ICON_MAX_SIZE >= 512 )); then
|
||||
make_icon 512 "icon_256x256@2x.png"
|
||||
make_icon 512 "icon_512x512.png"
|
||||
fi
|
||||
if (( ICON_MAX_SIZE >= 1024 )); then
|
||||
make_icon 1024 "icon_512x512@2x.png"
|
||||
fi
|
||||
|
||||
iconutil -c icns "$ICONSET_DIR" -o "$RESOURCES_DIR/$ICON_NAME.icns"
|
||||
rm -rf "$ICONSET_DIR"
|
||||
@@ -156,7 +204,7 @@ cat > "$CONTENTS_DIR/Info.plist" <<PLIST
|
||||
<key>LSSupportsOpeningDocumentsInPlace</key>
|
||||
<true/>
|
||||
<key>NSHumanReadableCopyright</key>
|
||||
<string>MIT License</string>
|
||||
<string>GNU General Public License version 2</string>
|
||||
</dict>
|
||||
</plist>
|
||||
PLIST
|
||||
|
||||
@@ -11,9 +11,16 @@ INSTALLER_SIGNING_IDENTITY="${INSTALLER_SIGNING_IDENTITY:-}"
|
||||
PROVISIONING_PROFILE="${PROVISIONING_PROFILE:-}"
|
||||
ENTITLEMENTS_PATH="${ENTITLEMENTS_PATH:-$ROOT_DIR/Signing/IHatePDFs-AppStore.entitlements}"
|
||||
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}"
|
||||
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() {
|
||||
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."
|
||||
|
||||
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" \
|
||||
APP_VERSION="$APP_VERSION" \
|
||||
BUILD_NUMBER="$BUILD_NUMBER" \
|
||||
APP_DIR="$APP_DIR" \
|
||||
SIGNING_IDENTITY="$APP_SIGNING_IDENTITY" \
|
||||
ENTITLEMENTS_PATH="$ENTITLEMENTS_PATH" \
|
||||
PROVISIONING_PROFILE="$PROVISIONING_PROFILE" \
|
||||
"$ROOT_DIR/scripts/build-app.sh"
|
||||
|
||||
xattr -cr "$APP_DIR" 2>/dev/null || true
|
||||
rm -f "$PKG_PATH"
|
||||
productbuild \
|
||||
--component "$APP_DIR" /Applications \
|
||||
--sign "$INSTALLER_SIGNING_IDENTITY" \
|
||||
@@ -63,7 +73,7 @@ if [[ "$VALIDATE_WITH_ALTOOL" == "1" ]]; then
|
||||
--type macos \
|
||||
--file "$PKG_PATH" \
|
||||
--username "$ASC_USERNAME" \
|
||||
--password "$ASC_PASSWORD"
|
||||
--password "@env:ASC_PASSWORD"
|
||||
fi
|
||||
|
||||
echo "Created App Store package: $PKG_PATH"
|
||||
|
||||
@@ -14,11 +14,19 @@ if [[ "$BUILD_APP" != "0" || ! -d "$APP_DIR" ]]; then
|
||||
fi
|
||||
|
||||
rm -f "$DMG_PATH"
|
||||
hdiutil create \
|
||||
-volname "$APP_NAME" \
|
||||
-srcfolder "$APP_DIR" \
|
||||
-ov \
|
||||
-format UDZO \
|
||||
"$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 \
|
||||
-volname "$APP_NAME" \
|
||||
-srcfolder "$APP_DIR" \
|
||||
-ov \
|
||||
-format UDZO \
|
||||
"$DMG_PATH"
|
||||
fi
|
||||
|
||||
echo "Created $DMG_PATH"
|
||||
|
||||
64
scripts/make-tiny-archives.sh
Executable file
64
scripts/make-tiny-archives.sh
Executable 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"
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
APP_VERSION="${APP_VERSION:-0.3.0}"
|
||||
BUILD_NUMBER="${BUILD_NUMBER:-4}"
|
||||
APP_VERSION="${APP_VERSION:-0.4.0}"
|
||||
BUILD_NUMBER="${BUILD_NUMBER:-6}"
|
||||
RELEASE_VERSION="${RELEASE_VERSION:-${APP_VERSION%.0}}"
|
||||
|
||||
129
scripts/verify-release-artifacts.sh
Executable file
129
scripts/verify-release-artifacts.sh
Executable 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
40
scripts/verify-release-size.sh
Executable 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
|
||||
Reference in New Issue
Block a user