Skip to content

Add macOS app bundle with double-click file associations - #54

Merged
JoshCLWren merged 7 commits into
mainfrom
macos-app-bundle
Aug 8, 2026
Merged

Add macOS app bundle with double-click file associations#54
JoshCLWren merged 7 commits into
mainfrom
macos-app-bundle

Conversation

@JoshCLWren

@JoshCLWren JoshCLWren commented Aug 8, 2026

Copy link
Copy Markdown
Owner

Packages the viewer as cdisplayagain.app so Finder opens .cbz/.cbr on double-click, matching the Linux desktop-entry workflow. Everything platform-specific is guarded on darwin; the Linux build, install, and argv paths are unchanged.

Why app code had to change

macOS does not put a double-clicked file in argv. Finder launches the app with an empty argv and sends the path as an openDocument Apple Event once the Tk event loop is running. Without a handler, double-clicking a comic launched the viewer and then showed the "Open Comic" file picker.

main() now registers a ::tk::mac::OpenDocument handler and pumps the event loop briefly (750ms) before falling back to the dialog. After the viewer exists the handler is re-registered against it, so opening a second comic from Finder loads it into the open window.

Bugs found along the way

  • _init_logging killed every Finder launch. It created a relative logs directory, and Finder sets the working directory to /, so the packaged app died with OSError: [Errno 30] Read-only file system: 'logs' before any window appeared. A frozen build now logs to ~/Library/Logs/cdisplayagain on macOS and $XDG_STATE_HOME/cdisplayagain/logs on Linux; source runs keep the relative logs/; CDISPLAYAGAIN_LOG_DIR still overrides everything, which the CI and compatibility scripts depend on. An unwritable log root now degrades to console logging rather than aborting startup. This was latent on Linux too, for any launcher with a read-only working directory.
  • file:// arguments were truncated. raw[7] took a single character where raw[7:] was meant. Now parsed with urlparse + url2pathname, so percent-encoded paths decode correctly.

Packaging

cdisplayagain.spec gains a BUNDLE step behind a sys.platform check:

  • CFBundleDocumentTypes for cbz/cbr/cbt/cba at LSHandlerRank: Owner, images as Alternate, plus exported UTIs for cbz/cbr.
  • Windowed (console=False) on macOS only, so Finder does not open a Terminal beside the viewer. Linux keeps its console for CLI diagnostics.
  • A committed multi-resolution cdisplayagain.icns, generated by scripts/make-icns.sh. PyInstaller's PNG auto-conversion produced a hash-named single-size file that CFBundleTypeIconFile could not reference.

The bundle carries its own Python, Tcl/Tk, Pillow, libvips, and unrar; it does not use the developer's venv.

Install

make install now depends on build-onedir, so a stale dist/ can never be installed. scripts/install-macos.sh copies to /Applications, ad-hoc re-signs (arm64 refuses to exec a moved unsigned bundle), registers with Launch Services, writes a ~/.local/bin/cdisplayagain wrapper, and sets default handlers via duti when present, printing the manual Get Info steps otherwise.

It removes prior installs before copying, sharing one routine with --uninstall. With no MACOS_APPDIR set it sweeps /Applications and ~/Applications; with one set it is scoped strictly to that directory and leaves system defaults alone, so pointing it at a scratch directory cannot remove a real install.

make package-macos produces a distributable zip via ditto (preserves the ad-hoc signature), mirroring package-linux.sh.

Testing notes

  • 23 new tests across tests/test_macos_open_document.py and tests/test_log_location.py. FakeTk in test_parity_tasks.py gained createcommand.
  • make pytest on macOS opens real Tk windows for ~30s; there is no xvfb for Aqua. test_right_click_shows_context_menu fails natively because Aqua's tk_popup needs a live event loop.
  • make pytest-container runs headless in the Debian container with a dedicated Docker volume, so the host .venv is never overwritten with Linux binaries. It was observed hanging on macOS, where Docker bind-mounts the repo across the VM boundary, so it is documented as the Linux/CI-parity path rather than the macOS default.

Verified

Built, installed, and confirmed working end to end on Apple silicon (macOS 15, arm64): double-clicking a .cbz in Finder opens the viewer. Lint and pyright are clean. The full suite has not been run on Linux locally; CI covers that.

Not included

No signing/notarization (the bundle is ad-hoc signed, so a downloaded copy is quarantined until right-click > Open), no universal2 binary, and no macOS CI job. Happy to add a build job if you want the bundle verified on every PR.

