Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .githooks/pre-commit
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,23 @@ if [ -n "$PYTHON_FILES" ]; then
fi
fi

# A local UV_INDEX_URL or pip.conf mirror makes every uv command rewrite the
# registry of every entry in uv.lock. Committing that breaks CI and every clone
# that cannot reach the mirror, and nothing announces the change.
if git diff --cached --name-only --diff-filter=ACM | grep -qx 'uv.lock'; then
FOREIGN_REGISTRIES=$(git show ":uv.lock" \
| grep -oE 'registry = "[^"]+"' \
| grep -v 'registry = "https://pypi.org/simple"' \
| sort -u || true)
if [ -n "$FOREIGN_REGISTRIES" ]; then
echo ""
echo "${RED}ERROR: uv.lock resolves against a non-public index:${NC}"
echo "$FOREIGN_REGISTRIES" | sed 's/^/ /'
echo "${RED}Restore it with 'git checkout uv.lock' before committing.${NC}"
exit 1
fi
fi

# Run the shared lint script
bash scripts/lint.sh

105 changes: 97 additions & 8 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,82 @@ jobs:
SHA256SUMS
retention-days: 7

build-macos:
permissions:
contents: read
strategy:
fail-fast: false
matrix:
include:
- arch: arm64
runner: macos-14
- arch: x86_64
runner: macos-13
runs-on: ${{ matrix.runner }}
steps:
- name: Check out exact selected commit
uses: actions/checkout@v4
with:
ref: ${{ github.sha }}

- name: Verify release tag version
if: startsWith(github.ref, 'refs/tags/v')
shell: bash
run: |
project_version=$(awk -F '"' '$1 ~ /^version[[:space:]]*=/ {print $2; exit}' pyproject.toml)
test "${GITHUB_REF_NAME#v}" = "$project_version"

- name: Set up uv
uses: astral-sh/setup-uv@v5

# The shared setup action is Linux-only: it apt-gets and rebuilds pyvips
# from source, while macOS needs the pyvips-binary wheel that carries
# libvips inside the bundle.
- name: Install Python 3.13 and sync dependencies
shell: bash
run: |
uv python install 3.13
uv venv
uv sync --locked

- name: Build app bundle
shell: bash
run: |
source .venv/bin/activate
make build

- name: Smoke test the bundle from a read-only working directory
shell: bash
run: |
cd /
"$GITHUB_WORKSPACE/dist/cdisplayagain.app/Contents/MacOS/cdisplayagain" --version

- name: Verify the bundle declares its file associations
shell: bash
run: |
plist=dist/cdisplayagain.app/Contents/Info.plist
for extension in cbz cbr; do
plutil -p "$plist" | grep -q "\"$extension\"" || {
echo "Info.plist does not declare .$extension" >&2
exit 1
}
done

- name: Package app bundle
id: package
shell: bash
run: |
source .venv/bin/activate
asset_path=$(bash scripts/package-macos.sh)
echo "asset_path=$asset_path" >> "$GITHUB_OUTPUT"

- name: Upload tested macOS release artifact
uses: actions/upload-artifact@v4
with:
name: cdisplayagain-macos-${{ matrix.arch }}-release
path: ${{ steps.package.outputs.asset_path }}
retention-days: 7

