From 726318f0167e2e95117e52feffd5b424d2289eb3 Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Sat, 14 Jun 2025 17:28:44 +0700 Subject: [PATCH] Add a hidden env var to preload our libs and audio helpers on macOS --- qt/aqt/__init__.py | 26 +++---- qt/aqt/_macos_helper.py | 9 +-- qt/aqt/mediasrv.py | 10 +-- qt/aqt/package.py | 125 +++++++++++---------------------- qt/aqt/utils.py | 28 +++----- qt/bundle/launcher/src/main.rs | 6 +- 6 files changed, 67 insertions(+), 137 deletions(-) diff --git a/qt/aqt/__init__.py b/qt/aqt/__init__.py index cdbd05ebe..80c4dd081 100644 --- a/qt/aqt/__init__.py +++ b/qt/aqt/__init__.py @@ -3,12 +3,18 @@ from __future__ import annotations +import os import atexit import logging import sys from collections.abc import Callable from typing import TYPE_CHECKING, Any, Union, cast +if "ANKI_FIRST_RUN" in os.environ: + from .package import first_run_setup + + first_run_setup() + try: import pip_system_certs.wrapt_requests except ModuleNotFoundError: @@ -32,18 +38,9 @@ if "--syncserver" in sys.argv: from anki.syncserver import run_sync_server from anki.utils import is_mac - from .package import _fix_protobuf_path - - if is_mac and getattr(sys, "frozen", False): - _fix_protobuf_path() - # does not return run_sync_server() -from .package import packaged_build_setup - -packaged_build_setup() - import argparse import builtins import cProfile @@ -270,13 +267,7 @@ def setupLangAndBackend( # load qt translations _qtrans = QTranslator() - if is_mac and getattr(sys, "frozen", False): - qt_dir = os.path.join(sys.prefix, "../Resources/qt_translations") - else: - if qtmajor == 5: - qt_dir = QLibraryInfo.location(QLibraryInfo.TranslationsPath) # type: ignore - else: - qt_dir = QLibraryInfo.path(QLibraryInfo.LibraryPath.TranslationsPath) + qt_dir = QLibraryInfo.path(QLibraryInfo.LibraryPath.TranslationsPath) qt_lang = lang.replace("-", "_") if _qtrans.load(f"qtbase_{qt_lang}", qt_dir): app.installTranslator(_qtrans) @@ -607,14 +598,13 @@ def _run(argv: list[str] | None = None, exec: bool = True) -> AnkiApp | None: profiler = cProfile.Profile() profiler.enable() - packaged = getattr(sys, "frozen", False) x11_available = os.getenv("DISPLAY") wayland_configured = qtmajor > 5 and ( os.getenv("QT_QPA_PLATFORM") == "wayland" or os.getenv("WAYLAND_DISPLAY") ) wayland_forced = os.getenv("ANKI_WAYLAND") - if (packaged or is_gnome) and wayland_configured: + if is_gnome and wayland_configured: if wayland_forced or not x11_available: # Work around broken fractional scaling in Wayland # https://bugreports.qt.io/browse/QTBUG-113574 diff --git a/qt/aqt/_macos_helper.py b/qt/aqt/_macos_helper.py index 859cb4b0a..482693384 100644 --- a/qt/aqt/_macos_helper.py +++ b/qt/aqt/_macos_helper.py @@ -14,12 +14,9 @@ import aqt.utils class _MacOSHelper: def __init__(self) -> None: - if getattr(sys, "frozen", False): - path = os.path.join(sys.prefix, "libankihelper.dylib") - else: - path = os.path.join( - aqt.utils.aqt_data_folder(), "lib", "libankihelper.dylib" - ) + path = os.path.join( + aqt.utils.aqt_data_folder(), "lib", "libankihelper.dylib" + ) self._dll = CDLL(path) self._dll.system_is_dark.restype = c_bool diff --git a/qt/aqt/mediasrv.py b/qt/aqt/mediasrv.py index a38790728..69ef054ec 100644 --- a/qt/aqt/mediasrv.py +++ b/qt/aqt/mediasrv.py @@ -252,14 +252,8 @@ def _handle_local_file_request(request: LocalFileRequest) -> Response: def _builtin_data(path: str) -> bytes: """Return data from file in aqt/data folder. Path must use forward slash separators.""" - # packaged build? - if getattr(sys, "frozen", False): - reader = aqt.__loader__.get_resource_reader("_aqt") # type: ignore - with reader.open_resource(path) as f: - return f.read() - else: - full_path = aqt_data_path() / ".." / path - return full_path.read_bytes() + full_path = aqt_data_path() / ".." / path + return full_path.read_bytes() def _handle_builtin_file_request(request: BundledFileRequest) -> Response: diff --git a/qt/aqt/package.py b/qt/aqt/package.py index f1ee8cd79..a9b9bba7b 100644 --- a/qt/aqt/package.py +++ b/qt/aqt/package.py @@ -4,94 +4,51 @@ """Helpers for the packaged version of Anki.""" from __future__ import annotations - -import os -import sys from pathlib import Path +import subprocess +from anki.utils import is_mac +def first_run_setup() -> None: + """Code run the first time after install/upgrade. + + Currently, we just import our main libraries and invoke + mpv/lame on macOS, which is slow on the first run, and doing + it this way shows progress being made. + """ -def _fix_pywin32() -> None: - # extend sys.path with .pth files - import site - - site.addsitedir(sys.path[0]) - - # use updated sys.path to locate dll folder and add it to path - path = sys.path[-1] - path = path.replace("Pythonwin", "pywin32_system32") - os.environ["PATH"] += ";" + path - - # import Python modules from .dll files - import importlib.machinery - - for name in "pythoncom", "pywintypes": - filename = os.path.join(path, name + "39.dll") - loader = importlib.machinery.ExtensionFileLoader(name, filename) - spec = importlib.machinery.ModuleSpec(name=name, loader=loader, origin=filename) - _mod = importlib._bootstrap._load(spec) # type: ignore - - -def _patch_pkgutil() -> None: - """Teach pkgutil.get_data() how to read files from in-memory resources. - - This is required for jsonschema.""" - import importlib - import pkgutil - - def get_data_custom(package: str, resource: str) -> bytes | None: - try: - module = importlib.import_module(package) - reader = module.__loader__.get_resource_reader(package) # type: ignore - with reader.open_resource(resource) as f: - return f.read() - except Exception: - return None - - pkgutil.get_data = get_data_custom - - -def _patch_certifi() -> None: - """Tell certifi (and thus requests) to use a file in our package folder. - - By default it creates a copy of the data in a temporary folder, which then gets - cleaned up by macOS's temp file cleaner.""" - import certifi - - def where() -> str: - prefix = Path(sys.prefix) - if sys.platform == "darwin": - path = prefix / "../Resources/certifi/cacert.pem" - else: - path = prefix / "lib" / "certifi" / "cacert.pem" - return str(path) - - certifi.where = where - - -def _fix_protobuf_path() -> None: - sys.path.append(str(Path(sys.prefix) / "../Resources")) - - -def packaged_build_setup() -> None: - if not getattr(sys, "frozen", False): + if not is_mac: return + + def _dot(): + print(".", flush=True, end="") - print("Initial setup...") + _dot() + import anki.collection + _dot() + import PyQt6.sip + _dot() + import PyQt6.QtCore + _dot() + import PyQt6.QtGui + _dot() + import PyQt6.QtNetwork + _dot() + import PyQt6.QtQuick + _dot() + import PyQt6.QtWebChannel + _dot() + import PyQt6.QtWebEngineCore + _dot() + import PyQt6.QtWebEngineWidgets + _dot() + import PyQt6.QtWidgets - if sys.platform == "win32": - _fix_pywin32() - elif sys.platform == "darwin": - _fix_protobuf_path() + import anki_audio + audio_pkg_path = Path(anki_audio.__file__).parent - _patch_pkgutil() - _patch_certifi() - - # escape hatch for debugging issues with packaged build startup - if os.getenv("ANKI_STARTUP_REPL"): - # mypy incorrectly thinks this does not exist on Windows - is_tty = os.isatty(sys.stdin.fileno()) # type: ignore - if is_tty: - import code - - code.InteractiveConsole().interact() - sys.exit(0) + # Invoke mpv and lame + cmd = [Path(""), "--version"] + for cmd_name in ["mpv", "lame"]: + _dot() + cmd[0] = audio_pkg_path / cmd_name + subprocess.run(cmd, check=True, capture_output=True) diff --git a/qt/aqt/utils.py b/qt/aqt/utils.py index 6ae8bace8..e17550fc0 100644 --- a/qt/aqt/utils.py +++ b/qt/aqt/utils.py @@ -87,24 +87,15 @@ if TYPE_CHECKING: def aqt_data_path() -> Path: - # packaged? - if getattr(sys, "frozen", False): - prefix = Path(sys.prefix) - path = prefix / "lib/_aqt/data" - if path.exists(): - return path - else: - return prefix / "../Resources/_aqt/data" - else: - import _aqt.colors + import _aqt.colors - data_folder = Path(inspect.getfile(_aqt.colors)).with_name("data") - if data_folder.exists(): - return data_folder.absolute() - else: - # should only happen when running unit tests - print("warning, data folder not found") - return Path(".") + data_folder = Path(inspect.getfile(_aqt.colors)).with_name("data") + if data_folder.exists(): + return data_folder.absolute() + else: + # should only happen when running unit tests + print("warning, data folder not found") + return Path(".") def aqt_data_folder() -> str: @@ -1207,12 +1198,11 @@ def supportText() -> str: platname = platform.platform() return """\ -Anki {} {} {} +Anki {} {} Python {} Qt {} PyQt {} Platform: {} """.format( version_with_build(), - "(src)" if not getattr(sys, "frozen", False) else "", "(ao)" if mw.addonManager.dirty else "", platform.python_version(), qVersion(), diff --git a/qt/bundle/launcher/src/main.rs b/qt/bundle/launcher/src/main.rs index 470eed6a7..2a09f722e 100644 --- a/qt/bundle/launcher/src/main.rs +++ b/qt/bundle/launcher/src/main.rs @@ -87,7 +87,10 @@ fn main() { // Pre-validate by running --version to trigger any Gatekeeper checks let anki_bin = uv_install_root.join(".venv/bin/anki"); println!("\n\x1B[1mThis may take a few minutes. Please wait...\x1B[0m"); - let _ = Command::new(&anki_bin).arg("--version").output(); + let _ = Command::new(&anki_bin) + .env("ANKI_FIRST_RUN", "1") + .arg("--version") + .status(); // Then launch the binary as detached subprocess so the terminal can close let child = Command::new(&anki_bin) @@ -98,7 +101,6 @@ fn main() { .spawn() .unwrap(); std::mem::forget(child); - println!("Anki launched successfully"); } else { // If venv already existed, exec as normal println!(