Summary by CodeRabbit

  • New Features

    • Added macOS app bundle builds, installation, packaging, and uninstall support.
    • Added support for opening comic archives and images from Finder, including file associations.
    • Improved macOS application launch and document handling.
    • Added safer cross-platform log directory selection with graceful fallback behavior.
    • Added Docker-based headless test execution.
  • Bug Fixes

    • Improved Tk environment handling for macOS runs and packaged applications.
  • Documentation

    • Updated setup, installation, packaging, and macOS usage instructions.
  • Tests

    • Added coverage for logging behavior, macOS document opening, and platform-specific test execution.

Package the viewer as cdisplayagain.app so Finder can open .cbz/.cbr on
double-click, matching the Linux desktop-entry workflow.

macOS delivers a double-clicked file through an openDocument Apple Event
rather than argv, so main() registers a ::tk::mac::OpenDocument handler and
waits briefly for it before falling back to the file dialog. The handler is
re-registered against the running viewer, so opening a second comic from
Finder loads it into the open window. All of this is guarded on darwin;
the Linux argv path is unchanged.

Fix two bugs this exposed:

- _init_logging created a relative "logs" directory. Finder launches apps
  with the working directory set to "/", so a packaged build died with
  "Errno 30: Read-only file system" before showing a window. A frozen build
  now logs under ~/Library/Logs on macOS and $XDG_STATE_HOME on Linux, and
  an unwritable log root degrades to console logging instead of aborting.
  CDISPLAYAGAIN_LOG_DIR still overrides everything, as the CI and
  compatibility scripts rely on it.
- A file:// argument was truncated to a single character by raw[7]
  instead of raw[7:].

The spec grows a BUNDLE step behind a sys.platform check, declaring
CFBundleDocumentTypes for cbz/cbr/cbt/cba at LSHandlerRank Owner plus
exported UTIs, and builds windowed so Finder does not open a Terminal
alongside the viewer. The Linux build path is untouched.

make install now builds first, so a stale dist/ cannot be installed, and
install-macos.sh removes prior installs before copying. With no explicit
MACOS_APPDIR it sweeps /Applications and ~/Applications; with one set it
stays scoped to that directory and leaves system default handlers alone.

scripts/tk-env.sh points Tk at the Tcl/Tk that uv's python-build-standalone
keeps outside the virtualenv, which otherwise breaks every Tk() call in a
source run on macOS.
@coderabbitai

coderabbitai Bot commented Aug 8, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@JoshCLWren, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 24 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: d0cf3fdc-5822-4f95-8d62-39ad8ac96bcc

📥 Commits

Reviewing files that changed from the base of the PR and between 7ce959b and 560b183.

📒 Files selected for processing (12)
  • .githooks/pre-commit
  • .github/workflows/release.yml
  • Makefile
  • README.md
  • cdisplayagain.py
  • cdisplayagain.spec
  • packaging/linux/cdisplayagain.xml
  • pyproject.toml
  • scripts/install-macos.sh
  • scripts/package-macos.sh
  • scripts/update-cask.sh
  • tk_bootstrap.py
📝 Walkthrough

Walkthrough

The pull request adds macOS app-bundle support, Finder document opening, platform-aware logging, installation and packaging scripts, Tk environment handling, and native or Docker-based test execution.

Changes

macOS runtime integration

Layer / File(s) Summary
Platform-aware logging and Finder document handling
cdisplayagain.py
Logging now selects writable platform-specific directories. macOS Finder document events are normalized, queued, registered with Tk, and loaded into an existing ComicViewer.
Runtime validation
tests/test_log_location.py, tests/test_macos_open_document.py, tests/test_parity_tasks.py
Tests cover log-directory fallback, file-URL conversion, Tk registration, queued documents, viewer loading, startup selection, and fake Tk command support.

macOS packaging and distribution

Layer / File(s) Summary
Application bundle metadata
cdisplayagain.spec
The PyInstaller specification reads the project version, selects platform icons, suppresses the macOS console, and defines document associations and exported file types.
Build and installation targets
Makefile, scripts/tk-env.sh
Make targets support macOS Tk setup, app installation, uninstallation, packaging, and platform-specific test execution.
macOS installation and packaging scripts
scripts/install-macos.sh, scripts/make-icns.sh, scripts/package-macos.sh
Scripts generate icons, install or remove the app, register it with Launch Services, configure archive handlers, and create versioned ZIP packages.

Documentation and containerized testing

Layer / File(s) Summary
Usage and test documentation
README.md
Documentation describes macOS builds, installation, file associations, quarantine handling, Tk setup, native tests, Docker tests, and Makefile targets.
Headless test execution
Makefile
The pytest-container target checks Docker, builds or reuses a CI image, mounts a persistent virtual environment, synchronizes locked dependencies, and runs tests under xvfb.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related issues

