From de7de82f769f58d20c814fbdb5274aa366e887ef Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Thu, 26 Jun 2025 16:21:39 +0700 Subject: [PATCH] Refactor launcher + various tweaks - Launcher can now be accessed via Tools>Upgrade/Downgrade - Anki closes automatically on update - When launcher not available, show update link like in the past - It appears that access to the modern console host requires an app to be built with the windows console subsystem, so we introduce an extra anki-console.exe binary to relaunch ourselves with. Solves https://forums.ankiweb.net/t/new-online-installer-launcher/62745/50 - Windows now requires you to close the terminal like on a Mac, as I couldn't figure out how to have it automatically close. Suggestions welcome! - Reduce the amount of duplicate/near-duplicate code in the various platform files, and improve readability - Add a helper to install the current code into the launcher env - Fix cargo test failing to build on ARM64 Windows --- Cargo.lock | 1 + Cargo.toml | 2 +- ftl/qt/qt-accel.ftl | 1 + ftl/qt/qt-misc.ftl | 2 +- qt/aqt/forms/main.ui | 10 +- qt/aqt/main.py | 13 ++ qt/aqt/update.py | 54 ++++++-- qt/launcher/Cargo.toml | 5 + qt/launcher/src/bin/anki_console.rs | 58 +++++++++ qt/launcher/src/bin/build_win.rs | 10 +- qt/launcher/src/main.rs | 187 +++++++++++++++++----------- qt/launcher/src/platform/mac.rs | 70 ++--------- qt/launcher/src/platform/mod.rs | 108 ++++++++++++++-- qt/launcher/src/platform/unix.rs | 74 ----------- qt/launcher/src/platform/windows.rs | 152 +++++----------------- qt/launcher/win/anki-console.bat | 5 - qt/launcher/win/build.bat | 9 +- rslib/sync/Cargo.toml | 5 + tools/update-launcher-env | 15 +++ tools/update-launcher-env.bat | 8 ++ 20 files changed, 435 insertions(+), 354 deletions(-) create mode 100644 qt/launcher/src/bin/anki_console.rs delete mode 100644 qt/launcher/win/anki-console.bat create mode 100755 tools/update-launcher-env create mode 100644 tools/update-launcher-env.bat diff --git a/Cargo.lock b/Cargo.lock index 03f9e63c8..04e7c6c76 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3543,6 +3543,7 @@ dependencies = [ "anki_io", "anki_process", "anyhow", + "camino", "dirs 6.0.0", "embed-resource", "libc", diff --git a/Cargo.toml b/Cargo.toml index d2ce2ce2a..61cca8649 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -138,7 +138,7 @@ unic-ucd-category = "0.9.0" unicode-normalization = "0.1.24" walkdir = "2.5.0" which = "8.0.0" -winapi = { version = "0.3", features = ["wincon", "errhandlingapi", "consoleapi"] } +winapi = { version = "0.3", features = ["wincon"] } windows = { version = "0.61.3", features = ["Media_SpeechSynthesis", "Media_Core", "Foundation_Collections", "Storage_Streams"] } wiremock = "0.6.3" xz2 = "0.1.7" diff --git a/ftl/qt/qt-accel.ftl b/ftl/qt/qt-accel.ftl index 6c832b368..3ab54eb24 100644 --- a/ftl/qt/qt-accel.ftl +++ b/ftl/qt/qt-accel.ftl @@ -46,3 +46,4 @@ qt-accel-zoom-editor-in = Zoom Editor &In qt-accel-zoom-editor-out = Zoom Editor &Out qt-accel-create-backup = Create &Backup qt-accel-load-backup = &Revert to Backup +qt-accel-upgrade-downgrade = Upgrade/Downgrade diff --git a/ftl/qt/qt-misc.ftl b/ftl/qt/qt-misc.ftl index 60c22ef8b..d7bbef990 100644 --- a/ftl/qt/qt-misc.ftl +++ b/ftl/qt/qt-misc.ftl @@ -73,7 +73,7 @@ qt-misc-second = qt-misc-layout-auto-enabled = Responsive layout enabled qt-misc-layout-vertical-enabled = Vertical layout enabled qt-misc-layout-horizontal-enabled = Horizontal layout enabled -qt-misc-please-restart-to-update-anki = Please restart Anki to update to the latest version. +qt-misc-open-anki-launcher = Change to a different Anki version? ## deprecated- these strings will be removed in the future, and do not need ## to be translated diff --git a/qt/aqt/forms/main.ui b/qt/aqt/forms/main.ui index 0687d4ef3..bffc67ad0 100644 --- a/qt/aqt/forms/main.ui +++ b/qt/aqt/forms/main.ui @@ -46,7 +46,7 @@ 0 0 667 - 24 + 43 @@ -93,6 +93,7 @@ + @@ -130,7 +131,7 @@ Ctrl+P - QAction::PreferencesRole + QAction::MenuRole::PreferencesRole @@ -283,6 +284,11 @@ qt_accel_load_backup + + + qt_accel_upgrade_downgrade + + diff --git a/qt/aqt/main.py b/qt/aqt/main.py index 8e01208a4..b261cd34e 100644 --- a/qt/aqt/main.py +++ b/qt/aqt/main.py @@ -1308,6 +1308,14 @@ title="{}" {}>{}""".format( def onPrefs(self) -> None: aqt.dialogs.open("Preferences", self) + def on_upgrade_downgrade(self) -> None: + if not askUser(tr.qt_misc_open_anki_launcher()): + return + + from aqt.update import update_and_restart + + update_and_restart() + def onNoteTypes(self) -> None: import aqt.models @@ -1389,6 +1397,8 @@ title="{}" {}>{}""".format( ########################################################################## def setupMenus(self) -> None: + from aqt.update import have_launcher + m = self.form # File @@ -1418,6 +1428,9 @@ title="{}" {}>{}""".format( qconnect(m.actionCreateFiltered.triggered, self.onCram) qconnect(m.actionEmptyCards.triggered, self.onEmptyCards) qconnect(m.actionNoteTypes.triggered, self.onNoteTypes) + qconnect(m.action_upgrade_downgrade.triggered, self.on_upgrade_downgrade) + if not have_launcher(): + m.action_upgrade_downgrade.setVisible(False) qconnect(m.actionPreferences.triggered, self.onPrefs) # View diff --git a/qt/aqt/update.py b/qt/aqt/update.py index d8e92426c..61fec8e6b 100644 --- a/qt/aqt/update.py +++ b/qt/aqt/update.py @@ -1,7 +1,11 @@ # 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 from pathlib import Path import aqt @@ -10,7 +14,7 @@ from anki.collection import CheckForUpdateResponse, Collection from anki.utils import dev_mode, int_time, int_version, is_mac, is_win, plat_desc from aqt.operations import QueryOp from aqt.qt import * -from aqt.utils import show_info, show_warning, showText, tr +from aqt.utils import openLink, show_warning, showText, tr def check_for_update() -> None: @@ -80,22 +84,56 @@ def prompt_to_update(mw: aqt.AnkiQt, ver: str) -> None: # ignore this update mw.pm.meta["suppressUpdate"] = ver elif ret == QMessageBox.StandardButton.Yes: - update_and_restart() + if have_launcher(): + update_and_restart() + else: + openLink(aqt.appWebsiteDownloadSection) + + +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: - """Download and install the update, then restart Anki.""" - update_on_next_run() - # todo: do this automatically in the future - show_info(tr.qt_misc_please_restart_to_update_anki()) + 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 update_on_next_run() -> None: +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: - data_dir = Path(os.environ.get("LOCALAPPDATA", "")) + from .winpaths import get_local_appdata + + data_dir = Path(get_local_appdata()) elif is_mac: data_dir = Path.home() / "Library" / "Application Support" else: # Linux diff --git a/qt/launcher/Cargo.toml b/qt/launcher/Cargo.toml index 45ca11e9b..735cd892e 100644 --- a/qt/launcher/Cargo.toml +++ b/qt/launcher/Cargo.toml @@ -11,6 +11,7 @@ rust-version.workspace = true anki_io.workspace = true anki_process.workspace = true anyhow.workspace = true +camino.workspace = true dirs.workspace = true [target.'cfg(windows)'.dependencies] @@ -22,5 +23,9 @@ libc-stdhandle.workspace = true name = "build_win" path = "src/bin/build_win.rs" +[[bin]] +name = "anki-console" +path = "src/bin/anki_console.rs" + [target.'cfg(windows)'.build-dependencies] embed-resource.workspace = true diff --git a/qt/launcher/src/bin/anki_console.rs b/qt/launcher/src/bin/anki_console.rs new file mode 100644 index 000000000..596377ba1 --- /dev/null +++ b/qt/launcher/src/bin/anki_console.rs @@ -0,0 +1,58 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +#![windows_subsystem = "console"] + +use std::env; +use std::io::stdin; +use std::process::Command; + +use anyhow::Context; +use anyhow::Result; + +fn main() { + if let Err(e) = run() { + eprintln!("Error: {:#}", e); + std::process::exit(1); + } +} + +fn run() -> Result<()> { + let current_exe = env::current_exe().context("Failed to get current executable path")?; + let exe_dir = current_exe + .parent() + .context("Failed to get executable directory")?; + + let anki_exe = exe_dir.join("anki.exe"); + + if !anki_exe.exists() { + anyhow::bail!("anki.exe not found in the same directory"); + } + + // Forward all command line arguments to anki.exe + let args: Vec = env::args().skip(1).collect(); + + let mut cmd = Command::new(&anki_exe); + cmd.args(&args); + + if std::env::var("ANKI_IMPLICIT_CONSOLE").is_err() { + // if directly invoked by the user, signal the launcher that the + // user wants a Python console + std::env::set_var("ANKI_CONSOLE", "1"); + } + + // Wait for the process to complete and forward its exit code + let status = cmd.status().context("Failed to execute anki.exe")?; + if !status.success() { + println!("\nPress enter to close."); + let mut input = String::new(); + let _ = stdin().read_line(&mut input); + } + + if let Some(code) = status.code() { + std::process::exit(code); + } else { + // Process was terminated by a signal + std::process::exit(1); + } +} diff --git a/qt/launcher/src/bin/build_win.rs b/qt/launcher/src/bin/build_win.rs index ff385d9ea..3ad2c7ce0 100644 --- a/qt/launcher/src/bin/build_win.rs +++ b/qt/launcher/src/bin/build_win.rs @@ -114,6 +114,12 @@ fn copy_files(output_dir: &Path) -> Result<()> { let launcher_dst = output_dir.join("anki.exe"); copy_file(&launcher_src, &launcher_dst)?; + // Copy anki-console binary + let console_src = + PathBuf::from(CARGO_TARGET_DIR).join("x86_64-pc-windows-msvc/release/anki-console.exe"); + let console_dst = output_dir.join("anki-console.exe"); + copy_file(&console_src, &console_dst)?; + // Copy uv.exe and uvw.exe let uv_src = PathBuf::from("../../../out/extracted/uv/uv.exe"); let uv_dst = output_dir.join("uv.exe"); @@ -133,14 +139,12 @@ fn copy_files(output_dir: &Path) -> Result<()> { output_dir.join(".python-version"), )?; - // Copy anki-console.bat - copy_file("anki-console.bat", output_dir.join("anki-console.bat"))?; - Ok(()) } fn sign_binaries(output_dir: &Path) -> Result<()> { sign_file(&output_dir.join("anki.exe"))?; + sign_file(&output_dir.join("anki-console.exe"))?; sign_file(&output_dir.join("uv.exe"))?; Ok(()) } diff --git a/qt/launcher/src/main.rs b/qt/launcher/src/main.rs index 2ad3ac00c..b2535f410 100644 --- a/qt/launcher/src/main.rs +++ b/qt/launcher/src/main.rs @@ -16,21 +16,34 @@ use anki_io::modified_time; use anki_io::read_file; use anki_io::remove_file; use anki_io::write_file; +use anki_io::ToUtf8Path; use anki_process::CommandExt; use anyhow::Context; use anyhow::Result; use crate::platform::ensure_terminal_shown; -use crate::platform::exec_anki; -use crate::platform::get_anki_binary_path; use crate::platform::get_exe_and_resources_dirs; use crate::platform::get_uv_binary_name; -use crate::platform::handle_first_launch; -use crate::platform::initial_terminal_setup; -use crate::platform::launch_anki_detached; +use crate::platform::launch_anki_after_update; +use crate::platform::launch_anki_normally; mod platform; +// todo: -c appearing as app name now + +struct State { + has_existing_install: bool, + prerelease_marker: std::path::PathBuf, + uv_install_root: std::path::PathBuf, + uv_path: std::path::PathBuf, + user_pyproject_path: std::path::PathBuf, + user_python_version_path: std::path::PathBuf, + dist_pyproject_path: std::path::PathBuf, + dist_python_version_path: std::path::PathBuf, + uv_lock_path: std::path::PathBuf, + sync_complete_marker: std::path::PathBuf, +} + #[derive(Debug, Clone)] pub enum VersionKind { PyOxidizer(String), @@ -46,16 +59,8 @@ pub enum MainMenuChoice { Quit, } -#[derive(Debug, Clone, Default)] -pub struct Config { - pub show_console: bool, -} - fn main() { if let Err(e) = run() { - let mut config: Config = Config::default(); - initial_terminal_setup(&mut config); - eprintln!("Error: {:#}", e); eprintln!("Press enter to close..."); let mut input = String::new(); @@ -66,58 +71,92 @@ fn main() { } fn run() -> Result<()> { - let mut config: Config = Config::default(); - let uv_install_root = dirs::data_local_dir() .context("Unable to determine data_dir")? .join("AnkiProgramFiles"); - let sync_complete_marker = uv_install_root.join(".sync_complete"); - let prerelease_marker = uv_install_root.join("prerelease"); let (exe_dir, resources_dir) = get_exe_and_resources_dirs()?; - let dist_pyproject_path = resources_dir.join("pyproject.toml"); - let user_pyproject_path = uv_install_root.join("pyproject.toml"); - let dist_python_version_path = resources_dir.join(".python-version"); - let user_python_version_path = uv_install_root.join(".python-version"); - let uv_lock_path = uv_install_root.join("uv.lock"); - let uv_path: std::path::PathBuf = exe_dir.join(get_uv_binary_name()); + + let state = State { + has_existing_install: uv_install_root.join(".sync_complete").exists(), + prerelease_marker: uv_install_root.join("prerelease"), + uv_install_root: uv_install_root.clone(), + uv_path: exe_dir.join(get_uv_binary_name()), + user_pyproject_path: uv_install_root.join("pyproject.toml"), + user_python_version_path: uv_install_root.join(".python-version"), + dist_pyproject_path: resources_dir.join("pyproject.toml"), + dist_python_version_path: resources_dir.join(".python-version"), + uv_lock_path: uv_install_root.join("uv.lock"), + sync_complete_marker: uv_install_root.join(".sync_complete"), + }; // Create install directory and copy project files in - create_dir_all(&uv_install_root)?; - let had_user_pyproj = user_pyproject_path.exists(); + create_dir_all(&state.uv_install_root)?; + let had_user_pyproj = state.user_pyproject_path.exists(); if !had_user_pyproj { // during initial launcher testing, enable betas by default - write_file(&prerelease_marker, "")?; + write_file(&state.prerelease_marker, "")?; } - copy_if_newer(&dist_pyproject_path, &user_pyproject_path)?; - copy_if_newer(&dist_python_version_path, &user_python_version_path)?; + copy_if_newer(&state.dist_pyproject_path, &state.user_pyproject_path)?; + copy_if_newer( + &state.dist_python_version_path, + &state.user_python_version_path, + )?; - let pyproject_has_changed = !sync_complete_marker.exists() || { - let pyproject_toml_time = modified_time(&user_pyproject_path)?; - let sync_complete_time = modified_time(&sync_complete_marker)?; + let pyproject_has_changed = !state.sync_complete_marker.exists() || { + let pyproject_toml_time = modified_time(&state.user_pyproject_path)?; + let sync_complete_time = modified_time(&state.sync_complete_marker)?; Ok::(pyproject_toml_time > sync_complete_time) } .unwrap_or(true); if !pyproject_has_changed { - // If venv is already up to date, exec as normal - initial_terminal_setup(&mut config); - let anki_bin = get_anki_binary_path(&uv_install_root); - exec_anki(&anki_bin, &config)?; + // If venv is already up to date, launch Anki normally + let args: Vec = std::env::args().skip(1).collect(); + let cmd = build_python_command(&state.uv_install_root, &args)?; + launch_anki_normally(cmd)?; return Ok(()); } - // we'll need to launch uv; reinvoke ourselves in a terminal so the user can see + // If we weren't in a terminal, respawn ourselves in one ensure_terminal_shown()?; + print!("\x1B[2J\x1B[H"); // Clear screen and move cursor to top println!("\x1B[1mAnki Launcher\x1B[0m\n"); - // Check if there's an existing installation before removing marker - let has_existing_install = sync_complete_marker.exists(); + main_menu_loop(&state)?; + // Write marker file to indicate we've completed the sync process + write_sync_marker(&state.sync_complete_marker)?; + + #[cfg(target_os = "macos")] + { + let cmd = build_python_command(&state.uv_install_root, &[])?; + platform::mac::prepare_for_launch_after_update(cmd)?; + } + + if cfg!(unix) && !cfg!(target_os = "macos") { + println!("\nPress enter to start Anki."); + let mut input = String::new(); + let _ = stdin().read_line(&mut input); + } else { + // on Windows/macOS, the user needs to close the terminal/console + // currently, but ideas on how we can avoid this would be good! + println!("Anki will start shortly."); + println!("\x1B[1mYou can close this window.\x1B[0m\n"); + } + + let cmd = build_python_command(&state.uv_install_root, &[])?; + launch_anki_after_update(cmd)?; + + Ok(()) +} + +fn main_menu_loop(state: &State) -> Result<()> { loop { - let menu_choice = get_main_menu_choice(has_existing_install, &prerelease_marker); + let menu_choice = + get_main_menu_choice(state.has_existing_install, &state.prerelease_marker); match menu_choice { MainMenuChoice::Quit => std::process::exit(0), @@ -127,40 +166,40 @@ fn run() -> Result<()> { } MainMenuChoice::ToggleBetas => { // Toggle beta prerelease file - if prerelease_marker.exists() { - let _ = remove_file(&prerelease_marker); + if state.prerelease_marker.exists() { + let _ = remove_file(&state.prerelease_marker); println!("Beta releases disabled."); } else { - write_file(&prerelease_marker, "")?; + write_file(&state.prerelease_marker, "")?; println!("Beta releases enabled."); } println!(); continue; } - _ => { + choice @ (MainMenuChoice::Latest | MainMenuChoice::Version(_)) => { // For other choices, update project files and sync update_pyproject_for_version( - menu_choice.clone(), - dist_pyproject_path.clone(), - user_pyproject_path.clone(), - dist_python_version_path.clone(), - user_python_version_path.clone(), + choice, + state.dist_pyproject_path.clone(), + state.user_pyproject_path.clone(), + state.dist_python_version_path.clone(), + state.user_python_version_path.clone(), )?; // Remove sync marker before attempting sync - let _ = remove_file(&sync_complete_marker); + let _ = remove_file(&state.sync_complete_marker); // Sync the venv - let mut command = Command::new(&uv_path); - command.current_dir(&uv_install_root).args([ + let mut command = Command::new(&state.uv_path); + command.current_dir(&state.uv_install_root).args([ "sync", "--upgrade", "--managed-python", ]); // Add python version if .python-version file exists - if user_python_version_path.exists() { - let python_version = read_file(&user_python_version_path)?; + if state.user_python_version_path.exists() { + let python_version = read_file(&state.user_python_version_path)?; let python_version_str = String::from_utf8(python_version) .context("Invalid UTF-8 in .python-version")?; let python_version_trimmed = python_version_str.trim(); @@ -168,7 +207,7 @@ fn run() -> Result<()> { } // Set UV_PRERELEASE=allow if beta mode is enabled - if prerelease_marker.exists() { + if state.prerelease_marker.exists() { command.env("UV_PRERELEASE", "allow"); } @@ -182,7 +221,7 @@ fn run() -> Result<()> { Err(e) => { // If sync fails due to things like a missing wheel on pypi, // we need to remove the lockfile or uv will cache the bad result. - let _ = remove_file(&uv_lock_path); + let _ = remove_file(&state.uv_lock_path); println!("Install failed: {:#}", e); println!(); continue; @@ -191,22 +230,6 @@ fn run() -> Result<()> { } } } - - // Write marker file to indicate we've completed the sync process - write_sync_marker(&sync_complete_marker)?; - - // First launch - let anki_bin = get_anki_binary_path(&uv_install_root); - handle_first_launch(&anki_bin)?; - - println!("\nPress enter to start Anki."); - - let mut input = String::new(); - let _ = stdin().read_line(&mut input); - - // Then launch the binary as detached subprocess so the terminal can close - launch_anki_detached(&anki_bin, &config)?; - Ok(()) } @@ -403,3 +426,25 @@ fn parse_version_kind(version: &str) -> Option { Some(VersionKind::Uv(version.to_string())) } } + +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(); + if show_console { + uv_install_root.join(".venv/Scripts/python.exe") + } else { + uv_install_root.join(".venv/Scripts/pythonw.exe") + } + } else { + uv_install_root.join(".venv/bin/python") + }; + + let mut cmd = Command::new(python_exe); + cmd.args(["-c", "import aqt; aqt.run()"]); + cmd.args(args); + // tell the Python code it was invoked by the launcher, and updating is + // available + cmd.env("ANKI_LAUNCHER", std::env::current_exe()?.utf8()?.as_str()); + + Ok(cmd) +} diff --git a/qt/launcher/src/platform/mac.rs b/qt/launcher/src/platform/mac.rs index 2369f538a..ab2c4b8fb 100644 --- a/qt/launcher/src/platform/mac.rs +++ b/qt/launcher/src/platform/mac.rs @@ -1,7 +1,8 @@ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html -use std::os::unix::process::CommandExt; +use std::io; +use std::io::Write; use std::process::Command; use std::sync::atomic::AtomicBool; use std::sync::atomic::Ordering; @@ -13,45 +14,7 @@ use anki_process::CommandExt as AnkiCommandExt; use anyhow::Context; use anyhow::Result; -// Re-export Unix functions that macOS uses -pub use super::unix::{ - ensure_terminal_shown, - exec_anki, - get_anki_binary_path, - initial_terminal_setup, -}; - -pub fn launch_anki_detached(anki_bin: &std::path::Path, _config: &crate::Config) -> Result<()> { - use std::process::Stdio; - - let child = Command::new(anki_bin) - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .process_group(0) - .ensure_spawn()?; - std::mem::forget(child); - - println!("Anki will start shortly."); - println!("\x1B[1mYou can close this window.\x1B[0m\n"); - Ok(()) -} - -pub fn relaunch_in_terminal() -> Result<()> { - let current_exe = std::env::current_exe().context("Failed to get current executable path")?; - Command::new("open") - .args(["-a", "Terminal"]) - .arg(current_exe) - .ensure_spawn()?; - std::process::exit(0); -} - -pub fn handle_first_launch(anki_bin: &std::path::Path) -> Result<()> { - use std::io::Write; - use std::io::{ - self, - }; - +pub fn prepare_for_launch_after_update(mut cmd: Command) -> 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(); @@ -67,7 +30,7 @@ pub fn handle_first_launch(anki_bin: &std::path::Path) -> Result<()> { } }); - let _ = Command::new(anki_bin) + let _ = cmd .env("ANKI_FIRST_RUN", "1") .arg("--version") .stdout(std::process::Stdio::null()) @@ -81,22 +44,11 @@ pub fn handle_first_launch(anki_bin: &std::path::Path) -> Result<()> { Ok(()) } -pub fn get_exe_and_resources_dirs() -> Result<(std::path::PathBuf, std::path::PathBuf)> { - let exe_dir = std::env::current_exe() - .context("Failed to get current executable path")? - .parent() - .context("Failed to get executable directory")? - .to_owned(); - - let resources_dir = exe_dir - .parent() - .context("Failed to get parent directory")? - .join("Resources"); - - Ok((exe_dir, resources_dir)) -} - -pub fn get_uv_binary_name() -> &'static str { - // macOS uses standard uv binary name - "uv" +pub fn relaunch_in_terminal() -> Result<()> { + let current_exe = std::env::current_exe().context("Failed to get current executable path")?; + Command::new("open") + .args(["-a", "Terminal"]) + .arg(current_exe) + .ensure_spawn()?; + std::process::exit(0); } diff --git a/qt/launcher/src/platform/mod.rs b/qt/launcher/src/platform/mod.rs index bb7208abe..9dc74f8e9 100644 --- a/qt/launcher/src/platform/mod.rs +++ b/qt/launcher/src/platform/mod.rs @@ -1,18 +1,108 @@ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html -#[cfg(unix)] +#[cfg(all(unix, not(target_os = "macos")))] mod unix; #[cfg(target_os = "macos")] -mod mac; +pub mod mac; #[cfg(target_os = "windows")] -mod windows; +pub mod windows; -#[cfg(target_os = "macos")] -pub use mac::*; -#[cfg(all(unix, not(target_os = "macos")))] -pub use unix::*; -#[cfg(target_os = "windows")] -pub use windows::*; +use std::path::PathBuf; + +use anki_process::CommandExt; +use anyhow::Context; +use anyhow::Result; + +pub fn get_exe_and_resources_dirs() -> Result<(PathBuf, PathBuf)> { + let exe_dir = std::env::current_exe() + .context("Failed to get current executable path")? + .parent() + .context("Failed to get executable directory")? + .to_owned(); + + let resources_dir = if cfg!(target_os = "macos") { + // On macOS, resources are in ../Resources relative to the executable + exe_dir + .parent() + .context("Failed to get parent directory")? + .join("Resources") + } else { + // On other platforms, resources are in the same directory as executable + exe_dir.clone() + }; + + Ok((exe_dir, resources_dir)) +} + +pub fn get_uv_binary_name() -> &'static str { + if cfg!(target_os = "windows") { + "uv.exe" + } else if cfg!(target_os = "macos") { + "uv" + } else if cfg!(target_arch = "x86_64") { + "uv.amd64" + } else { + "uv.arm64" + } +} + +pub fn launch_anki_after_update(mut cmd: std::process::Command) -> Result<()> { + use std::process::Stdio; + + cmd.stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200; + const DETACHED_PROCESS: u32 = 0x00000008; + cmd.creation_flags(CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS); + } + + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + cmd.process_group(0); + } + + let child = cmd.ensure_spawn()?; + std::mem::forget(child); + + Ok(()) +} + +pub fn launch_anki_normally(mut cmd: std::process::Command) -> Result<()> { + #[cfg(windows)] + { + crate::platform::windows::attach_to_parent_console(); + cmd.ensure_success()?; + } + #[cfg(unix)] + cmd.ensure_exec()?; + Ok(()) +} + +#[cfg(windows)] +pub use windows::ensure_terminal_shown; + +#[cfg(unix)] +pub fn ensure_terminal_shown() -> Result<()> { + use std::io::IsTerminal; + + let stdout_is_terminal = IsTerminal::is_terminal(&std::io::stdout()); + if !stdout_is_terminal { + #[cfg(target_os = "macos")] + mac::relaunch_in_terminal()?; + #[cfg(not(target_os = "macos"))] + unix::relaunch_in_terminal()?; + } + + // Set terminal title to "Anki Launcher" + print!("\x1b]2;Anki Launcher\x07"); + Ok(()) +} diff --git a/qt/launcher/src/platform/unix.rs b/qt/launcher/src/platform/unix.rs index 324bf5aa3..0430bfa96 100644 --- a/qt/launcher/src/platform/unix.rs +++ b/qt/launcher/src/platform/unix.rs @@ -1,36 +1,11 @@ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html -#![allow(dead_code)] - -use std::io::IsTerminal; -use std::path::PathBuf; use std::process::Command; -use anki_process::CommandExt as AnkiCommandExt; use anyhow::Context; use anyhow::Result; -use crate::Config; - -pub fn initial_terminal_setup(_config: &mut Config) { - // No special terminal setup needed on Unix -} - -pub fn ensure_terminal_shown() -> Result<()> { - let stdout_is_terminal = IsTerminal::is_terminal(&std::io::stdout()); - if !stdout_is_terminal { - // If launched from GUI, try to relaunch in a terminal - crate::platform::relaunch_in_terminal()?; - } - - // Set terminal title to "Anki Launcher" - print!("\x1b]2;Anki Launcher\x07"); - - Ok(()) -} - -#[cfg(not(target_os = "macos"))] pub fn relaunch_in_terminal() -> Result<()> { let current_exe = std::env::current_exe().context("Failed to get current executable path")?; @@ -72,52 +47,3 @@ pub fn relaunch_in_terminal() -> Result<()> { // If no terminal worked, continue without relaunching Ok(()) } - -pub fn get_anki_binary_path(uv_install_root: &std::path::Path) -> PathBuf { - uv_install_root.join(".venv/bin/anki") -} - -pub fn launch_anki_detached(anki_bin: &std::path::Path, config: &Config) -> Result<()> { - // On non-macOS Unix systems, we don't need to detach since we never spawned a - // terminal - exec_anki(anki_bin, config) -} - -pub fn handle_first_launch(_anki_bin: &std::path::Path) -> Result<()> { - // No special first launch handling needed for generic Unix systems - Ok(()) -} - -pub fn exec_anki(anki_bin: &std::path::Path, _config: &Config) -> Result<()> { - let args: Vec = std::env::args().skip(1).collect(); - Command::new(anki_bin) - .args(args) - .ensure_exec() - .map_err(anyhow::Error::new) -} - -pub fn get_exe_and_resources_dirs() -> Result<(PathBuf, PathBuf)> { - let exe_dir = std::env::current_exe() - .context("Failed to get current executable path")? - .parent() - .context("Failed to get executable directory")? - .to_owned(); - - // On generic Unix systems, assume resources are in the same directory as - // executable - let resources_dir = exe_dir.clone(); - - Ok((exe_dir, resources_dir)) -} - -pub fn get_uv_binary_name() -> &'static str { - // Use architecture-specific uv binary for non-Mac Unix systems - if cfg!(target_arch = "x86_64") { - "uv.amd64" - } else if cfg!(target_arch = "aarch64") { - "uv.arm64" - } else { - // Fallback to generic uv for other architectures - "uv" - } -} diff --git a/qt/launcher/src/platform/windows.rs b/qt/launcher/src/platform/windows.rs index 4e3752d44..0a701c07a 100644 --- a/qt/launcher/src/platform/windows.rs +++ b/qt/launcher/src/platform/windows.rs @@ -1,82 +1,71 @@ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html -use std::path::PathBuf; use std::process::Command; -use anki_process::CommandExt; use anyhow::Context; use anyhow::Result; -use winapi::um::consoleapi; -use winapi::um::errhandlingapi; use winapi::um::wincon; -use crate::Config; - pub fn ensure_terminal_shown() -> Result<()> { - ensure_console(); - // // Check if we're already relaunched to prevent infinite recursion - // if std::env::var("ANKI_LAUNCHER_IN_TERMINAL").is_ok() { - // println!("Recurse: Preparing to start Anki...\n"); - // return Ok(()); - // } - - // if have_console { - // } else { - // relaunch_in_cmd()?; - // } - Ok(()) -} - -fn ensure_console() { unsafe { if !wincon::GetConsoleWindow().is_null() { - return; + // We already have a console, no need to spawn anki-console.exe + return Ok(()); } - - if consoleapi::AllocConsole() == 0 { - let error_code = errhandlingapi::GetLastError(); - eprintln!("unexpected AllocConsole error: {}", error_code); - return; - } - - // This black magic triggers Windows to switch to the new - // ANSI-supporting console host, which is usually only available - // when the app is built with the console subsystem. - let _ = Command::new("cmd").args(&["/C", ""]).status(); } + + if std::env::var("ANKI_IMPLICIT_CONSOLE").is_ok() && attach_to_parent_console() { + // Successfully attached to parent console + reconnect_stdio_to_console(); + return Ok(()); + } + + // No console available, spawn anki-console.exe and exit + let current_exe = std::env::current_exe().context("Failed to get current executable path")?; + let exe_dir = current_exe + .parent() + .context("Failed to get executable directory")?; + + let console_exe = exe_dir.join("anki-console.exe"); + + if !console_exe.exists() { + anyhow::bail!("anki-console.exe not found in the same directory"); + } + + // Spawn anki-console.exe without waiting + Command::new(&console_exe) + .env("ANKI_IMPLICIT_CONSOLE", "1") + .spawn() + .context("Failed to spawn anki-console.exe")?; + + // Exit immediately after spawning + std::process::exit(0); } -fn attach_to_parent_console() -> bool { +pub fn attach_to_parent_console() -> bool { unsafe { if !wincon::GetConsoleWindow().is_null() { // we have a console already - println!("attach: already had console, false"); return false; } if wincon::AttachConsole(wincon::ATTACH_PARENT_PROCESS) != 0 { // successfully attached to parent - println!("attach: true"); + reconnect_stdio_to_console(); true } else { - println!("attach: false"); false } } } -/// If parent process has a console (eg cmd.exe), redirect our output there. -/// Sets config.show_console to true if successfully attached to console. -pub fn initial_terminal_setup(config: &mut Config) { +/// Reconnect stdin/stdout/stderr to the console. +fn reconnect_stdio_to_console() { use std::ffi::CString; use libc_stdhandle::*; - if !attach_to_parent_console() { - return; - } - // we launched without a console, so we'll need to open stdin/out/err let conin = CString::new("CONIN$").unwrap(); let conout = CString::new("CONOUT$").unwrap(); @@ -89,79 +78,4 @@ pub fn initial_terminal_setup(config: &mut Config) { libc::freopen(conout.as_ptr(), w.as_ptr(), stdout()); libc::freopen(conout.as_ptr(), w.as_ptr(), stderr()); } - - config.show_console = true; -} - -pub fn get_anki_binary_path(uv_install_root: &std::path::Path) -> std::path::PathBuf { - uv_install_root.join(".venv/Scripts/anki.exe") -} - -fn build_python_command( - anki_bin: &std::path::Path, - args: &[String], - config: &Config, -) -> Result { - let venv_dir = anki_bin - .parent() - .context("Failed to get venv Scripts directory")? - .parent() - .context("Failed to get venv directory")?; - - // Use python.exe if show_console is true, otherwise pythonw.exe - let python_exe = if config.show_console { - venv_dir.join("Scripts/python.exe") - } else { - venv_dir.join("Scripts/pythonw.exe") - }; - - let mut cmd = Command::new(python_exe); - cmd.args(["-c", "import aqt; aqt.run()"]); - cmd.args(args); - - Ok(cmd) -} - -pub fn launch_anki_detached(anki_bin: &std::path::Path, config: &Config) -> Result<()> { - use std::os::windows::process::CommandExt; - use std::process::Stdio; - - const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200; - const DETACHED_PROCESS: u32 = 0x00000008; - - let mut cmd = build_python_command(anki_bin, &[], config)?; - cmd.stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .creation_flags(CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS) - .ensure_spawn()?; - Ok(()) -} - -pub fn handle_first_launch(_anki_bin: &std::path::Path) -> Result<()> { - Ok(()) -} - -pub fn exec_anki(anki_bin: &std::path::Path, config: &Config) -> Result<()> { - let args: Vec = std::env::args().skip(1).collect(); - let mut cmd = build_python_command(anki_bin, &args, config)?; - cmd.ensure_success()?; - Ok(()) -} - -pub fn get_exe_and_resources_dirs() -> Result<(PathBuf, PathBuf)> { - let exe_dir = std::env::current_exe() - .context("Failed to get current executable path")? - .parent() - .context("Failed to get executable directory")? - .to_owned(); - - // On Windows, resources dir is the same as exe_dir - let resources_dir = exe_dir.clone(); - - Ok((exe_dir, resources_dir)) -} - -pub fn get_uv_binary_name() -> &'static str { - "uv.exe" } diff --git a/qt/launcher/win/anki-console.bat b/qt/launcher/win/anki-console.bat deleted file mode 100644 index a565fa7b6..000000000 --- a/qt/launcher/win/anki-console.bat +++ /dev/null @@ -1,5 +0,0 @@ -@echo off -"%~dp0"\anki %* -pause - - diff --git a/qt/launcher/win/build.bat b/qt/launcher/win/build.bat index b21831462..da574f210 100644 --- a/qt/launcher/win/build.bat +++ b/qt/launcher/win/build.bat @@ -1,5 +1,10 @@ @echo off -set CODESIGN=1 -REM set NO_COMPRESS=1 +if "%NOCOMP%"=="1" ( + set NO_COMPRESS=1 + set CODESIGN=0 +) else ( + set CODESIGN=1 + set NO_COMPRESS=0 +) cargo run --bin build_win diff --git a/rslib/sync/Cargo.toml b/rslib/sync/Cargo.toml index 7a8f8534a..d23b4f380 100644 --- a/rslib/sync/Cargo.toml +++ b/rslib/sync/Cargo.toml @@ -13,4 +13,9 @@ path = "main.rs" name = "anki-sync-server" [dependencies] + +[target.'cfg(windows)'.dependencies] +anki = { workspace = true, features = ["native-tls"] } + +[target.'cfg(not(windows))'.dependencies] anki = { workspace = true, features = ["rustls"] } diff --git a/tools/update-launcher-env b/tools/update-launcher-env new file mode 100755 index 000000000..c84569f55 --- /dev/null +++ b/tools/update-launcher-env @@ -0,0 +1,15 @@ +#!/bin/bash +# +# Install our latest anki/aqt code into the launcher venv + +set -e + +rm -rf out/wheels +./ninja wheels +if [[ "$OSTYPE" == "darwin"* ]]; then + export VIRTUAL_ENV=$HOME/Library/Application\ Support/AnkiProgramFiles/.venv +else + export VIRTUAL_ENV=$HOME/.local/share/AnkiProgramFiles/.venv +fi +./out/extracted/uv/uv pip install out/wheels/* + diff --git a/tools/update-launcher-env.bat b/tools/update-launcher-env.bat new file mode 100644 index 000000000..9b0b814c6 --- /dev/null +++ b/tools/update-launcher-env.bat @@ -0,0 +1,8 @@ +@echo off +rem +rem Install our latest anki/aqt code into the launcher venv + +rmdir /s /q out\wheels 2>nul +call tools\ninja wheels +set VIRTUAL_ENV=%LOCALAPPDATA%\AnkiProgramFiles\.venv +for %%f in (out\wheels\*.whl) do out\extracted\uv\uv pip install "%%f" \ No newline at end of file