diff --git a/.githooks/pre-commit b/.githooks/pre-commit index 8e038b9..3c62a06 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bbfb06d..20cdc05 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 @@ -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 diff --git a/Makefile b/Makefile index abc4e8d..9302211 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.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 @@ -6,6 +6,8 @@ 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}' @@ -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 @@ -66,7 +73,7 @@ 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 @@ -74,7 +81,21 @@ 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 \ @@ -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;' \ @@ -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 diff --git a/README.md b/README.md index 4edfbc7..036477f 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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--macos-.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 @@ -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: @@ -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). + - `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. diff --git a/cdisplayagain.icns b/cdisplayagain.icns new file mode 100644 index 0000000..ab8d736 Binary files /dev/null and b/cdisplayagain.icns differ diff --git a/cdisplayagain.py b/cdisplayagain.py index 911d4b4..a755ea2 100644 --- a/cdisplayagain.py +++ b/cdisplayagain.py @@ -14,11 +14,16 @@ import time import weakref from collections import OrderedDict -from collections.abc import Callable +from collections.abc import Callable, Sequence from pathlib import Path from typing import cast +from urllib.parse import urlparse +from urllib.request import url2pathname try: + from tk_bootstrap import configure_tk_library + + configure_tk_library() import tkinter as tk from tkinter import filedialog, messagebox except ImportError as e: # pragma: no cover @@ -84,24 +89,64 @@ def _as_wm(obj: tk.Misc) -> tk.Wm: ("All files", "*.*"), ] -LOG_ROOT = Path(os.environ.get("CDISPLAYAGAIN_LOG_DIR", "logs")).expanduser() + +def _user_log_root() -> Path: + """Return the per-user log location for the current platform.""" + if sys.platform == "darwin": + return Path.home() / "Library" / "Logs" / "cdisplayagain" + state_home = os.environ.get("XDG_STATE_HOME") or str(Path.home() / ".local" / "state") + return Path(state_home).expanduser() / "cdisplayagain" / "logs" + + +def _default_log_root() -> Path: + """Pick a log root that survives being launched from a read-only directory. + + Finder and desktop launchers start a packaged app with the working directory + set to "/", so the relative path a source checkout uses is unwritable there. + """ + override = os.environ.get("CDISPLAYAGAIN_LOG_DIR") + if override: + return Path(override).expanduser() + if getattr(sys, "frozen", False): + return _user_log_root() + return Path("logs") + + +LOG_ROOT = _default_log_root() LOG_PATH: Path | None = None +LOG_FORMAT = "%(asctime)s %(levelname)s %(message)s" def _init_logging() -> None: global LOG_PATH timestamp = time.strftime("%Y%m%d-%H%M%S") - log_dir = LOG_ROOT / timestamp - log_dir.mkdir(parents=True, exist_ok=True) - LOG_PATH = log_dir / "cdisplayagain.log" + LOG_PATH = None + for root in dict.fromkeys([LOG_ROOT, _user_log_root()]): + log_dir = root / timestamp + try: + log_dir.mkdir(parents=True, exist_ok=True) + except OSError as e: + print(f"Cannot write logs to {log_dir}: {e}", file=sys.stderr) + continue + LOG_PATH = log_dir / "cdisplayagain.log" + break + + # Failing to open a log file must never stop the viewer from starting. + if LOG_PATH is None: + logging.basicConfig(level=logging.INFO, format=LOG_FORMAT) + logging.warning("File logging disabled; no writable log directory found.") + return + logging.basicConfig( filename=str(LOG_PATH), filemode="a", level=logging.INFO, - format="%(asctime)s %(levelname)s %(message)s", + format=LOG_FORMAT, ) logging.info("Logging initialized at %s", LOG_PATH) - logging.info("cdisplayagain version=%s build=%s executable=%s", APP_VERSION, BUILD_ID, sys.executable) + logging.info( + "cdisplayagain version=%s build=%s executable=%s", APP_VERSION, BUILD_ID, sys.executable + ) launch_ns = os.environ.get("CDISPLAYAGAIN_LAUNCH_NS") if launch_ns: try: @@ -408,9 +453,7 @@ def _run(self): and height is not None ): with self._pending_requests_lock: - self._pending_requests.discard( - (source_generation, index, width, height) - ) + self._pending_requests.discard((source_generation, index, width, height)) class ComicViewer(tk.Frame): @@ -562,7 +605,9 @@ def _drain_worker_results(self) -> bool: break had_items = True if source_generation != self._source_generation: - logging.info("Discarding result from previous comic: generation=%d", source_generation) + logging.info( + "Discarding result from previous comic: generation=%d", source_generation + ) continue self._update_from_cache(index, img) return had_items @@ -1682,6 +1727,64 @@ def set_mouse_binding(self, button: str | None = None, action: str | None = None logging.info("Set mouse binding: %s -> %s", button, action) +_PENDING_OPEN_DOCUMENTS: list[str] = [] +OPEN_DOCUMENT_TIMEOUT_MS = 750 + + +def comic_path_from_argument(raw: str) -> Path: + """Normalize a launcher-supplied argument into a filesystem path.""" + if raw.startswith("file://"): + raw = url2pathname(urlparse(raw).path) + return Path(raw).expanduser() + + +def _record_open_document(*paths: str) -> None: + """Queue paths delivered by the macOS openDocument Apple Event.""" + _PENDING_OPEN_DOCUMENTS.extend(paths) + + +def register_open_document_handler(root: tk.Tk, callback: Callable | None = None) -> bool: + """Register the Apple Event that Finder uses to hand files to a macOS .app bundle. + + Double-clicking a document on macOS does not put its path in argv; it sends + an openDocument event once the Tk event loop is running. Returns False on + platforms where the command does not exist, so callers can fall back to argv. + """ + if sys.platform != "darwin": + return False + _PENDING_OPEN_DOCUMENTS.clear() + try: + root.createcommand("::tk::mac::OpenDocument", callback or _record_open_document) + except tk.TclError: + logging.info("openDocument handler unavailable; falling back to argv") + return False + return True + + +def await_open_document(root: tk.Tk, timeout_ms: int = OPEN_DOCUMENT_TIMEOUT_MS) -> str | None: + """Pump the event loop briefly so a launch-time openDocument event can land.""" + deadline = time.monotonic() + timeout_ms / 1000 + while not _PENDING_OPEN_DOCUMENTS and time.monotonic() < deadline: + try: + root.update() + except tk.TclError: # pragma: no cover - root torn down mid-wait + break + time.sleep(0.02) + return _PENDING_OPEN_DOCUMENTS.pop(0) if _PENDING_OPEN_DOCUMENTS else None + + +def _open_documents_in_viewer(app: ComicViewer, paths: Sequence[str]) -> None: + """Load a document Finder sent to the already-running viewer.""" + if not paths: + return + path = comic_path_from_argument(paths[0]) + if not path.exists(): + logging.warning("openDocument path does not exist: %s", path) + return + app._open_comic(path) + app._request_focus() + + def main(): """Parse arguments and launch the comic viewer.""" _init_logging() @@ -1709,21 +1812,25 @@ def main(): ) sys.exit(1) + open_document_available = register_open_document_handler(root) + path: Path | None = None if args.comic: - raw = args.comic - if raw.startswith("file://"): - raw = raw[7] - path = Path(raw).expanduser() + path = comic_path_from_argument(args.comic) else: - # Use the existing root for the dialog - selection = filedialog.askopenfilename( - parent=root, title="Open Comic", filetypes=FILE_DIALOG_TYPES - ) - if not selection: - root.destroy() - return - path = Path(selection) + if open_document_available: + document = await_open_document(root) + if document: + path = comic_path_from_argument(document) + if path is None: + # Use the existing root for the dialog + selection = filedialog.askopenfilename( + parent=root, title="Open Comic", filetypes=FILE_DIALOG_TYPES + ) + if not selection: + root.destroy() + return + path = Path(selection) if not path.exists(): print(f"File not found: {path}", file=sys.stderr) @@ -1741,6 +1848,9 @@ def main(): app._set_cursor_hidden(True) app._request_focus() + if open_document_available: + register_open_document_handler(root, lambda *paths: _open_documents_in_viewer(app, paths)) + root.deiconify() root.mainloop() diff --git a/cdisplayagain.spec b/cdisplayagain.spec index 1b16cf6..11e24ed 100644 --- a/cdisplayagain.spec +++ b/cdisplayagain.spec @@ -1,7 +1,18 @@ from pathlib import Path +import sys import sysconfig +import tomllib project_root = Path.cwd() +is_macos = sys.platform == "darwin" + +with (project_root / "pyproject.toml").open("rb") as pyproject: + version = str(tomllib.load(pyproject)["project"]["version"]) + +icon_name = "cdisplayagain.icns" +icon_path = project_root / icon_name +if not (is_macos and icon_path.exists()): + icon_path = project_root / "cdisplayagain.png" python_lib_dirs = { Path(path) @@ -19,7 +30,7 @@ a = Analysis( pathex=[str(project_root)], binaries=tk_binaries, datas=[(str(project_root / "cdisplayagain.png"), ".")], - hiddenimports=["PIL._tkinter_finder", "build_info"], + hiddenimports=["PIL._tkinter_finder", "build_info", "tk_bootstrap"], hookspath=[], hooksconfig={}, runtime_hooks=[], @@ -36,8 +47,10 @@ exe = EXE( bootloader_ignore_signals=False, strip=False, upx=False, - console=True, - icon=str(project_root / "cdisplayagain.png"), + # A console executable on macOS makes Finder open a Terminal window alongside + # the viewer; Linux keeps the console for CLI diagnostics. + console=not is_macos, + icon=str(icon_path), exclude_binaries=True, ) coll = COLLECT( @@ -48,3 +61,66 @@ coll = COLLECT( upx=False, name="cdisplayagain", ) + +if is_macos: + comic_extensions = ["cbz", "cbr", "cbt", "cba"] + image_extensions = ["jpg", "jpeg", "png", "gif", "webp", "bmp", "tif", "tiff"] + app = BUNDLE( + coll, + name="cdisplayagain.app", + icon=str(icon_path), + bundle_identifier="io.github.joshclwren.cdisplayagain", + version=version, + info_plist={ + "CFBundleName": "cdisplayagain", + "CFBundleDisplayName": "cdisplayagain", + "CFBundleShortVersionString": version, + "CFBundleVersion": version, + "NSHighResolutionCapable": True, + "LSMinimumSystemVersion": "11.0", + "LSApplicationCategoryType": "public.app-category.graphics-design", + "CFBundleDocumentTypes": [ + { + "CFBundleTypeName": "Comic Archive", + "CFBundleTypeRole": "Viewer", + # Owner rank tells Launch Services this app claims the type + # outright, so Finder prefers it over apps that merely open zips. + "LSHandlerRank": "Owner", + "CFBundleTypeExtensions": comic_extensions, + "CFBundleTypeIconFile": icon_name, + "LSItemContentTypes": [ + "public.cbz-archive", + "public.cbr-archive", + "com.apple.comic-book-archive", + ], + }, + { + "CFBundleTypeName": "Image", + "CFBundleTypeRole": "Viewer", + "LSHandlerRank": "Alternate", + "CFBundleTypeExtensions": image_extensions, + "LSItemContentTypes": ["public.image"], + }, + ], + "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"]}, + }, + ], + }, + ) + + # PyInstaller does not emit the classic package marker, and Finder falls + # back to a generic icon for bundles without it even when Info.plist + # already carries CFBundlePackageType. + pkg_info = Path(DISTPATH) / "cdisplayagain.app" / "Contents" / "PkgInfo" + pkg_info.write_text("APPL????", encoding="ascii") diff --git a/packaging/linux/cdisplayagain.xml b/packaging/linux/cdisplayagain.xml new file mode 100644 index 0000000..4b42ea0 --- /dev/null +++ b/packaging/linux/cdisplayagain.xml @@ -0,0 +1,11 @@ + + + + Comic Book ZIP archive + + + + Comic Book RAR archive + + + diff --git a/pyproject.toml b/pyproject.toml index 893d37f..6e56147 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,6 +14,9 @@ dependencies = [ addopts = ["--cov=cdisplayagain", "--cov=archives", "--cov-context=test", "--cov-fail-under=96", "--no-cov-on-fail"] pythonpath = ["."] +[tool.uv] +python-preference = "managed" + [tool.pyright] include = ["."] stubPath = "stubs" diff --git a/scripts/install-macos.sh b/scripts/install-macos.sh new file mode 100755 index 0000000..4ee1a37 --- /dev/null +++ b/scripts/install-macos.sh @@ -0,0 +1,168 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + echo "Usage: $0 [--uninstall]" +} + +if [[ $# -gt 1 || ( $# -eq 1 && "$1" != "--uninstall" ) ]]; then + usage >&2 + exit 2 +fi +if [[ "$(uname)" != "Darwin" ]]; then + echo "ERROR: this installer is macOS only; use scripts/install-linux.sh on Linux." >&2 + exit 1 +fi + +app_name="cdisplayagain.app" +bundle_id="io.github.joshclwren.cdisplayagain" +app_dir=${MACOS_APPDIR:-/Applications} +installed_app="$app_dir/$app_name" +prefix=${PREFIX:-"$HOME/.local"} +bin_path="$prefix/bin/cdisplayagain" +extensions=(cbz cbr cbt cba) +lsregister=/System/Library/Frameworks/CoreServices.framework/Versions/A/Frameworks/LaunchServices.framework/Versions/A/Support/lsregister +source_root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd) + +# With no explicit destination, sweep both standard locations so an earlier +# install cannot leave a second copy behind for Finder to prefer. An explicit +# MACOS_APPDIR stays scoped to itself: callers pointing this at a scratch +# directory must never have their real install removed underneath them. +if [[ -n "${MACOS_APPDIR:-}" ]]; then + install_locations=("$installed_app") +else + install_locations=("/Applications/$app_name" "$HOME/Applications/$app_name") +fi + +register_with_launch_services() { + if [[ -x "$lsregister" ]]; then + "$lsregister" -f "$installed_app" >/dev/null 2>&1 || true + fi +} + +refresh_finder_icons() { + # Finder caches an app's icon against the bundle path and its modification + # time, so installing over a previous copy keeps showing the stale icon + # (a generic one, if the earlier build predated Contents/PkgInfo). Bumping + # the timestamp and relaunching Finder is what makes the new icon appear. + touch -- "$installed_app" 2>/dev/null || true + local finder_user=${SUDO_USER:-$(id -un)} + if killall -u "$finder_user" Finder >/dev/null 2>&1; then + echo "Relaunched Finder so the new icon is picked up." + fi +} + +remove_existing_installs() { + local removed=0 location + for location in "${install_locations[@]}"; do + [[ -d "$location" ]] || continue + if [[ -x "$lsregister" ]]; then + "$lsregister" -u "$location" >/dev/null 2>&1 || true + fi + if ! rm -rf -- "$location" 2>/dev/null; then + # Only the destination has to be removable; a leftover elsewhere + # (say /Applications when installing to ~/Applications without + # admin rights) is worth a warning, not a failed install. + if [[ "$location" == "$installed_app" ]]; then + echo "ERROR: could not remove the existing install at $location." >&2 + echo "Remove it manually, or re-run with sudo." >&2 + exit 1 + fi + echo "WARNING: could not remove an older install at $location" >&2 + continue + fi + echo "Removed previous install at $location" + removed=1 + done + rm -f -- "$bin_path" + return $((removed == 0)) +} + +warn_about_stray_copies() { + command -v mdfind >/dev/null 2>&1 || return 0 + local stray + while IFS= read -r stray; do + [[ -n "$stray" ]] || continue + # The build output and any release archive are expected; only flag copies + # somewhere Finder might launch instead of the one just installed. + [[ "$stray" == "$installed_app" || "$stray" == "$source_root"/* ]] && continue + echo "Note: another copy is registered at $stray" + echo " Delete it if Finder opens the wrong one." + done < <(mdfind "kMDItemCFBundleIdentifier == '$bundle_id'" 2>/dev/null) +} + +if [[ $# -eq 1 ]]; then + if remove_existing_installs; then + echo "Uninstalled cdisplayagain." + else + echo "Nothing to uninstall." + fi + exit 0 +fi + +# Support both a repo checkout (dist/) and an extracted release archive. +for candidate in "$source_root/dist/$app_name" "$source_root/$app_name"; do + if [[ -d "$candidate" ]]; then + built_app=$candidate + break + fi +done +if [[ -z "${built_app:-}" ]]; then + echo "ERROR: no $app_name found. Run 'make build' first." >&2 + exit 1 +fi +if [[ ! -x "$built_app/Contents/MacOS/cdisplayagain" ]]; then + echo "ERROR: $built_app is incomplete; rebuild with 'make build'." >&2 + exit 1 +fi + +mkdir -p -- "$app_dir" 2>/dev/null || true +if [[ ! -w "$app_dir" ]]; then + echo "ERROR: $app_dir is not writable." >&2 + echo "Retry with 'sudo $0' or install elsewhere: MACOS_APPDIR=~/Applications $0" >&2 + exit 1 +fi + +remove_existing_installs || true + +mkdir -p -- "$app_dir" "$prefix/bin" +cp -a -- "$built_app" "$installed_app" + +# 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 + +cat > "$bin_path" </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[*]}" +else + echo "Note: 'duti' is not installed, so the default handler was not forced." + echo " Install it with 'brew install duti' and re-run, or set it manually:" + echo " right-click a .cbz > Get Info > Open With > cdisplayagain > Change All." +fi + +warn_about_stray_copies + +echo "Installed $installed_app" +echo "Run '$bin_path --version' to verify it." +echo "Uninstall with '$0 --uninstall'." diff --git a/scripts/make-icns.sh b/scripts/make-icns.sh new file mode 100755 index 0000000..3b1e298 --- /dev/null +++ b/scripts/make-icns.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# Regenerate cdisplayagain.icns from cdisplayagain.png. +# +# The .icns is committed so cloners and CI build an identical bundle without +# needing macOS tooling; only re-run this when the source PNG changes. +set -euo pipefail + +if [[ "$(uname)" != "Darwin" ]]; then + echo "ERROR: iconutil is macOS only." >&2 + exit 1 +fi + +root_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd) +source_png="$root_dir/cdisplayagain.png" +output_icns="$root_dir/cdisplayagain.icns" +iconset=$(mktemp -d)/cdisplayagain.iconset +trap 'rm -rf -- "$(dirname -- "$iconset")"' EXIT +mkdir -p -- "$iconset" + +for size in 16 32 128 256 512; do + sips -z "$size" "$size" "$source_png" --out "$iconset/icon_${size}x${size}.png" >/dev/null + retina=$((size * 2)) + sips -z "$retina" "$retina" "$source_png" \ + --out "$iconset/icon_${size}x${size}@2x.png" >/dev/null +done + +iconutil --convert icns --output "$output_icns" "$iconset" +echo "Wrote $output_icns" diff --git a/scripts/package-macos.sh b/scripts/package-macos.sh new file mode 100755 index 0000000..2213bca --- /dev/null +++ b/scripts/package-macos.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +set -euo pipefail + +root_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd) +app_bundle="$root_dir/dist/cdisplayagain.app" + +if [[ "$(uname)" != "Darwin" ]]; then + echo "ERROR: macOS packaging must run on macOS." >&2 + exit 1 +fi +if [[ ! -x "$app_bundle/Contents/MacOS/cdisplayagain" ]]; then + echo "ERROR: expected a completed app bundle at $app_bundle" >&2 + echo "Run 'make build' first." >&2 + exit 1 +fi +if [[ -n "$(git -C "$root_dir" status --porcelain --untracked-files=all)" ]]; then + echo "ERROR: refusing to package a dirty worktree." >&2 + git -C "$root_dir" status --short >&2 + exit 1 +fi + +version=$(uv run --no-project python - "$root_dir/pyproject.toml" <<'PY' +import sys +import tomllib + +with open(sys.argv[1], "rb") as file: + print(tomllib.load(file)["project"]["version"]) +PY +) +arch=$(uname -m) +asset_name="cdisplayagain-${version}-macos-${arch}.zip" +staging_root=$(mktemp -d) +trap 'rm -rf "$staging_root"' EXIT +package_root="$staging_root/cdisplayagain-${version}-macos-${arch}" +mkdir -p "$package_root" + +# 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" +cat > "$package_root/README-install.txt" < Open, or clear the flag with: + xattr -dr com.apple.quarantine /Applications/cdisplayagain.app + +This archive bundles its own Python, Tk, Pillow, libvips, and unrar runtimes. +EOF + +output_path="$root_dir/$asset_name" +rm -f "$output_path" +ditto -c -k --keepParent "$package_root" "$output_path" +printf '%s\n' "$output_path" diff --git a/scripts/update-cask.sh b/scripts/update-cask.sh new file mode 100755 index 0000000..36d8daa --- /dev/null +++ b/scripts/update-cask.sh @@ -0,0 +1,83 @@ +#!/usr/bin/env bash +# Generate the Homebrew cask for a published release. +# +# Usage: scripts/update-cask.sh [output-path] +# +# Reads the checksums the release workflow published, so the cask can never +# disagree with the assets people actually download. +set -euo pipefail + +if [[ $# -lt 1 || $# -gt 2 ]]; then + echo "Usage: $0 [output-path]" >&2 + exit 2 +fi + +tag=$1 +version=${tag#v} +output=${2:-Casks/cdisplayagain.rb} +repo=${CDISPLAYAGAIN_REPO:-JoshCLWren/cdisplayagain} + +if ! command -v gh >/dev/null 2>&1; then + echo "ERROR: the gh CLI is required." >&2 + exit 1 +fi + +checksums=$(gh release view "$tag" --repo "$repo" --json assets \ + --jq '.assets[] | select(.name == "SHA256SUMS") | .url') +if [[ -z "$checksums" ]]; then + echo "ERROR: release $tag has no SHA256SUMS asset." >&2 + exit 1 +fi + +sums=$(gh release download "$tag" --repo "$repo" --pattern SHA256SUMS --output - 2>/dev/null) + +lookup() { + local suffix=$1 line + line=$(grep -E "cdisplayagain-${version}-macos-${suffix}\.zip\$" <<<"$sums" || true) + if [[ -z "$line" ]]; then + echo "ERROR: no macOS ${suffix} asset in $tag checksums." >&2 + exit 1 + fi + awk '{print $1}' <<<"$line" +} + +arm_sha=$(lookup arm64) +intel_sha=$(lookup x86_64) + +mkdir -p -- "$(dirname -- "$output")" +cat > "$output" <= :big_sur" + + # package-macos.sh zips a versioned parent directory holding the bundle + # alongside install.sh and the license, so the app is one level down. + app "cdisplayagain-#{version}-macos-#{arch}/cdisplayagain.app" + + # The app is ad-hoc signed rather than notarized, so Gatekeeper blocks the + # quarantined copy Homebrew downloads until the flag is cleared. + postflight do + system_command "/usr/bin/xattr", + args: ["-dr", "com.apple.quarantine", "#{appdir}/cdisplayagain.app"], + sudo: false + end + + zap trash: [ + "~/Library/Logs/cdisplayagain", + "~/Library/Saved Application State/io.github.joshclwren.cdisplayagain.savedState", + ] +end +EOF + +echo "Wrote $output for $tag" diff --git a/tests/test_log_location.py b/tests/test_log_location.py new file mode 100644 index 0000000..73f4825 --- /dev/null +++ b/tests/test_log_location.py @@ -0,0 +1,97 @@ +"""Tests for choosing a writable log directory across launch contexts.""" + +import sys +from pathlib import Path + +import pytest + +import cdisplayagain + + +@pytest.fixture(autouse=True) +def restore_log_state(): + """Keep log-root mutations from leaking into other tests.""" + original_root = cdisplayagain.LOG_ROOT + original_path = cdisplayagain.LOG_PATH + yield + cdisplayagain.LOG_ROOT = original_root + cdisplayagain.LOG_PATH = original_path + + +def test_default_log_root_prefers_env_override(monkeypatch, tmp_path): + """An explicit CDISPLAYAGAIN_LOG_DIR wins over every platform default.""" + monkeypatch.setenv("CDISPLAYAGAIN_LOG_DIR", str(tmp_path / "custom")) + + assert cdisplayagain._default_log_root() == tmp_path / "custom" + + +def test_default_log_root_is_relative_for_source_runs(monkeypatch): + """A source checkout keeps logging beside the working directory.""" + monkeypatch.delenv("CDISPLAYAGAIN_LOG_DIR", raising=False) + monkeypatch.delattr(sys, "frozen", raising=False) + + assert cdisplayagain._default_log_root() == Path("logs") + + +def test_default_log_root_is_user_scoped_when_frozen(monkeypatch): + """A packaged app logs under the user's directory, not the launch directory. + + Finder starts a bundled app in "/", so a relative log path aborts startup. + """ + monkeypatch.delenv("CDISPLAYAGAIN_LOG_DIR", raising=False) + monkeypatch.setattr(sys, "frozen", True, raising=False) + + root = cdisplayagain._default_log_root() + + assert root.is_absolute() + assert root == cdisplayagain._user_log_root() + + +def test_user_log_root_uses_library_logs_on_macos(monkeypatch): + """Keep application logs in ~/Library/Logs on macOS.""" + monkeypatch.setattr(sys, "platform", "darwin") + + assert cdisplayagain._user_log_root() == Path.home() / "Library" / "Logs" / "cdisplayagain" + + +def test_user_log_root_honors_xdg_state_home(monkeypatch, tmp_path): + """Linux follows XDG_STATE_HOME when it is set.""" + monkeypatch.setattr(sys, "platform", "linux") + monkeypatch.setenv("XDG_STATE_HOME", str(tmp_path)) + + assert cdisplayagain._user_log_root() == tmp_path / "cdisplayagain" / "logs" + + +def test_init_logging_writes_under_configured_root(monkeypatch, tmp_path): + """The configured root is used when it is writable.""" + monkeypatch.setattr(cdisplayagain, "LOG_ROOT", tmp_path) + + cdisplayagain._init_logging() + + assert cdisplayagain.LOG_PATH is not None + assert tmp_path in cdisplayagain.LOG_PATH.parents + + +def test_init_logging_falls_back_when_root_is_unwritable(monkeypatch, tmp_path): + """An unwritable root falls back to the per-user location instead of crashing.""" + fallback = tmp_path / "fallback" + monkeypatch.setattr(cdisplayagain, "LOG_ROOT", Path("/proc/nonexistent/logs")) + monkeypatch.setattr(cdisplayagain, "_user_log_root", lambda: fallback) + + cdisplayagain._init_logging() + + assert cdisplayagain.LOG_PATH is not None + assert fallback in cdisplayagain.LOG_PATH.parents + + +def test_init_logging_survives_with_no_writable_directory(monkeypatch): + """No writable directory anywhere disables file logging rather than aborting.""" + + def _refuse(*args, **kwargs): + raise OSError("read-only file system") + + monkeypatch.setattr(Path, "mkdir", _refuse) + + cdisplayagain._init_logging() + + assert cdisplayagain.LOG_PATH is None diff --git a/tests/test_macos_open_document.py b/tests/test_macos_open_document.py new file mode 100644 index 0000000..c1c4bc9 --- /dev/null +++ b/tests/test_macos_open_document.py @@ -0,0 +1,181 @@ +"""Tests for the macOS openDocument Apple Event path used by Finder launches.""" + +import sys +import tkinter as tk +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +import cdisplayagain + + +@pytest.fixture(autouse=True) +def clear_pending_documents(): + """Keep the module-level openDocument queue from leaking between tests.""" + cdisplayagain._PENDING_OPEN_DOCUMENTS.clear() + yield + cdisplayagain._PENDING_OPEN_DOCUMENTS.clear() + + +def test_comic_path_from_argument_plain_path(): + """A plain filesystem path passes through unchanged.""" + assert cdisplayagain.comic_path_from_argument("/comics/issue.cbz") == Path("/comics/issue.cbz") + + +def test_comic_path_from_argument_expands_user(): + """A tilde path expands to the user's home directory.""" + result = cdisplayagain.comic_path_from_argument("~/issue.cbz") + assert result == Path.home() / "issue.cbz" + assert "~" not in str(result) + + +def test_comic_path_from_argument_file_url(): + """A file:// URL becomes a real path rather than a single stray character.""" + assert cdisplayagain.comic_path_from_argument("file:///comics/issue.cbz") == Path( + "/comics/issue.cbz" + ) + + +def test_comic_path_from_argument_file_url_with_escaped_space(): + """Percent-encoded characters in a file:// URL are decoded.""" + result = cdisplayagain.comic_path_from_argument("file:///comics/my%20issue.cbz") + assert result == Path("/comics/my issue.cbz") + + +def test_register_open_document_handler_skips_non_darwin(monkeypatch): + """Non-macOS platforms report no handler so callers fall back to argv.""" + monkeypatch.setattr(sys, "platform", "linux") + root = MagicMock() + + assert cdisplayagain.register_open_document_handler(root) is False + root.createcommand.assert_not_called() + + +def test_register_open_document_handler_registers_on_darwin(monkeypatch): + """Register the Tk command Finder delivers documents through on macOS.""" + monkeypatch.setattr(sys, "platform", "darwin") + root = MagicMock() + + assert cdisplayagain.register_open_document_handler(root) is True + command_name, callback = root.createcommand.call_args[0] + assert command_name == "::tk::mac::OpenDocument" + assert callback is cdisplayagain._record_open_document + + +def test_register_open_document_handler_accepts_custom_callback(monkeypatch): + """A caller-supplied callback replaces the default queueing handler.""" + monkeypatch.setattr(sys, "platform", "darwin") + root = MagicMock() + callback = MagicMock() + + cdisplayagain.register_open_document_handler(root, callback) + + assert root.createcommand.call_args[0][1] is callback + + +def test_register_open_document_handler_survives_tcl_error(monkeypatch): + """A Tk build without the mac command degrades to the argv path.""" + monkeypatch.setattr(sys, "platform", "darwin") + root = MagicMock() + root.createcommand.side_effect = tk.TclError("no such command") + + assert cdisplayagain.register_open_document_handler(root) is False + + +def test_await_open_document_returns_queued_path(): + """A document delivered while pumping the event loop is returned.""" + root = MagicMock() + root.update.side_effect = lambda: cdisplayagain._record_open_document("/comics/issue.cbz") + + assert cdisplayagain.await_open_document(root, timeout_ms=500) == "/comics/issue.cbz" + + +def test_await_open_document_returns_none_on_timeout(): + """No Apple Event within the window means fall through to the file dialog.""" + root = MagicMock() + + assert cdisplayagain.await_open_document(root, timeout_ms=1) is None + + +def test_await_open_document_consumes_only_first_path(): + """Only the first queued document is consumed by the launch-time wait.""" + cdisplayagain._record_open_document("/comics/one.cbz", "/comics/two.cbz") + root = MagicMock() + + assert cdisplayagain.await_open_document(root, timeout_ms=1) == "/comics/one.cbz" + assert cdisplayagain._PENDING_OPEN_DOCUMENTS == ["/comics/two.cbz"] + + +def test_open_documents_in_viewer_loads_path(tmp_path): + """A document sent to a running viewer is opened and focused.""" + comic = tmp_path / "issue.cbz" + comic.write_bytes(b"") + app = MagicMock() + + cdisplayagain._open_documents_in_viewer(app, [str(comic)]) + + app._open_comic.assert_called_once_with(comic) + app._request_focus.assert_called_once() + + +def test_open_documents_in_viewer_ignores_empty_event(): + """An openDocument event with no paths is a no-op.""" + app = MagicMock() + + cdisplayagain._open_documents_in_viewer(app, []) + + app._open_comic.assert_not_called() + + +def test_open_documents_in_viewer_ignores_missing_file(tmp_path): + """A path that no longer exists does not reach the loader.""" + app = MagicMock() + + cdisplayagain._open_documents_in_viewer(app, [str(tmp_path / "gone.cbz")]) + + app._open_comic.assert_not_called() + + +def test_main_prefers_open_document_over_file_dialog(monkeypatch, tmp_path): + """On macOS with no argv path, a Finder document wins over the picker.""" + from PIL import Image + + comic = tmp_path / "issue.png" + Image.new("RGB", (10, 10), color="red").save(comic) + monkeypatch.setattr(sys, "platform", "darwin") + + 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() + + mock_dialog.assert_not_called() + assert mock_viewer.call_args[0][1] == comic + + +def test_main_falls_back_to_dialog_without_open_document(monkeypatch, tmp_path): + """When no Apple Event arrives, macOS still shows the Open Comic dialog.""" + from PIL import Image + + comic = tmp_path / "issue.png" + Image.new("RGB", (10, 10), color="red").save(comic) + monkeypatch.setattr(sys, "platform", "darwin") + + with ( + patch("tkinter.Tk") as mock_tk, + patch.object(cdisplayagain, "ComicViewer") as mock_viewer, + patch.object(cdisplayagain, "await_open_document", return_value=None), + patch("tkinter.filedialog.askopenfilename", return_value=str(comic)) as mock_dialog, + patch("sys.argv", ["cdisplayagain.py"]), + ): + mock_tk.return_value = MagicMock() + cdisplayagain.main() + + mock_dialog.assert_called_once() + assert mock_viewer.call_args[0][1] == comic diff --git a/tests/test_parity_tasks.py b/tests/test_parity_tasks.py index 377cec1..5e10302 100644 --- a/tests/test_parity_tasks.py +++ b/tests/test_parity_tasks.py @@ -96,6 +96,9 @@ def deiconify(self): def mainloop(self): calls["mainloop"] = True + def createcommand(self, name, callback): + calls["createcommand"] = name + class FakeViewer: def __init__(self, master, comic_path): self.master = master @@ -140,6 +143,9 @@ def deiconify(self): def mainloop(self): pass + def createcommand(self, name, callback): + pass + class FakeViewer: def __init__(self, master, comic_path): self.master = master diff --git a/tk_bootstrap.py b/tk_bootstrap.py new file mode 100644 index 0000000..6d789bd --- /dev/null +++ b/tk_bootstrap.py @@ -0,0 +1,29 @@ +"""Configure Tcl/Tk paths for relocatable Python distributions. + +uv installs python-build-standalone, which keeps Tcl/Tk under the interpreter +prefix. Tk probes for init.tcl relative to the virtualenv, finds nothing, and +every Tk() call fails with "Can't find a usable init.tcl". Pointing the two +library variables at the interpreter's own copies fixes source runs, tests, and +IDE launches alike. Homebrew and system pythons resolve this themselves, so the +directory checks keep this a no-op for them. +""" + +from __future__ import annotations + +import os +import sys +from pathlib import Path + + +def configure_tk_library() -> None: + """Point Python at bundled Tcl/Tk libraries when they are available.""" + python_prefix = Path(sys.base_prefix) + tcl_library = python_prefix / "lib" / "tcl8.6" + tk_library = python_prefix / "lib" / "tk8.6" + if tcl_library.is_dir(): + os.environ.setdefault("TCL_LIBRARY", str(tcl_library)) + if tk_library.is_dir(): + os.environ.setdefault("TK_LIBRARY", str(tk_library)) + + +configure_tk_library()