Possibly related PRs

  • JoshCLWren/cdisplayagain#49 — This pull request extends the related Makefile and PyInstaller packaging workflow with macOS-specific build and distribution support.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: adding a macOS app bundle with Finder file associations.
Docstring Coverage ✅ Passed Docstring coverage is 80.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch macos-app-bundle

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 7

🧹 Nitpick comments (3)
cdisplayagain.py (1)

1743-1758: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Use a precise callback type and note the queue reset.

Two points:

  1. Callable has no parameter or return types. The coding guidelines require precise annotations on public functions. Use Callable[..., None].
  2. register_open_document_handler clears _PENDING_OPEN_DOCUMENTS on every call. The second registration in main (Line 1849) therefore drops any paths that Finder queued during startup but that await_open_document did not consume. If a user selects several comics and opens them together, the extra paths are lost silently. If that is intended for a single-window viewer, keep it. If not, drain the queue into the new callback after registering.

As per coding guidelines: "Keep public functions annotated with precise types instead of using Any (ruff ANN401 rule)".

♻️ Proposed annotation change
-def register_open_document_handler(root: tk.Tk, callback: Callable | None = None) -> bool:
+def register_open_document_handler(
+    root: tk.Tk, callback: Callable[..., None] | None = None
+) -> bool:
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cdisplayagain.py` around lines 1743 - 1758, Update the public
register_open_document_handler signature to use Callable[..., None] | None
instead of untyped Callable. Avoid clearing _PENDING_OPEN_DOCUMENTS during
repeated registration; after installing the callback, preserve and deliver any
queued paths to the selected callback so startup events are not lost.

Source: Coding guidelines

Makefile (1)

175-191: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Run the container test as the host UID:GID.

docker run defaults to root while binding $(CURDIR) into /app, so pytest creates root-owned .pytest_cache/__pycache__ entries in the checkout. Add --user "$$(id -u):$$(id -g)" before running pytest.

The image tag matches the compose service cdisplayagain-ci:13, so no tag mismatch remains.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Makefile` around lines 175 - 191, Update the pytest-container docker run
invocation to execute as the host user by adding the escaped host UID:GID via
--user before the image command. Keep the existing cdisplayagain-ci:13 image,
mounts, environment, and pytest command unchanged.
scripts/tk-env.sh (1)

20-27: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Discover the Tcl/Tk directory instead of hardcoding 8.6.

python-build-standalone 3.13/3.14 can ship Tcl/Tk 9.0, so lib/tcl8.6 and lib/tk8.6 may not exist. If scripts/tk-env.sh prints nothing, Makefile’s TK_ENV stays empty and Tk still reports “Can’t find a usable init.tcl”. Resolve the install by globbing lib/tcl*/lib/tk* or reading tkinter.TkVersion.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/tk-env.sh` around lines 20 - 27, The Tcl/Tk discovery logic using
tcl_library and tk_library must stop hardcoding 8.6. Update the Python code in
scripts/tk-env.sh to discover matching lib/tcl* and lib/tk* directories, or
derive the version via tkinter.TkVersion, then emit TCL_LIBRARY and TK_LIBRARY
when init.tcl and the Tk directory exist.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@cdisplayagain.spec`:
- Around line 105-118: Update the UTTypeIdentifier values in
UTExportedTypeDeclarations to app-owned identifiers under
io.github.joshclwren.cdisplayagain for both CBZ and CBR, and update the
corresponding LSItemContentTypes entries to use those exact same identifiers
while preserving the existing descriptions, conformances, and filename
extensions.

In `@README.md`:
- Line 205: Update the README entry for make pytest to state that it runs tests
with real Tk windows on macOS, and direct users to make pytest-container for the
headless Docker execution path.

In `@scripts/install-macos.sh`:
- Around line 119-123: Update scripts/install-macos.sh lines 119-123 around the
codesign invocation to capture its exit status instead of suppressing failure,
and print a warning naming the manual codesign --force --sign - command when
signing fails. Update lines 137-141 to collect extensions whose duti
registration fails, suppress per-extension errors only while recording them, and
report success only for extensions that succeeded.
- Around line 137-141: Update the duti-handling branch to track whether any
invocation in the extension loop fails instead of unconditionally suppressing
errors. Use that result to report success only when all handlers were set, and
otherwise emit a failure message indicating the default-handler configuration
did not complete.

In `@scripts/package-macos.sh`:
- Around line 37-40: Update install-macos.sh so its bundle discovery checks
script_dir in addition to source_root, allowing the packaged install.sh and
cdisplayagain.app siblings to be found. Preserve the existing source_root
comparison in warn_about_stray_copies, and also exclude the script_dir copy from
stray-copy warnings.