compatibility:
permissions:
contents: read
Expand Down Expand Up @@ -129,33 +205,46 @@ jobs:
permissions:
contents: read
id-token: write
needs: build-release
needs: [build-release, build-macos]
runs-on: ubuntu-22.04
steps:
- name: Download tested release artifact
- name: Download every tested release artifact
uses: actions/download-artifact@v4
with:
name: cdisplayagain-linux-x86_64-release
pattern: cdisplayagain-*-release
path: artifact
merge-multiple: true
- name: Generate build provenance
uses: actions/attest-build-provenance@v2
continue-on-error: true
with:
subject-path: artifact/*.tar.gz
subject-path: |
artifact/*.tar.gz
artifact/*.zip

publish:
if: startsWith(github.ref, 'refs/tags/v')
needs: [compatibility, attest]
needs: [compatibility, attest, build-macos]
permissions:
contents: write
runs-on: ubuntu-22.04
steps:
- name: Download tested release artifact
- name: Download every tested release artifact
uses: actions/download-artifact@v4
with:
name: cdisplayagain-linux-x86_64-release
pattern: cdisplayagain-*-release
path: artifact
merge-multiple: true
- name: Checksum the published set
shell: bash
run: |
cd artifact
rm -f SHA256SUMS
sha256sum ./*.tar.gz ./*.zip | sed 's| \./| |' > SHA256SUMS
cat SHA256SUMS
- name: Publish GitHub Release assets
env:
GH_TOKEN: ${{ github.token }}
run: gh release create "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" artifact/*.tar.gz artifact/SHA256SUMS --generate-notes
run: >-
gh release create "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY"
artifact/*.tar.gz artifact/*.zip artifact/SHA256SUMS --generate-notes
58 changes: 52 additions & 6 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
.PHONY: help lint pytest sync venv run smoke clean-build build build-onedir package-linux install install-bin install-desktop mime-query redo ci-test-debian ci-test-local ci-build-image githook install-githook deploy
.PHONY: help lint pytest sync venv run smoke clean-build build build-onedir package-linux install install-bin install-desktop mime-query redo ci-test-debian ci-test-local ci-build-image githook install-githook deploy build-macos install-macos uninstall-macos package-macos pytest-container

# Configuration
PREFIX ?= $(HOME)/.local
BINDIR ?= $(PREFIX)/bin
LIBDIR ?= $(PREFIX)/lib
XDG_DATA_HOME ?= $(HOME)/.local/share
APPDIR ?= $(XDG_DATA_HOME)/applications
UNAME := $(shell uname)
MACOS_APPDIR ?= /Applications

help: ## Show this help message
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}'
Expand All @@ -22,13 +24,18 @@ install-githook: ## Install pre-commit hook for new developers
githook: install-githook ## Run lint checks manually (installs pre-commit hook if missing)
bash scripts/lint.sh

pytest: ## Run tests (requires xvfb to prevent GUI windows)
@if ! command -v xvfb-run >/dev/null 2>&1; then \
pytest: ## Run tests (xvfb on Linux; macOS opens real windows, see pytest-container)
@if [ "$(UNAME)" = "Darwin" ]; then \
echo "NOTE: macOS has no xvfb, so this run opens real Tk windows and takes"; \
echo " over the display for ~30s. 'make pytest-container' is headless."; \
TK_SILENCE_DEPRECATION=1 uv run --active pytest; \
elif ! command -v xvfb-run >/dev/null 2>&1; then \
echo "ERROR: xvfb-run is required to run tests."; \
echo "Install xvfb: sudo apt-get install xvfb"; \
exit 1; \
else \
xvfb-run -a -s "-screen 0 1280x1024x24" uv run --active pytest; \
fi
xvfb-run -a -s "-screen 0 1280x1024x24" uv run --active pytest

profile-cbz: ## Profile CBZ launch performance (Usage: make profile-cbz FILE=path/to/comic.cbz)
@if [ -z "$(FILE)" ]; then echo "Usage: make profile-cbz FILE=path/to/comic.cbz"; exit 1; fi
Expand Down Expand Up @@ -66,15 +73,29 @@ deploy: clean-build build-onedir install ## Build + install for this machine (o

build: build-onedir ## Build the PyInstaller onedir bundle

build-onedir: ## Build onedir bundle (faster startup than onefile)
build-onedir: ## Build onedir bundle (adds cdisplayagain.app on macOS)
uv run --active python scripts/generate_build_info.py
uv run --active pyinstaller --clean --noconfirm cdisplayagain.spec

package-linux: build-onedir ## Package the tested Linux onedir bundle
bash scripts/package-linux.sh


install: install-bin install-desktop ## Install everything
install: build-onedir ## Build, then install (.app on macOS, bundle + desktop entry on Linux)
@if [ "$(UNAME)" = "Darwin" ]; then \
$(MAKE) --no-print-directory install-macos; \
else \
$(MAKE) --no-print-directory install-bin install-desktop; \
fi

install-macos: ## Install cdisplayagain.app to $(MACOS_APPDIR) and register file types
@bash scripts/install-macos.sh

uninstall-macos: ## Remove the installed macOS app bundle
@bash scripts/install-macos.sh --uninstall

package-macos: build-onedir ## Package the macOS .app into a distributable zip
@bash scripts/package-macos.sh

install-bin: ## Install binary to system
@if [ -f dist/cdisplayagain ]; then \
Expand Down Expand Up @@ -103,11 +124,19 @@ install-desktop: ## Install desktop entry (Linux) or app symlink (macOS)
echo "Double-click a .cbz/.cbr once and choose cdisplayagain, then 'Always Open With'."; \
else \
mkdir -p $(APPDIR); \
mkdir -p $(XDG_DATA_HOME)/mime/packages; \
install -m 0644 packaging/linux/cdisplayagain.xml \
$(XDG_DATA_HOME)/mime/packages/cdisplayagain.xml; \
update-mime-database $(XDG_DATA_HOME)/mime 2>/dev/null || true; \
mkdir -p $(XDG_DATA_HOME)/icons/hicolor/256x256/apps; \
install -m 0644 cdisplayagain.png \
$(XDG_DATA_HOME)/icons/hicolor/256x256/apps/cdisplayagain.png; \
printf '%s\n' \
'[Desktop Entry]' \
'Type=Application' \
'Name=cdisplayagain' \
"Exec=$(BINDIR)/cdisplayagain-launcher %f" \
'Icon=cdisplayagain' \
'Terminal=false' \
'Categories=Graphics;Viewer;' \
'MimeType=application/x-cbz;application/x-cbr;application/vnd.comicbook+zip;application/vnd.comicbook-rar;application/x-ext-cbz;application/x-ext-cbr;' \
Expand Down Expand Up @@ -149,6 +178,23 @@ ci-test-local: ## Run CI-like tests locally (requires xvfb and libvips)
grep -E "passed|failed|ERROR|coverage" ci-test-output.log | tail -10; \
fi

pytest-container: ## Run the suite headless in Docker without touching the host .venv
@if ! docker info >/dev/null 2>&1; then \
echo "ERROR: Docker is not available or not running."; \
echo "Start Docker, or run natively with 'make pytest'."; \
exit 1; \
fi
@if ! docker image inspect cdisplayagain-ci:13 >/dev/null 2>&1; then \
$(MAKE) --no-print-directory ci-build-image; \
fi
@docker run --rm \
-v "$(CURDIR):/app" \
-v cdisplayagain-container-venv:/app/.venv \
-w /app \
-e PATH="/root/.local/bin:$$PATH" \
cdisplayagain-ci:13 \
bash -c 'uv sync --locked && xvfb-run -a --server-args="-screen 0 1280x1024x24" uv run pytest tests/ -q --tb=short'

ci-build-image: ## Build/rebuild cached debian image
@echo "Building cached debian image..."
@docker compose build ci
Expand Down
82 changes: 80 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,8 @@ release directory. `PREFIX`, `HOME`, and `XDG_DATA_HOME` can override these
locations for testing or custom user-local layouts.

Only Linux x86-64 release downloads are currently provided. Packaged Windows
and macOS releases are not yet available.
and macOS releases are not yet available; macOS users build the app locally
with `make build && make install` (see below).

Release builds are produced in Ubuntu 22.04 (glibc 2.35), the oldest verified
distribution baseline, and compatibility checks run the same archive on
Expand All @@ -62,6 +63,58 @@ bundle includes its Python, Pillow, pyvips/libvips, unrar, and Tk runtime
components; it requires a glibc-based Linux x86-64 system with the usual X11
libraries. Alpine Linux and musl-based systems are unsupported.

#### macOS via Homebrew

```bash
brew tap JoshCLWren/tap
brew install --cask cdisplayagain
```

The cask installs the same notarization-free bundle the release workflow
publishes for arm64 and Intel. Because the app is ad-hoc signed rather than
notarized, and Homebrew quarantines every cask download, the cask clears the
quarantine flag on install. See the
[tap README](https://github.com/JoshCLWren/homebrew-tap) if you would rather
handle that yourself with `--no-quarantine`. Building from source avoids the
question entirely, since a locally built app is never quarantined.

#### macOS app bundle (Apple silicon or Intel)

Build a real `cdisplayagain.app` from a clone and install it, so `.cbz`/`.cbr`
files open on double-click:

```bash
uv sync
make install
```

`make install` builds `dist/cdisplayagain.app` (a self-contained bundle carrying
its own Python, Tk, Pillow, libvips, and unrar), copies it to `/Applications`,
registers it with Launch Services, and drops a CLI wrapper at
`~/.local/bin/cdisplayagain`. Use `make build` alone to produce the bundle
without installing it. Override the destination with `MACOS_APPDIR`
(for example `MACOS_APPDIR=~/Applications make install`) and the wrapper
location with `PREFIX`. Remove everything with `make uninstall-macos`.

Making it the *default* comic viewer needs one more piece, because macOS will
not let an app claim a file type on its own. If [`duti`](https://github.com/moretension/duti)
is installed (`brew install duti`), `make install` sets the default handler for
`.cbz`, `.cbr`, `.cbt`, and `.cba` automatically. Otherwise set it once by hand:
right-click a comic, choose Get Info, set Open With to cdisplayagain, and click
Change All.

`make package-macos` produces a distributable
`cdisplayagain-<version>-macos-<arch>.zip` containing the app, an `install.sh`,
and the license. The bundle is unsigned and un-notarized, so a copy downloaded
through a browser is quarantined; open it the first time with right-click >
Open, or clear the flag with
`xattr -dr com.apple.quarantine /Applications/cdisplayagain.app`.

Finder hands a double-clicked file to a macOS app through an `openDocument`
Apple Event rather than `argv`, so the viewer registers a
`::tk::mac::OpenDocument` handler at startup. That also means double-clicking a
second comic while the app is running loads it into the open window.

#### Source installation for contributors

Source installation is for development and requires Python and the project
Expand Down Expand Up @@ -105,12 +158,32 @@ You must install it separately:
brew install python-tk
```

Running from source under a `uv`-managed interpreter needs no extra step, but
it is worth knowing why. uv installs python-build-standalone, which keeps
Tcl/Tk under the interpreter prefix while Tk probes for `init.tcl` relative to
the virtualenv, so every `Tk()` call would fail with
`Can't find a usable init.tcl`. `tk_bootstrap.py` sets `TCL_LIBRARY` and
`TK_LIBRARY` at import time to fix this for every entry point: the app, the
tests, and IDE launches alike. It is a no-op for Homebrew and system pythons,
which resolve those paths themselves.

If you encounter issues with the UI not responding or appearing, try:
```bash
export TK_SILENCE_DEPRECATION=1
python cdisplayagain.py path/to/comic.cbz
```

**Running the tests on macOS.** There is no xvfb for Aqua, so `make pytest`
opens real Tk windows and takes over the display for the ~30 seconds the suite
runs. One test, `test_right_click_shows_context_menu`, fails natively because
Aqua's `tk_popup` needs a live event loop.

`make pytest-container` runs the suite headless in the Debian container, using a
dedicated Docker volume so the host `.venv` is never overwritten with Linux
binaries. It is the CI-parity path and works well on Linux, but be warned that
it has been observed hanging on macOS, where Docker bind-mounts the repository
across the VM boundary. Prefer the native run there, or let CI cover it.

### Usage

Open any `.cbz` or `.cbr` archive:
Expand Down Expand Up @@ -139,7 +212,12 @@ and use `Esc` or `q` to close the window.
- `make venv`: create the uv-managed virtualenv.
- `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.

- `make pytest-container`: run the suite headless in Docker on any platform.
- `make build`: build the PyInstaller bundle, plus `cdisplayagain.app` on macOS.
- `make install`: build and install for this machine (Linux bundle or macOS `.app`).
- `make uninstall-macos`: remove the installed macOS app and CLI wrapper.
- `make package-macos`: zip the macOS app for distribution.
- `make run FILE=path/to/comic.cbz`: launch the viewer.
- `make smoke FILE=path/to/comic.cbz`: print the manual checklist and launch.
- `make profile-cbz FILE=path/to/comic.cbz`: profile CBZ launch performance.
Expand Down
Binary file added cdisplayagain.icns
Binary file not shown.
Loading
Loading