Inject Upgrade/Downgrade menu item and audio support into older versions

This commit is contained in:
Damien Elmes 2025-06-28 16:57:52 +07:00
parent a587343f29
commit f5073b402a
4 changed files with 214 additions and 5 deletions

View file

@ -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()

View file

@ -0,0 +1,6 @@
{
"name": "Anki Launcher",
"package": "anki-launcher",
"min_point_version": 50,
"max_point_version": 250600
}

View file

@ -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<VersionKind> {
}
}
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<std::path::PathBuf> {
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<Command> {
let python_exe = if cfg!(target_os = "windows") {
let show_console = std::env::var("ANKI_CONSOLE").is_ok();

View file

@ -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();