In `@tests/test_macos_open_document.py`:
- Around line 148-156: Replace the permissive ComicViewer mocks in
tests/test_macos_open_document.py at lines 148-156 and 170-178 with one shared
interface fake defining the constructor, _fullscreen, _set_cursor_hidden(), and
_request_focus(). Use the fake in both tests and assert each instance captured
the expected comic_path; no direct change is needed elsewhere.
- Around line 148-156: Patch cdisplayagain._init_logging in both main() test
contexts to prevent real logging initialization:
tests/test_macos_open_document.py lines 148-156 and lines 170-178. Add the mock
alongside the existing tkinter, viewer, document, dialog, and argv patches; both
sites require the same direct change.

---

Nitpick comments:
In `@cdisplayagain.py`:
- Around line 1743-1758: Update the public register_open_document_handler
signature to use Callable[..., None] | None instead of untyped Callable. Avoid
clearing _PENDING_OPEN_DOCUMENTS during repeated registration; after installing
the callback, preserve and deliver any queued paths to the selected callback so
startup events are not lost.

In `@Makefile`:
- Around line 175-191: Update the pytest-container docker run invocation to
execute as the host user by adding the escaped host UID:GID via --user before
the image command. Keep the existing cdisplayagain-ci:13 image, mounts,
environment, and pytest command unchanged.

In `@scripts/tk-env.sh`:
- Around line 20-27: The Tcl/Tk discovery logic using tcl_library and tk_library
must stop hardcoding 8.6. Update the Python code in scripts/tk-env.sh to
discover matching lib/tcl* and lib/tk* directories, or derive the version via
tkinter.TkVersion, then emit TCL_LIBRARY and TK_LIBRARY when init.tcl and the Tk
directory exist.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 477a8877-9acf-4c58-a456-f39dedcfc98c

📥 Commits

Reviewing files that changed from the base of the PR and between a5bcda3 and 7ce959b.

📒 Files selected for processing (12)
  • Makefile
  • README.md
  • cdisplayagain.icns
  • cdisplayagain.py
  • cdisplayagain.spec
  • scripts/install-macos.sh
  • scripts/make-icns.sh
  • scripts/package-macos.sh
  • scripts/tk-env.sh
  • tests/test_log_location.py
  • tests/test_macos_open_document.py
  • tests/test_parity_tasks.py

Comment thread cdisplayagain.spec
Comment on lines +105 to +118
"UTExportedTypeDeclarations": [
{
"UTTypeIdentifier": "public.cbz-archive",
"UTTypeDescription": "Comic Book ZIP Archive",
"UTTypeConformsTo": ["public.zip-archive"],
"UTTypeTagSpecification": {"public.filename-extension": ["cbz"]},
},
{
"UTTypeIdentifier": "public.cbr-archive",
"UTTypeDescription": "Comic Book RAR Archive",
"UTTypeConformsTo": ["public.archive"],
"UTTypeTagSpecification": {"public.filename-extension": ["cbr"]},
},
],

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

Are public.cbz-archive and public.cbr-archive Apple-defined uniform type identifiers, and may third-party apps declare them in UTExportedTypeDeclarations?

💡 Result:

No, public.cbz-archive and public.cbr-archive are not Apple-defined Uniform Type Identifiers (UTIs) [1][2][3][4]. Uniform Type Identifiers that start with the public domain (e.g., public.*) are reserved for use by Apple [2][5]. Because these specific identifiers are not part of Apple's system-declared UTI registry, they should not be used as if they were official system types [1][2][6]. Regarding the declaration of these identifiers: 1. UTExportedTypeDeclarations: You may use the UTExportedTypeDeclarations key in your app's Info.plist to define your own UTIs for proprietary or unsupported file formats [6]. However, if you are defining a UTI for a format that is already established by another organization (such as comic book archive formats), it is best practice to use a reverse-DNS identifier owned by your organization (e.g., com.yourcompany.cbz-archive) rather than attempting to claim a public-domain identifier [6]. 2. Ownership and Conflicts: Because public-domain UTIs are reserved, if you declare one in your UTExportedTypeDeclarations, you may encounter conflicts with the system or other applications that attempt to define or utilize the same identifier [2][6]. It is technically possible for an app to include these strings in its Info.plist, but it is incorrect to treat them as Apple-defined standards, and doing so is discouraged as it violates the intended naming conventions for third-party UTIs [2][6]. For supporting standard comic book formats like CBZ or CBR, developers typically define their own unique, reverse-DNS-style UTIs in their app's Info.plist and map them to the appropriate file extensions [7][6].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== locate cdisplayagain.spec =="
fd -a 'cdisplayagain\.spec$' . || true

