diff --git a/qt/launcher/addon/__init__.py b/qt/launcher/addon/__init__.py new file mode 100644 index 000000000..fb0168d14 --- /dev/null +++ b/qt/launcher/addon/__init__.py @@ -0,0 +1,135 @@ +# 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 + +import aqt.sound +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 _anki_launcher_path() -> str | None: + return os.getenv("ANKI_LAUNCHER") + + +def have_launcher() -> bool: + return _anki_launcher_path() is not None + + +def update_and_restart() -> None: + from aqt import mw + + launcher = _anki_launcher_path() + assert launcher + + _trigger_launcher_run() + + with contextlib.suppress(ResourceWarning): + env = os.environ.copy() + creationflags = 0 + if sys.platform == "win32": + creationflags = ( + subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.DETACHED_PROCESS + ) + subprocess.Popen( + [launcher], + start_new_session=True, + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + env=env, + creationflags=creationflags, + ) + + mw.app.quit() + + +def _trigger_launcher_run() -> None: + """Bump the mtime on pyproject.toml in the local data directory to trigger an update on next run.""" + try: + # Get the local data directory equivalent to Rust's dirs::data_local_dir() + if is_win: + from aqt.winpaths import get_local_appdata + + data_dir = Path(get_local_appdata()) + elif is_mac: + data_dir = Path.home() / "Library" / "Application Support" + else: # Linux + data_dir = Path( + os.environ.get("XDG_DATA_HOME", Path.home() / ".local" / "share") + ) + + pyproject_path = data_dir / "AnkiProgramFiles" / "pyproject.toml" + + if pyproject_path.exists(): + # Touch the file to update its mtime + pyproject_path.touch() + except Exception as e: + print(e) + + +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 setup(): + if pointVersion() >= 250600: + return + if not have_launcher(): + 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: + aqt.sound._packagedCmd = _packagedCmd + + +setup() diff --git a/qt/launcher/addon/manifest.json b/qt/launcher/addon/manifest.json new file mode 100644 index 000000000..b4f08e70d --- /dev/null +++ b/qt/launcher/addon/manifest.json @@ -0,0 +1,6 @@ +{ + "name": "Anki Launcher", + "package": "anki-launcher", + "min_point_version": 50, + "max_point_version": 250600 +} diff --git a/qt/launcher/src/main.rs b/qt/launcher/src/main.rs index b2535f410..d8903140d 100644 --- a/qt/launcher/src/main.rs +++ b/qt/launcher/src/main.rs @@ -6,6 +6,7 @@ use std::io::stdin; use std::io::stdout; use std::io::Write; +use std::os::unix::process::CommandExt; use std::process::Command; use std::time::SystemTime; use std::time::UNIX_EPOCH; @@ -17,7 +18,7 @@ use anki_io::read_file; use anki_io::remove_file; use anki_io::write_file; use anki_io::ToUtf8Path; -use anki_process::CommandExt; +use anki_process::CommandExt as AnkiCommandExt; use anyhow::Context; use anyhow::Result; @@ -133,7 +134,7 @@ fn run() -> Result<()> { #[cfg(target_os = "macos")] { let cmd = build_python_command(&state.uv_install_root, &[])?; - platform::mac::prepare_for_launch_after_update(cmd)?; + platform::mac::prepare_for_launch_after_update(cmd, &uv_install_root)?; } if cfg!(unix) && !cfg!(target_os = "macos") { @@ -179,7 +180,7 @@ fn main_menu_loop(state: &State) -> Result<()> { choice @ (MainMenuChoice::Latest | MainMenuChoice::Version(_)) => { // For other choices, update project files and sync update_pyproject_for_version( - choice, + choice.clone(), state.dist_pyproject_path.clone(), state.user_pyproject_path.clone(), state.dist_python_version_path.clone(), @@ -215,7 +216,10 @@ fn main_menu_loop(state: &State) -> Result<()> { match command.ensure_success() { Ok(_) => { - // Sync succeeded, break out of loop + // Sync succeeded + if matches!(&choice, MainMenuChoice::Version(VersionKind::PyOxidizer(_))) { + inject_helper_addon(&state.uv_install_root)?; + } break; } Err(e) => { @@ -351,6 +355,7 @@ fn update_pyproject_for_version( &format!( concat!( "aqt[qt6]=={}\",\n", + " \"anki-audio==0.1.0; sys.platform == 'win32' or sys.platform == 'darwin'\",\n", " \"pyqt6==6.6.1\",\n", " \"pyqt6-qt6==6.6.2\",\n", " \"pyqt6-webengine==6.6.0\",\n", @@ -427,6 +432,54 @@ fn parse_version_kind(version: &str) -> Option { } } +fn inject_helper_addon(_uv_install_root: &std::path::Path) -> Result<()> { + let addons21_path = get_anki_addons21_path()?; + + if !addons21_path.exists() { + return Ok(()); + } + + let addon_folder = addons21_path.join("anki-launcher"); + + // Remove existing anki-launcher folder if it exists + if addon_folder.exists() { + anki_io::remove_dir_all(&addon_folder)?; + } + + // Create the anki-launcher folder + create_dir_all(&addon_folder)?; + + // Write the embedded files + let init_py_content = include_str!("../addon/__init__.py"); + let manifest_json_content = include_str!("../addon/manifest.json"); + + write_file(addon_folder.join("__init__.py"), init_py_content)?; + write_file(addon_folder.join("manifest.json"), manifest_json_content)?; + + Ok(()) +} + +fn get_anki_addons21_path() -> Result { + let anki_base_path = if cfg!(target_os = "windows") { + // Windows: %APPDATA%\Anki2 + dirs::config_dir() + .context("Unable to determine config directory")? + .join("Anki2") + } else if cfg!(target_os = "macos") { + // macOS: ~/Library/Application Support/Anki2 + dirs::data_dir() + .context("Unable to determine data directory")? + .join("Anki2") + } else { + // Linux: ~/.local/share/Anki2 + dirs::data_dir() + .context("Unable to determine data directory")? + .join("Anki2") + }; + + Ok(anki_base_path.join("addons21")) +} + fn build_python_command(uv_install_root: &std::path::Path, args: &[String]) -> Result { let python_exe = if cfg!(target_os = "windows") { let show_console = std::env::var("ANKI_CONSOLE").is_ok(); diff --git a/qt/launcher/src/platform/mac.rs b/qt/launcher/src/platform/mac.rs index ab2c4b8fb..292e48726 100644 --- a/qt/launcher/src/platform/mac.rs +++ b/qt/launcher/src/platform/mac.rs @@ -3,6 +3,7 @@ use std::io; use std::io::Write; +use std::path::Path; use std::process::Command; use std::sync::atomic::AtomicBool; use std::sync::atomic::Ordering; @@ -14,7 +15,7 @@ use anki_process::CommandExt as AnkiCommandExt; use anyhow::Context; use anyhow::Result; -pub fn prepare_for_launch_after_update(mut cmd: Command) -> Result<()> { +pub fn prepare_for_launch_after_update(mut cmd: Command, root: &Path) -> Result<()> { // Pre-validate by running --version to trigger any Gatekeeper checks print!("\n\x1B[1mThis may take a few minutes. Please wait\x1B[0m"); io::stdout().flush().unwrap(); @@ -37,6 +38,20 @@ pub fn prepare_for_launch_after_update(mut cmd: Command) -> Result<()> { .stderr(std::process::Stdio::null()) .ensure_success(); + if cfg!(target_os = "macos") { + // older Anki versions had a short mpv timeout and didn't support + // ANKI_FIRST_RUN, so we need to ensure mpv passes Gatekeeper + // validation prior to launch + let mpv_path = root.join(".venv/lib/python3.9/site-packages/anki_audio/mpv"); + if mpv_path.exists() { + let _ = Command::new(&mpv_path) + .arg("--version") + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .ensure_success(); + } + } + // Stop progress indicator running.store(false, Ordering::Relaxed); progress_thread.join().unwrap();