mirror of
https://github.com/ankitects/anki.git
synced 2025-09-18 14:02:21 -04:00

While testing the previous PR, I noticed that if stdout is set to None, the same behaviour is shown as in the following report: https://forums.ankiweb.net/t/cannot-switch-versions/64565 This leads me to wonder whether IsTerminal is behaving differently on that user's system, and the use of an env var may be more reliable.
193 lines
5.3 KiB
Python
193 lines
5.3 KiB
Python
# Copyright: Ankitects Pty Ltd and contributors
|
|
# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
|
|
|
from __future__ import annotations
|
|
|
|
import contextlib
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from anki.utils import pointVersion
|
|
from aqt import mw
|
|
from aqt.qt import QAction
|
|
from aqt.utils import askUser, is_mac, is_win, showInfo
|
|
|
|
|
|
def launcher_executable() -> str | None:
|
|
"""Return the path to the Anki launcher executable."""
|
|
return os.getenv("ANKI_LAUNCHER")
|
|
|
|
|
|
def uv_binary() -> str | None:
|
|
"""Return the path to the uv binary."""
|
|
return os.environ.get("ANKI_LAUNCHER_UV")
|
|
|
|
|
|
def launcher_root() -> str | None:
|
|
"""Return the path to the launcher root directory (AnkiProgramFiles)."""
|
|
return os.environ.get("UV_PROJECT")
|
|
|
|
|
|
def venv_binary(cmd: str) -> str | None:
|
|
"""Return the path to a binary in the launcher's venv."""
|
|
root = launcher_root()
|
|
if not root:
|
|
return None
|
|
|
|
root_path = Path(root)
|
|
if is_win:
|
|
binary_path = root_path / ".venv" / "Scripts" / cmd
|
|
else:
|
|
binary_path = root_path / ".venv" / "bin" / cmd
|
|
|
|
return str(binary_path)
|
|
|
|
|
|
def add_python_requirements(reqs: list[str]) -> tuple[bool, str]:
|
|
"""Add Python requirements to the launcher venv using uv add.
|
|
|
|
Returns (success, output)"""
|
|
|
|
binary = uv_binary()
|
|
if not binary:
|
|
return (False, "Not in packaged build.")
|
|
|
|
uv_cmd = [binary, "add"] + reqs
|
|
result = subprocess.run(uv_cmd, capture_output=True, text=True, check=False)
|
|
|
|
if result.returncode == 0:
|
|
root = launcher_root()
|
|
if root:
|
|
sync_marker = Path(root) / ".sync_complete"
|
|
sync_marker.touch()
|
|
return (True, result.stdout)
|
|
else:
|
|
return (False, result.stderr)
|
|
|
|
|
|
def trigger_launcher_run() -> None:
|
|
"""Create a trigger file to request launcher UI on next run."""
|
|
try:
|
|
root = launcher_root()
|
|
if not root:
|
|
return
|
|
|
|
trigger_path = Path(root) / ".want-launcher"
|
|
trigger_path.touch()
|
|
except Exception as e:
|
|
print(e)
|
|
|
|
|
|
def update_and_restart() -> None:
|
|
"""Update and restart Anki using the launcher."""
|
|
launcher = launcher_executable()
|
|
assert launcher
|
|
|
|
trigger_launcher_run()
|
|
|
|
with contextlib.suppress(ResourceWarning):
|
|
env = os.environ.copy()
|
|
env["ANKI_LAUNCHER_WANT_TERMINAL"] = "1"
|
|
creationflags = 0
|
|
if sys.platform == "win32":
|
|
creationflags = (
|
|
subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.DETACHED_PROCESS
|
|
)
|
|
# On Windows, changing the handles breaks ANSI display
|
|
io = None if sys.platform == "win32" else subprocess.DEVNULL
|
|
|
|
subprocess.Popen(
|
|
[launcher],
|
|
start_new_session=True,
|
|
stdin=io,
|
|
stdout=io,
|
|
stderr=io,
|
|
env=env,
|
|
creationflags=creationflags,
|
|
)
|
|
|
|
mw.app.quit()
|
|
|
|
|
|
def confirm_then_upgrade():
|
|
if not askUser("Change to a different Anki version?"):
|
|
return
|
|
update_and_restart()
|
|
|
|
|
|
# return modified command array that points to bundled command, and return
|
|
# required environment
|
|
def _packagedCmd(cmd: list[str]) -> tuple[Any, dict[str, str]]:
|
|
cmd = cmd[:]
|
|
env = os.environ.copy()
|
|
# keep LD_LIBRARY_PATH when in snap environment
|
|
if "LD_LIBRARY_PATH" in env and "SNAP" not in env:
|
|
del env["LD_LIBRARY_PATH"]
|
|
|
|
# Try to find binary in anki-audio package for Windows/Mac
|
|
if is_win or is_mac:
|
|
try:
|
|
import anki_audio
|
|
|
|
audio_pkg_path = Path(anki_audio.__file__).parent
|
|
if is_win:
|
|
packaged_path = audio_pkg_path / (cmd[0] + ".exe")
|
|
else: # is_mac
|
|
packaged_path = audio_pkg_path / cmd[0]
|
|
|
|
if packaged_path.exists():
|
|
cmd[0] = str(packaged_path)
|
|
return cmd, env
|
|
except ImportError:
|
|
# anki-audio not available, fall back to old behavior
|
|
pass
|
|
|
|
packaged_path = Path(sys.prefix) / cmd[0]
|
|
if packaged_path.exists():
|
|
cmd[0] = str(packaged_path)
|
|
|
|
return cmd, env
|
|
|
|
|
|
def on_addon_config():
|
|
showInfo(
|
|
"This add-on is automatically added when installing older Anki versions, so that they work with the launcher. You can remove it if you wish."
|
|
)
|
|
|
|
|
|
def setup():
|
|
mw.addonManager.setConfigAction(__name__, on_addon_config)
|
|
|
|
if pointVersion() >= 250600:
|
|
return
|
|
if not launcher_executable():
|
|
return
|
|
|
|
# Add action to tools menu
|
|
action = QAction("Upgrade/Downgrade", mw)
|
|
action.triggered.connect(confirm_then_upgrade)
|
|
mw.form.menuTools.addAction(action)
|
|
|
|
# Monkey-patch audio tools to use anki-audio
|
|
if is_win or is_mac:
|
|
import aqt
|
|
import aqt.sound
|
|
|
|
aqt.sound._packagedCmd = _packagedCmd
|
|
|
|
# Inject launcher functions into launcher module
|
|
import aqt.package
|
|
|
|
aqt.package.launcher_executable = launcher_executable
|
|
aqt.package.update_and_restart = update_and_restart
|
|
aqt.package.trigger_launcher_run = trigger_launcher_run
|
|
aqt.package.uv_binary = uv_binary
|
|
aqt.package.launcher_root = launcher_root
|
|
aqt.package.venv_binary = venv_binary
|
|
aqt.package.add_python_requirements = add_python_requirements
|
|
|
|
|
|
setup()
|