echo "== relevant snippets =="
if [ -f cdisplayagain.spec ]; then
  sed -n '1,150p' cdisplayagain.spec | cat -n
fi

echo "== UTImportedTypeDeclarations and public.* declarations in repo =="
rg -n "UTExportedTypeDeclarations|UTImportedTypeDeclarations|public\.(cbz-archive|cbr-archive|public\.archive|zip-archive)|LSItemContentTypes" . || true

echo "== spec metadata values around app id/bundle/zip if present =="
python3 - <<'PY'
from pathlib import Path
p=Path('cdisplayagain.spec')
if p.exists():
    text=p.read_text()
    for needle in ['id:', 'name:', 'summary:', 'summary[en]:', 'description[en]:', 'Version', 'PackageGroup']:
        for i,line in enumerate(text.splitlines(),1):
            if needle in line or (needle=='Version' and 'Version:' in line) or (needle.startswith('description[') and 'description[' in line):
                if (needle=='description[en]:' and line.startswith('description')) or needle in line:
                    print(f'{i}:{line}')
PY

Repository: JoshCLWren/cdisplayagain

Length of output: 6006


🌐 Web query:

Apple CFBundleDocumentTypes LSItemContentTypes imported UTI UTImportedTypeDeclarations public.domain reserved bundle identifier namespace

💡 Result:

In Apple development, Uniform Type Identifiers (UTIs) are used to uniquely identify file types, data types, and bundles [1][2][3]. When your app needs to support or declare specific file types, you manage these definitions within your app's Info.plist using specific keys [4][5]. CFBundleDocumentTypes and LSItemContentTypes The CFBundleDocumentTypes key is an array of dictionaries that informs the system about the document types your app supports [6][4][7]. Within these dictionaries, the LSItemContentTypes key holds an array of strings, where each string is a UTI representing a supported file type [8][4][7]. UTImportedTypeDeclarations vs. UTExportedTypeDeclarations If your app utilizes a file type that is not owned by your app (e.g., one designed by another company or another app), you should include it in the UTImportedTypeDeclarations array in your Info.plist [9][5][10]. Conversely, if you are the creator or the authoritative source for a proprietary file format, you should include the definition in the UTExportedTypeDeclarations array [5][10]. UTI Namespace and Reserved Domains UTIs follow a reverse-DNS format (e.g., com.companyname.app.documenttype) to ensure global uniqueness [1][11][2]. Regarding reserved namespaces: - The public domain (e.g., public.text, public.jpeg) is reserved strictly for Apple-defined types that are broadly used across the system [1][2]. Developers should not create new UTIs within the public domain [1]. - The dyn domain is reserved for dynamic UTIs, which the system generates automatically when no other identifier is available [1][2]. - Third-party developers should use their own reverse-DNS domain (e.g., com.yourcompany) to define their unique UTI namespace [1][5]. To define a new UTI, you provide metadata such as the UTI identifier, a description, and the UTI's conformance (e.g., public.data or com.apple.package) to ensure the system understands how to handle the file [5][12].

Citations:


🌐 Web query:

"public.cbz-archive" UTI system declared uniform type identifier

💡 Result:

The string public.cbz-archive is not a system-declared Uniform Type Identifier (UTI) defined by Apple [1][2]. UTIs that begin with the public. prefix are reserved for system-declared identifiers defined exclusively by Apple [1]. Third-party developers who wish to support the CBZ (Comic Book ZIP) format must declare their own UTIs or use established custom identifiers within their application bundles [3]. In practice, public.cbz-archive is often used by third-party comic book reader applications as a convention to identify CBZ files [4]. Because there is no single official standard UTI for this format, various applications define their own unique identifiers, such as com.simplecomic.cbz-archive, com.yacreader.yacreader.cbz, or com.bitcartel.comicbooklover.cbz [4]. When configuring software to support CBZ files, developers typically include a list of these common identifiers in their Info.plist file to ensure interoperability across different tools [4][3].

Citations:


Use app-owned UTIs for CBZ and CBR support.

