diff --git a/app_version.py b/app_version.py index 71aaa27..394ee27 100644 --- a/app_version.py +++ b/app_version.py @@ -4,7 +4,7 @@ CI, so the version lives in exactly one place. CI fails a ``v`` release tag whose value doesn't match this string. """ -__version__ = "1.8.34" +__version__ = "1.9.0" # Display name; single source shared by the app + mixins. "PrevizRender" is # the product name (formerly "Render Mapper Pro"); installer/bundle artifact # names in BlenderVideoMapper.spec and installer/windows.iss intentionally keep diff --git a/app_window/update_mixin.py b/app_window/update_mixin.py index f327644..7a281ce 100644 --- a/app_window/update_mixin.py +++ b/app_window/update_mixin.py @@ -137,8 +137,17 @@ def _on_update_checked(self, info, manual: bool, error: str = "") -> None: self._append_log(f"[update] Check failed — {error}") if manual: detail = f"\n\n{error}" if error else "" + # A TLS/cert failure means no CA bundle is reachable — installed + # builds ship one, so this only bites when running from source. + cert_hint = "" + if "CERTIFICATE_VERIFY" in error or "SSL" in error: + cert_hint = ( + "\n\nThis looks like a missing HTTPS certificate bundle. " + "Installed builds ship one; running from source, install " + "it with: pip install certifi") warn(self, "Updates", "Couldn't reach GitHub to check for updates." + detail + + cert_hint + "\n\nYou can always download the latest version from the " "Releases page on GitHub.") return diff --git a/core/utils.py b/core/utils.py index 214fdf8..0caf13f 100644 --- a/core/utils.py +++ b/core/utils.py @@ -107,8 +107,9 @@ def ca_bundle_path() -> str | None: CERTIFICATE_VERIFY_FAILED ("Couldn't reach GitHub …") unless we point it at a bundled cacert.pem. Tries, in order: certifi (if importable), then the cacert.pem the build drops into the bundle root / certifi/ dir — so HTTPS - works even if ``import certifi`` itself fails inside the frozen app. Returns - None from source (the system trust store is fine there).""" + works even if ``import certifi`` itself fails inside the frozen app, then an + explicit ``SSL_CERT_FILE`` / ``REQUESTS_CA_BUNDLE`` env var. Returns None + from a normal source checkout (the system trust store is fine there).""" try: import certifi p = certifi.where() @@ -122,6 +123,12 @@ def ca_bundle_path() -> str | None: os.path.join(base, "certifi", "cacert.pem")): if os.path.exists(cand): return cand + # Last resort: honour an explicitly configured bundle so a user on a locked- + # down machine can point us at their own CA file without a rebuild. + for var in ("SSL_CERT_FILE", "REQUESTS_CA_BUNDLE"): + env_path = os.environ.get(var) + if env_path and os.path.exists(env_path): + return env_path return None diff --git a/panels.py b/panels.py index fe63006..b09ec59 100644 --- a/panels.py +++ b/panels.py @@ -103,6 +103,7 @@ _audio_probe_cache, file_manager_name, find_ffmpeg_tool, + reveal_in_file_manager, video_has_audio, ) from theme import LINK_COLORS, active_palette @@ -1162,6 +1163,8 @@ def current_video(self) -> str: def _show_video_context_menu(self, pos) -> None: item = self.vid_list.itemAt(pos) + path = str(item.data(ROLE_VIDEO_PATH) or "") if item is not None else "" + is_video = bool(path) and not path.startswith("__add_video__") menu = QMenu(self) add_action = menu.addAction("Add Videos...") render_action = menu.addAction("Start Render") @@ -1171,7 +1174,12 @@ def _show_video_context_menu(self, pos) -> None: menu.addSeparator() muted = bool(item.data(ROLE_MUTED)) mute_action = menu.addAction("Unmute audio" if muted else "Mute audio") - if item is None or str(item.data(ROLE_VIDEO_PATH) or "").startswith("__add_video__"): + reveal_action = None + if is_video: + menu.addSeparator() + reveal_action = menu.addAction(f"Reveal in {file_manager_name()}") + reveal_action.setToolTip(str(Path(path).parent)) + if not is_video: remove_action.setEnabled(False) chosen = menu.exec(self.vid_list.viewport().mapToGlobal(pos)) if chosen == add_action: @@ -1182,6 +1190,8 @@ def _show_video_context_menu(self, pos) -> None: self._remove_selected_video() elif mute_action is not None and item is not None and chosen == mute_action: self.toggle_mute(item.data(ROLE_VIDEO_PATH)) + elif reveal_action is not None and chosen == reveal_action: + reveal_in_file_manager(path) # Compact field widths so a single value never spans the panel: small numeric diff --git a/pyproject.toml b/pyproject.toml index fc99f27..0a5e7ce 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,6 +9,9 @@ requires-python = ">=3.12" dynamic = ["version"] dependencies = [ "PySide6==6.11.1", + # Ships a CA bundle so HTTPS (the GitHub update check) verifies even in a + # frozen build whose bundled Python carries no system certificates. + "certifi", ] [project.optional-dependencies] diff --git a/requirements.txt b/requirements.txt index e00824e..1c57ba8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,6 +2,10 @@ # Pinned for reproducibility; see requirements-dev.txt for dev/CI tooling and # requirements-build.txt for the frozen-app build pins. PySide6==6.11.1 +# CA bundle for HTTPS verification. A frozen build ships its own Python with no +# system certificates, so the GitHub update check fails with +# CERTIFICATE_VERIFY_FAILED without this. ca_bundle_path() prefers it. +certifi # Optional — enables the headless three.js (.glb/.gltf) render backend. The # Chromium browser itself is downloaded on demand, not via pip. playwright==1.60.0 diff --git a/tests/test_utils.py b/tests/test_utils.py index 43669d0..efd4cad 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -4,8 +4,11 @@ import sys from typing import cast +import pytest + from core.utils import ( atomic_write_text, + ca_bundle_path, expand_output_tokens, ext_for_format, ffmpeg_movie_av_args, @@ -176,3 +179,24 @@ def test_terminate_process_already_exited_is_noop(): p.wait() terminate_process(p) # must not raise on an already-dead process assert p.poll() is not None + + +def test_ca_bundle_prefers_certifi(): + # When certifi is importable its bundle wins — this is what a source checkout + # and every installed build rely on for HTTPS verification (the update check). + # CI's lint-test job installs only pinned tools, so skip when it's absent. + certifi = pytest.importorskip("certifi") + p = ca_bundle_path() + assert p is not None and p == certifi.where() + + +def test_ca_bundle_env_fallback(tmp_path, monkeypatch): + # No certifi, not frozen, but SSL_CERT_FILE points at a real bundle → honour + # it, so a locked-down machine can supply its own CA file without a rebuild. + ca = tmp_path / "corp-ca.pem" + ca.write_text("dummy") + monkeypatch.setitem(sys.modules, "certifi", None) # force `import certifi` to fail + monkeypatch.setattr(sys, "frozen", False, raising=False) + monkeypatch.delenv("REQUESTS_CA_BUNDLE", raising=False) + monkeypatch.setenv("SSL_CERT_FILE", str(ca)) + assert ca_bundle_path() == str(ca)