diff --git a/Makefile b/Makefile index f6624d3..c157971 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: help lint pytest sync venv run smoke clean-build build build-onedir install install-bin install-desktop mime-query redo ci-test-debian ci-test-local ci-build-image githook install-githook +.PHONY: help lint pytest sync venv run smoke clean-build build build-onedir macos-app macos-install linux-install install install-bin install-desktop mime-query redo ci-test-debian ci-test-local ci-build-image githook install-githook # Configuration PREFIX ?= /usr/local @@ -58,7 +58,7 @@ smoke: ## Run manual smoke test checklist uv run --active python cdisplayagain.py "$(FILE)" clean-build: ## Clean build artifacts - rm -rf build dist *.spec __pycache__ .pytest_cache + rm -rf build dist __pycache__ .pytest_cache build: clean-build ## Build single-file executable (slower startup) uv run --active pyinstaller --onefile --name cdisplayagain cdisplayagain.py @@ -70,6 +70,37 @@ build-onedir: ## Build onedir bundle (faster startup than onefile) --name cdisplayagain \ cdisplayagain.py +macos-app: clean-build ## Build a macOS app bundle associated with CBZ/CBR files + @if [ "$$(uname -s)" != "Darwin" ]; then echo "macos-app requires macOS"; exit 1; fi + mkdir -p build/cdisplayagain.iconset + sips -z 16 16 cdisplayagain.png --out build/cdisplayagain.iconset/icon_16x16.png >/dev/null + sips -z 32 32 cdisplayagain.png --out build/cdisplayagain.iconset/icon_16x16@2x.png >/dev/null + sips -z 32 32 cdisplayagain.png --out build/cdisplayagain.iconset/icon_32x32.png >/dev/null + sips -z 64 64 cdisplayagain.png --out build/cdisplayagain.iconset/icon_32x32@2x.png >/dev/null + sips -z 128 128 cdisplayagain.png --out build/cdisplayagain.iconset/icon_128x128.png >/dev/null + sips -z 256 256 cdisplayagain.png --out build/cdisplayagain.iconset/icon_128x128@2x.png >/dev/null + sips -z 256 256 cdisplayagain.png --out build/cdisplayagain.iconset/icon_256x256.png >/dev/null + sips -z 512 512 cdisplayagain.png --out build/cdisplayagain.iconset/icon_256x256@2x.png >/dev/null + sips -z 512 512 cdisplayagain.png --out build/cdisplayagain.iconset/icon_512x512.png >/dev/null + sips -z 1024 1024 cdisplayagain.png --out build/cdisplayagain.iconset/icon_512x512@2x.png >/dev/null + iconutil -c icns build/cdisplayagain.iconset -o build/cdisplayagain.icns + uv run --active pyinstaller --noconfirm cdisplayagain-macos.spec + +macos-install: macos-app ## Install the macOS app and register it with Launch Services + mkdir -p "$(HOME)/Applications" + ditto dist/cdisplayagain.app "$(HOME)/Applications/cdisplayagain.app" + /System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister \ + -f "$(HOME)/Applications/cdisplayagain.app" + @if command -v duti >/dev/null 2>&1; then \ + duti -s com.cdisplayagain.viewer .cbz all; \ + duti -s com.cdisplayagain.viewer .cbr all; \ + else \ + echo "Install duti to make cdisplayagain the default CBZ/CBR opener: brew install duti"; \ + fi + @echo "Installed $(HOME)/Applications/cdisplayagain.app" + +linux-install: build-onedir install ## Build and install the Linux CBZ/CBR file association + install: install-bin install-desktop ## Install everything @@ -94,11 +125,17 @@ install-bin: ## Install binary to system install-desktop: ## Install desktop entry mkdir -p $(HOME)/.local/share/applications + mkdir -p $(HOME)/.local/share/mime/packages + install -m 0644 packaging/linux/cdisplayagain.xml $(HOME)/.local/share/mime/packages/cdisplayagain.xml + update-mime-database $(HOME)/.local/share/mime + mkdir -p $(HOME)/.local/share/icons/hicolor/1024x1024/apps + install -m 0644 cdisplayagain.png $(HOME)/.local/share/icons/hicolor/1024x1024/apps/cdisplayagain.png printf '%s\n' \ '[Desktop Entry]' \ 'Type=Application' \ 'Name=cdisplayagain' \ "Exec=$(BINDIR)/cdisplayagain %f" \ + 'Icon=cdisplayagain' \ 'Terminal=false' \ 'Categories=Graphics;Viewer;' \ 'MimeType=application/x-cbz;application/x-cbr;' \ diff --git a/README.md b/README.md index e8e0c3d..742c97f 100644 --- a/README.md +++ b/README.md @@ -63,13 +63,18 @@ curl -Ls https://astral.sh/uv/install.sh | sh ### macOS Setup -**Note:** Recent Python versions on macOS no longer include tkinter by default. -You must install it separately: +The viewer needs a Python build with Tk support. `uv`'s managed Python includes +the Tk extension; install the project-supported interpreter and recreate the +virtual environment with it: ```bash -brew install python-tk +uv python install 3.13.11 +uv venv --clear --python 3.13.11 --managed-python +uv sync ``` +Do not install `tkinter` with pip: it is part of Python, not a PyPI package. + If you encounter issues with the UI not responding or appearing, try: ```bash export TK_SILENCE_DEPRECATION=1 @@ -96,6 +101,24 @@ Or via the Makefile: make run FILE=path/to/comic.cbz ``` +To associate comic files with cdisplayagain: + +```bash +# macOS +make macos-install + +# Linux +make linux-install +``` + +The macOS target installs an app bundle in `~/Applications` and registers CBZ +and CBR files with Launch Services. The Linux target installs the executable, +desktop entry, MIME definitions, and the existing cdisplayagain icon. + +On macOS, `duti` is used to select cdisplayagain as the default opener when it +is installed (`brew install duti`). Otherwise choose cdisplayagain once via +Finder's **Open With → Other…**, then enable **Always Open With**. + While viewing, navigate with the arrow keys, scroll wheel, or spacebar, and use `Esc` or `q` to close the window. diff --git a/cdisplayagain-macos.spec b/cdisplayagain-macos.spec new file mode 100644 index 0000000..1a4fb65 --- /dev/null +++ b/cdisplayagain-macos.spec @@ -0,0 +1,88 @@ +# -*- mode: python ; coding: utf-8 -*- + +from PyInstaller.utils.hooks import collect_submodules + + +hiddenimports = collect_submodules("PIL") + +a = Analysis( + ["cdisplayagain.py"], + pathex=["."], + binaries=[], + datas=[], + hiddenimports=hiddenimports, + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=[], + noarchive=False, +) +pyz = PYZ(a.pure) +exe = EXE( + pyz, + a.scripts, + [], + [], + name="cdisplayagain", + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + console=False, + argv_emulation=True, + exclude_binaries=True, +) +coll = COLLECT( + exe, + a.binaries, + a.zipfiles, + a.datas, + strip=False, + upx=True, + name="cdisplayagain", +) +app = BUNDLE( + coll, + name="cdisplayagain.app", + icon="build/cdisplayagain.icns", + bundle_identifier="com.cdisplayagain.viewer", + info_plist={ + "CFBundleDisplayName": "cdisplayagain", + "CFBundleDocumentTypes": [ + { + "CFBundleTypeExtensions": ["cbz"], + "CFBundleTypeName": "Comic Book ZIP", + "CFBundleTypeRole": "Viewer", + "LSHandlerRank": "Owner", + "LSItemContentTypes": ["com.cdisplayagain.cbz"], + }, + { + "CFBundleTypeExtensions": ["cbr"], + "CFBundleTypeName": "Comic Book RAR", + "CFBundleTypeRole": "Viewer", + "LSHandlerRank": "Owner", + "LSItemContentTypes": ["com.cdisplayagain.cbr"], + }, + ], + "UTExportedTypeDeclarations": [ + { + "UTTypeConformsTo": ["public.archive"], + "UTTypeDescription": "Comic Book ZIP archive", + "UTTypeIdentifier": "com.cdisplayagain.cbz", + "UTTypeTagSpecification": { + "public.filename-extension": ["cbz"], + "public.mime-type": "application/x-cbz", + }, + }, + { + "UTTypeConformsTo": ["public.archive"], + "UTTypeDescription": "Comic Book RAR archive", + "UTTypeIdentifier": "com.cdisplayagain.cbr", + "UTTypeTagSpecification": { + "public.filename-extension": ["cbr"], + "public.mime-type": "application/x-cbr", + }, + }, + ], + }, +) diff --git a/cdisplayagain.py b/cdisplayagain.py index b2f5966..41af2f5 100644 --- a/cdisplayagain.py +++ b/cdisplayagain.py @@ -19,6 +19,9 @@ from typing import cast 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 @@ -76,7 +79,18 @@ def _as_wm(obj: tk.Misc) -> tk.Wm: ("All files", "*.*"), ] -LOG_ROOT = Path(os.environ.get("CDISPLAYAGAIN_LOG_DIR", "logs")).expanduser() + +def _get_log_root() -> Path: + """Return a writable log directory for source and packaged launches.""" + configured_root = os.environ.get("CDISPLAYAGAIN_LOG_DIR") + if configured_root: + return Path(configured_root).expanduser() + if sys.platform == "darwin": + return Path.home() / "Library" / "Logs" / "cdisplayagain" + return Path("logs") + + +LOG_ROOT = _get_log_root() LOG_PATH: Path | None = None 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 f3b9dce..8e228d9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,6 +22,9 @@ pythonpath = ["."] include = ["."] stubPath = "stubs" +[tool.uv] +python-preference = "managed" + [tool.ruff] line-length = 100 target-version = "py313" diff --git a/tests/conftest.py b/tests/conftest.py index c2f224b..1685e77 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4,12 +4,19 @@ import logging import threading import time -import tkinter as tk from unittest.mock import patch import pytest from PIL import Image +try: + from tk_bootstrap import configure_tk_library + + configure_tk_library() + import tkinter as tk +except ImportError: + raise + import cdisplayagain diff --git a/tests/test_main_functions.py b/tests/test_main_functions.py index 02ddf5f..88d3ad2 100644 --- a/tests/test_main_functions.py +++ b/tests/test_main_functions.py @@ -17,6 +17,15 @@ def _write_image(path: Path) -> None: img.save(path) +def test_get_log_root_uses_user_directory_for_macos_bundle(monkeypatch, tmp_path): + """Packaged macOS launches must not write logs relative to the working directory.""" + monkeypatch.setattr(cdisplayagain.sys, "platform", "darwin") + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.delenv("CDISPLAYAGAIN_LOG_DIR", raising=False) + + assert cdisplayagain._get_log_root() == tmp_path / "Library" / "Logs" / "cdisplayagain" + + def test_main_function_with_file_argument(monkeypatch, tmp_path): """Test main function with command line file argument.""" _write_image(tmp_path / "test.png") diff --git a/tk_bootstrap.py b/tk_bootstrap.py new file mode 100644 index 0000000..9b1404a --- /dev/null +++ b/tk_bootstrap.py @@ -0,0 +1,21 @@ +"""Configure Tcl/Tk paths for relocatable Python distributions.""" + +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()