public.cbz-archive and public.cbr-archive do not match Apple-defined UTIs, so these UTExportedTypeDeclarations claim reserved and potentially conflicting identifiers. Declare the formats with identifiers under io.github.joshclwren.cdisplayagain, such as io.github.joshclwren.cdisplayagain.cbz-archive and io.github.joshclwren.cdisplayagain.cbr-archive, and keep the same values in LSItemContentTypes.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cdisplayagain.spec` around lines 105 - 118, Update the UTTypeIdentifier
values in UTExportedTypeDeclarations to app-owned identifiers under
io.github.joshclwren.cdisplayagain for both CBZ and CBR, and update the
corresponding LSItemContentTypes entries to use those exact same identifiers
while preserving the existing descriptions, conformances, and filename
extensions.

Comment thread README.md
- `make sync`: install dependencies from `uv.lock`.
- `make lint`: run ruff.
- `make pytest`: run the test suite.
- `make pytest`: run the test suite (xvfb on Linux, container on macOS).

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Correct the macOS make pytest behavior.

make pytest opens real Tk windows on macOS. It does not use a container. Direct users to make pytest-container for the headless Docker path.

Proposed fix
- - `make pytest`: run the test suite (xvfb on Linux, container on macOS).
+ - `make pytest`: run the test suite (xvfb on Linux; real Tk windows on macOS).
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
- `make pytest`: run the test suite (xvfb on Linux, container on macOS).
- `make pytest`: run the test suite (xvfb on Linux; real Tk windows on macOS).
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@README.md` at line 205, Update the README entry for make pytest to state that
it runs tests with real Tk windows on macOS, and direct users to make
pytest-container for the headless Docker execution path.

Comment thread scripts/install-macos.sh
Comment on lines +119 to +123
# Copying rewrites nothing, but an unsigned bundle that moved needs its ad-hoc
# signature refreshed or arm64 refuses to exec it.
if command -v codesign >/dev/null 2>&1; then
codesign --force --sign - --timestamp=none "$installed_app" >/dev/null 2>&1 || true
fi

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Suppressed exit statuses make the installer report success after a failure. Both steps end with || true or 2>/dev/null, and the script then prints an unconditional success message. The user sees a completed install even when the bundle cannot launch or the file associations are not set.

  • scripts/install-macos.sh#L119-L123: check the codesign exit status and print a warning that names the manual codesign --force --sign - command.
  • scripts/install-macos.sh#L137-L141: collect the extensions for which duti fails, and print the success message only for the extensions that succeeded.
📍 Affects 1 file
  • scripts/install-macos.sh#L119-L123 (this comment)
  • scripts/install-macos.sh#L137-L141
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/install-macos.sh` around lines 119 - 123, Update
scripts/install-macos.sh lines 119-123 around the codesign invocation to capture
its exit status instead of suppressing failure, and print a warning naming the
manual codesign --force --sign - command when signing fails. Update lines
137-141 to collect extensions whose duti registration fails, suppress
per-extension errors only while recording them, and report success only for
extensions that succeeded.

Comment thread scripts/install-macos.sh
Comment on lines +137 to +141
elif command -v duti >/dev/null 2>&1; then
for extension in "${extensions[@]}"; do
duti -s "$bundle_id" "$extension" all 2>/dev/null || true
done
echo "Set cdisplayagain as the default handler for: ${extensions[*]}"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Report a duti failure instead of claiming success.

Line 139 discards the exit status of every duti call. Line 141 then always reports that the default handler is set. If duti fails, the message is wrong and the user does not know that Finder still opens another app. Track the failures and adjust the message.

🔧 Proposed fix
 elif command -v duti >/dev/null 2>&1; then
+    failed=()
     for extension in "${extensions[@]}"; do
-        duti -s "$bundle_id" "$extension" all 2>/dev/null || true
+        duti -s "$bundle_id" "$extension" all 2>/dev/null || failed+=("$extension")
     done
-    echo "Set cdisplayagain as the default handler for: ${extensions[*]}"
+    if [[ ${`#failed`[@]} -eq 0 ]]; then
+        echo "Set cdisplayagain as the default handler for: ${extensions[*]}"
+    else
+        echo "WARNING: duti could not set the handler for: ${failed[*]}" >&2
+    fi
 fi
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
elif command -v duti >/dev/null 2>&1; then
for extension in "${extensions[@]}"; do
duti -s "$bundle_id" "$extension" all 2>/dev/null || true
done
echo "Set cdisplayagain as the default handler for: ${extensions[*]}"
elif command -v duti >/dev/null 2>&1; then
failed=()
for extension in "${extensions[@]}"; do
duti -s "$bundle_id" "$extension" all 2>/dev/null || failed+=("$extension")
done
if [[ ${`#failed`[@]} -eq 0 ]]; then
echo "Set cdisplayagain as the default handler for: ${extensions[*]}"
else
echo "WARNING: duti could not set the handler for: ${failed[*]}" >&2
fi
fi
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/install-macos.sh` around lines 137 - 141, Update the duti-handling
branch to track whether any invocation in the extension loop fails instead of
unconditionally suppressing errors. Use that result to report success only when
all handlers were set, and otherwise emit a failure message indicating the
default-handler configuration did not complete.

Comment thread scripts/package-macos.sh
Comment on lines +37 to +40
# ditto preserves the bundle's ad-hoc signature and extended attributes; zip does not.
ditto "$app_bundle" "$package_root/cdisplayagain.app"
install -m 0644 "$root_dir/LICENSE" "$package_root/LICENSE"
install -m 0755 "$root_dir/scripts/install-macos.sh" "$package_root/install.sh"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🔴 Critical | ⚡ Quick win

The packaged install.sh cannot find the bundle it ships with.

Line 38 places the bundle at <package_root>/cdisplayagain.app. Line 40 places the installer at <package_root>/install.sh.

scripts/install-macos.sh Line 25 computes source_root as the parent of the script directory, because it assumes it lives in scripts/. In the extracted archive, the script directory is <package_root>, so source_root becomes the extraction directory one level above. The candidate paths at Lines 92-97 then resolve to <extract_dir>/dist/cdisplayagain.app and <extract_dir>/cdisplayagain.app. Neither exists, and the installer aborts with "ERROR: no cdisplayagain.app found. Run 'make build' first."

The documented flow in README-install.txt ("Install with: ./install.sh") therefore fails for every downloaded release. Make install-macos.sh search the script directory as well as its parent.

🐛 Proposed fix in scripts/install-macos.sh
-source_root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
+script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
+source_root=$(cd -- "$script_dir/.." && pwd)
-for candidate in "$source_root/dist/$app_name" "$source_root/$app_name"; do
+for candidate in "$source_root/dist/$app_name" "$script_dir/$app_name" "$source_root/$app_name"; do

Note: warn_about_stray_copies compares against $source_root. Keep that comparison, and add $script_dir so the shipped copy is not reported as a stray.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/package-macos.sh` around lines 37 - 40, Update install-macos.sh so
its bundle discovery checks script_dir in addition to source_root, allowing the
packaged install.sh and cdisplayagain.app siblings to be found. Preserve the
existing source_root comparison in warn_about_stray_copies, and also exclude the
script_dir copy from stray-copy warnings.

Comment on lines +148 to +156
with (
patch("tkinter.Tk") as mock_tk,
patch.object(cdisplayagain, "ComicViewer") as mock_viewer,
patch.object(cdisplayagain, "await_open_document", return_value=str(comic)),
patch("tkinter.filedialog.askopenfilename") as mock_dialog,
patch("sys.argv", ["cdisplayagain.py"]),
):
mock_tk.return_value = MagicMock()
cdisplayagain.main()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Replace ComicViewer mocks with interface fakes.

MagicMock accepts any member access. These tests can pass when main() calls an invalid viewer member. Use a small fake that defines the constructor, _fullscreen, _set_cursor_hidden(), and _request_focus().

  • tests/test_macos_open_document.py#L148-L156: use an interface fake and assert its captured comic_path.
  • tests/test_macos_open_document.py#L170-L178: use the same interface fake and assert its captured comic_path.

As per coding guidelines, “Mirror the real ComicViewer interface in fakes rather than relaxing production code.”

📍 Affects 1 file
  • tests/test_macos_open_document.py#L148-L156 (this comment)
  • tests/test_macos_open_document.py#L170-L178
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_macos_open_document.py` around lines 148 - 156, Replace the
permissive ComicViewer mocks in tests/test_macos_open_document.py at lines
148-156 and 170-178 with one shared interface fake defining the constructor,
_fullscreen, _set_cursor_hidden(), and _request_focus(). Use the fake in both
tests and assert each instance captured the expected comic_path; no direct
change is needed elsewhere.

Source: Coding guidelines


🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Isolate logging from these main() tests.

main() calls _init_logging() before the mocked GUI path. Both tests can create logs/ in the repository and can fail when that directory is not writable.

  • tests/test_macos_open_document.py#L148-L156: patch cdisplayagain._init_logging in the test context.
  • tests/test_macos_open_document.py#L170-L178: patch cdisplayagain._init_logging in the test context.
📍 Affects 1 file
  • tests/test_macos_open_document.py#L148-L156 (this comment)
  • tests/test_macos_open_document.py#L170-L178
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_macos_open_document.py` around lines 148 - 156, Patch
cdisplayagain._init_logging in both main() test contexts to prevent real logging
initialization: tests/test_macos_open_document.py lines 148-156 and lines
170-178. Add the mock alongside the existing tkinter, viewer, document, dialog,
and argv patches; both sites require the same direct change.

Fold in the parts of the earlier file-associations branch that are better
than what this branch had.

tk_bootstrap.py replaces scripts/tk-env.sh. Setting TCL_LIBRARY and
TK_LIBRARY at import time fixes uv's python-build-standalone Tk for every
entry point, including plain "python cdisplayagain.py", pytest, and IDE
launches. The shell helper only reached commands routed through the
Makefile and needed an eval anywhere else.

Add packaging/linux/cdisplayagain.xml so install-desktop registers the
cbz/cbr MIME types with shared-mime-info instead of assuming the desktop
already knows them, and install the icon so the desktop entry can
reference it.

Pin uv to managed interpreters, matching the runtime tk_bootstrap targets.
@JoshCLWren

Copy link
Copy Markdown
Owner Author

Superseding #42

Folded in the two pieces #42 did better, and closed it in favour of this branch (which is current; #42 had gone CONFLICTING against main).

Adopted from #42:

  • tk_bootstrap.py replaces scripts/tk-env.sh. Setting TCL_LIBRARY/TK_LIBRARY at import time fixes uv's python-build-standalone Tk for every entry point: python cdisplayagain.py, pytest, IDE runs. The shell helper only reached commands routed through the Makefile and needed an eval anywhere else. Verified with env -u TCL_LIBRARY -u TK_LIBRARY: Tk works: 8.6.14.
  • packaging/linux/cdisplayagain.xml. install-desktop now registers the cbz/cbr MIME types with shared-mime-info rather than assuming the desktop already knows them, and installs the icon so the desktop entry can reference it.
  • [tool.uv] python-preference = "managed", which matches the interpreter tk_bootstrap targets.

Kept from this branch, where the approaches differed:

  • Apple Event handler over argv_emulation. Add cross-platform comic file associations #42 used PyInstaller's argv_emulation=True, which only intercepts the launch event. The ::tk::mac::OpenDocument handler here also loads a comic double-clicked while the app is already running, and being plain Python it is unit-testable.
  • One spec with a sys.platform branch rather than a second cdisplayagain-macos.spec, so the Linux and macOS build inputs cannot drift apart.

Only here: the file:// truncation fix, the unwritable-log-root fallback, make install building first, uninstall-before-install, package-macos.sh, the .icns generator, and the tests.

Both branches independently diagnosed the relative-logs/ startup crash, which is good confirmation that it was the actual blocker.

A developer with UV_INDEX_URL or a pip.conf mirror set has every uv command
silently rewrite the registry of all 25 entries in uv.lock. Committing that
breaks CI and any clone that cannot reach the mirror, and nothing announces
the change, so it is easy to stage by accident.

The pre-commit hook now fails when a staged uv.lock references any registry
other than pypi.org, naming the offending URLs.
Add a macOS job to the release workflow, on macos-14 for arm64 and macos-13
for Intel, so a tag produces downloadable app bundles alongside the Linux
archive. The job does not reuse the shared setup action: that action is
Linux-only and rebuilds pyvips from source, while macOS needs the
pyvips-binary wheel that carries libvips inside the bundle.

Two checks guard the regressions this platform actually hit. The bundle is
run from "/" to catch anything that assumes a writable working directory,
the way the relative log path did, and Info.plist is checked for its cbz and
cbr declarations so a spec change cannot silently drop file associations.

publish and attest now collect every artifact and regenerate SHA256SUMS
across the full published set.

scripts/update-cask.sh generates a Homebrew cask from a published release,
reading the checksums the workflow published so the cask cannot disagree
with the assets people download.

Drop --sequesterRsrc from the packaging step; it only added __MACOSX noise,
and the ad-hoc signature lives in the bundle rather than in xattrs.
Point macOS users at the tap first, and state plainly why the cask clears
the quarantine flag: the bundle is ad-hoc signed rather than notarized,
which needs a paid Apple Developer account.
PyInstaller does not emit Contents/PkgInfo, and Finder falls back to a
generic icon for bundles without it even though Info.plist already carries
CFBundlePackageType. Verified the ad-hoc signature still passes
'codesign --verify --deep --strict' with the file present.
Finder caches an app icon against the bundle path and its modification
time, so installing over a previous copy kept showing the stale icon and
needed a manual lsregister plus killall Finder afterwards. The installer
now bumps the timestamp and relaunches Finder itself.

Both are skipped when MACOS_APPDIR points somewhere non-standard, matching
how default handlers are already left alone for scratch installs.
@JoshCLWren
JoshCLWren merged commit 46d5c00 into main Aug 8, 2026
3 checks passed
@JoshCLWren
JoshCLWren deleted the macos-app-bundle branch August 8, 2026 17:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant