diff --git a/Cargo.toml b/Cargo.toml index 61cca8649..d2ce2ce2a 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"] } +winapi = { version = "0.3", features = ["wincon", "errhandlingapi", "consoleapi"] } 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/qt/launcher/src/bin/build_win.rs b/qt/launcher/src/bin/build_win.rs index 959034438..ff385d9ea 100644 --- a/qt/launcher/src/bin/build_win.rs +++ b/qt/launcher/src/bin/build_win.rs @@ -114,10 +114,13 @@ fn copy_files(output_dir: &Path) -> Result<()> { let launcher_dst = output_dir.join("anki.exe"); copy_file(&launcher_src, &launcher_dst)?; - // Copy uv.exe + // Copy uv.exe and uvw.exe let uv_src = PathBuf::from("../../../out/extracted/uv/uv.exe"); let uv_dst = output_dir.join("uv.exe"); copy_file(&uv_src, &uv_dst)?; + let uv_src = PathBuf::from("../../../out/extracted/uv/uvw.exe"); + let uv_dst = output_dir.join("uvw.exe"); + copy_file(&uv_src, &uv_dst)?; println!("Copying support files..."); diff --git a/qt/launcher/src/main.rs b/qt/launcher/src/main.rs index 4f397ad99..40896caa9 100644 --- a/qt/launcher/src/main.rs +++ b/qt/launcher/src/main.rs @@ -4,28 +4,48 @@ #![windows_subsystem = "windows"] use std::io::stdin; +use std::io::stdout; +use std::io::Write; use std::process::Command; +use std::time::SystemTime; +use std::time::UNIX_EPOCH; use anki_io::copy_if_newer; use anki_io::create_dir_all; use anki_io::modified_time; +use anki_io::read_file; use anki_io::remove_file; use anki_io::write_file; 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::handle_terminal_launch; use crate::platform::initial_terminal_setup; use crate::platform::launch_anki_detached; mod platform; +#[derive(Debug, Clone)] +pub enum VersionKind { + PyOxidizer(String), + Uv(String), +} + +#[derive(Debug, Clone)] +pub enum MainMenuChoice { + Latest, + KeepExisting, + Version(VersionKind), + ToggleBetas, + Quit, +} + #[derive(Debug, Clone, Default)] pub struct Config { pub show_console: bool, @@ -33,6 +53,9 @@ pub struct Config { 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(); @@ -43,8 +66,7 @@ fn main() { } fn run() -> Result<()> { - let mut config = Config::default(); - initial_terminal_setup(&mut config); + let mut config: Config = Config::default(); let uv_install_root = dirs::data_local_dir() .context("Unable to determine data_dir")? @@ -62,60 +84,320 @@ fn run() -> Result<()> { // Create install directory and copy project files in create_dir_all(&uv_install_root)?; + let had_user_pyproj = user_pyproject_path.exists(); + if !had_user_pyproj { + // during initial launcher testing, enable betas by default + write_file(&prerelease_marker, "")?; + } + copy_if_newer(&dist_pyproject_path, &user_pyproject_path)?; copy_if_newer(&dist_python_version_path, &user_python_version_path)?; - let pyproject_has_changed = - !user_pyproject_path.exists() || !sync_complete_marker.exists() || { - let pyproject_toml_time = modified_time(&user_pyproject_path)?; - let sync_complete_time = modified_time(&sync_complete_marker)?; - Ok::(pyproject_toml_time > sync_complete_time) - } - .unwrap_or(true); + 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)?; + 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)?; return Ok(()); } // we'll need to launch uv; reinvoke ourselves in a terminal so the user can see - handle_terminal_launch()?; + ensure_terminal_shown()?; + print!("\x1B[2J\x1B[H"); // Clear screen and move cursor to top + println!("\x1B[1mAnki Launcher\x1B[0m\n"); - // Remove sync marker before attempting sync - let _ = remove_file(&sync_complete_marker); + // Check if there's an existing installation before removing marker + let has_existing_install = sync_complete_marker.exists(); - // Sync the venv - let mut command = Command::new(&uv_path); - command - .current_dir(&uv_install_root) - .args(["sync", "--upgrade", "--managed-python"]); + loop { + let menu_choice = get_main_menu_choice(has_existing_install, &prerelease_marker); - // Set UV_PRERELEASE=allow if prerelease file exists - if prerelease_marker.exists() { - command.env("UV_PRERELEASE", "allow"); + match menu_choice { + MainMenuChoice::Quit => std::process::exit(0), + MainMenuChoice::KeepExisting => { + // Skip sync, just launch existing installation + break; + } + MainMenuChoice::ToggleBetas => { + // Toggle beta prerelease file + if prerelease_marker.exists() { + let _ = remove_file(&prerelease_marker); + println!("Beta releases disabled."); + } else { + write_file(&prerelease_marker, "")?; + println!("Beta releases enabled."); + } + println!(); + continue; + } + _ => { + // 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(), + )?; + + // Remove sync marker before attempting sync + let _ = remove_file(&sync_complete_marker); + + // Sync the venv + let mut command = Command::new(&uv_path); + command.current_dir(&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)?; + let python_version_str = String::from_utf8(python_version) + .context("Invalid UTF-8 in .python-version")?; + let python_version_trimmed = python_version_str.trim(); + command.args(["--python", python_version_trimmed]); + } + + // Set UV_PRERELEASE=allow if beta mode is enabled + if prerelease_marker.exists() { + command.env("UV_PRERELEASE", "allow"); + } + + match command.ensure_success() { + Ok(_) => { + // Sync succeeded, break out of loop + break; + } + 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); + println!("Install failed: {:#}", e); + println!(); + continue; + } + } + } + } } - // temporarily force it on during initial beta testing - command.env("UV_PRERELEASE", "allow"); - - if let Err(e) = command.ensure_success() { - // 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); - return Err(e.into()); - } - - // Write marker file to indicate successful sync - write_file(&sync_complete_marker, "")?; + // 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(()) } + +fn write_sync_marker(sync_complete_marker: &std::path::Path) -> Result<()> { + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .context("Failed to get system time")? + .as_secs(); + write_file(sync_complete_marker, timestamp.to_string())?; + Ok(()) +} + +fn get_main_menu_choice( + has_existing_install: bool, + prerelease_marker: &std::path::Path, +) -> MainMenuChoice { + loop { + println!("1) Latest Anki (just press enter)"); + println!("2) Choose a version"); + if has_existing_install { + println!("3) Keep existing version"); + } + println!(); + + let betas_enabled = prerelease_marker.exists(); + println!( + "4) Allow betas: {}", + if betas_enabled { "on" } else { "off" } + ); + println!("5) Quit"); + print!("> "); + let _ = stdout().flush(); + + let mut input = String::new(); + let _ = stdin().read_line(&mut input); + let input = input.trim(); + + println!(); + + return match input { + "" | "1" => MainMenuChoice::Latest, + "2" => MainMenuChoice::Version(get_version_kind()), + "3" => { + if has_existing_install { + MainMenuChoice::KeepExisting + } else { + println!("Invalid input. Please try again.\n"); + continue; + } + } + "4" => MainMenuChoice::ToggleBetas, + "5" => MainMenuChoice::Quit, + _ => { + println!("Invalid input. Please try again."); + continue; + } + }; + } +} + +fn get_version_kind() -> VersionKind { + loop { + println!("Enter the version you want to install:"); + print!("> "); + let _ = stdout().flush(); + + let mut input = String::new(); + let _ = stdin().read_line(&mut input); + let input = input.trim(); + + if input.is_empty() { + println!("Please enter a version."); + continue; + } + + match parse_version_kind(input) { + Some(version_kind) => { + println!(); + return version_kind; + } + None => { + println!("Invalid version format. Please enter a version like 24.10 or 25.06.1 (minimum 2.1.50)"); + continue; + } + } + } +} + +fn update_pyproject_for_version( + menu_choice: MainMenuChoice, + dist_pyproject_path: std::path::PathBuf, + user_pyproject_path: std::path::PathBuf, + dist_python_version_path: std::path::PathBuf, + user_python_version_path: std::path::PathBuf, +) -> Result<()> { + match menu_choice { + MainMenuChoice::Latest => { + let content = read_file(&dist_pyproject_path)?; + write_file(&user_pyproject_path, &content)?; + let python_version_content = read_file(&dist_python_version_path)?; + write_file(&user_python_version_path, &python_version_content)?; + } + MainMenuChoice::KeepExisting => { + // Do nothing - keep existing pyproject.toml and .python-version + } + MainMenuChoice::ToggleBetas => { + // This should not be reached as ToggleBetas is handled in the loop + unreachable!("ToggleBetas should be handled in the main loop"); + } + MainMenuChoice::Version(version_kind) => { + let content = read_file(&dist_pyproject_path)?; + let content_str = + String::from_utf8(content).context("Invalid UTF-8 in pyproject.toml")?; + let updated_content = match &version_kind { + VersionKind::PyOxidizer(version) => { + // Replace package name and add PyQt6 dependencies + content_str.replace( + "anki-release", + &format!( + concat!( + "aqt[qt6]=={}\",\n", + " \"pyqt6==6.6.1\",\n", + " \"pyqt6-qt6==6.6.2\",\n", + " \"pyqt6-webengine==6.6.0\",\n", + " \"pyqt6-webengine-qt6==6.6.2\",\n", + " \"pyqt6_sip==13.6.0" + ), + version + ), + ) + } + VersionKind::Uv(version) => { + content_str.replace("anki-release", &format!("anki-release=={}", version)) + } + }; + write_file(&user_pyproject_path, &updated_content)?; + + // Update .python-version based on version kind + match &version_kind { + VersionKind::PyOxidizer(_) => { + write_file(&user_python_version_path, "3.9")?; + } + VersionKind::Uv(_) => { + copy_if_newer(&dist_python_version_path, &user_python_version_path)?; + } + } + } + MainMenuChoice::Quit => { + std::process::exit(0); + } + } + Ok(()) +} + +fn parse_version_kind(version: &str) -> Option { + let numeric_chars: String = version + .chars() + .filter(|c| c.is_ascii_digit() || *c == '.') + .collect(); + + let parts: Vec<&str> = numeric_chars.split('.').collect(); + + if parts.len() < 2 { + return None; + } + + let major: u32 = match parts[0].parse() { + Ok(val) => val, + Err(_) => return None, + }; + + let minor: u32 = match parts[1].parse() { + Ok(val) => val, + Err(_) => return None, + }; + + let patch: u32 = if parts.len() >= 3 { + match parts[2].parse() { + Ok(val) => val, + Err(_) => return None, + } + } else { + 0 // Default patch to 0 if not provided + }; + + // Reject versions < 2.1.50 + if major == 2 && (minor != 1 || patch < 50) { + return None; + } + + if major < 25 || (major == 25 && minor < 6) { + Some(VersionKind::PyOxidizer(version.to_string())) + } else { + Some(VersionKind::Uv(version.to_string())) + } +} diff --git a/qt/launcher/src/platform/mac.rs b/qt/launcher/src/platform/mac.rs index b5157dd4b..1e7e4a695 100644 --- a/qt/launcher/src/platform/mac.rs +++ b/qt/launcher/src/platform/mac.rs @@ -28,12 +28,9 @@ pub fn launch_anki_detached(anki_bin: &std::path::Path, _config: &crate::Config) Ok(()) } -pub fn handle_terminal_launch() -> Result<()> { +pub fn ensure_terminal_shown() -> Result<()> { let stdout_is_terminal = std::io::IsTerminal::is_terminal(&std::io::stdout()); - if stdout_is_terminal { - print!("\x1B[2J\x1B[H"); // Clear screen and move cursor to top - println!("\x1B[1mPreparing to start Anki...\x1B[0m\n"); - } else { + if !stdout_is_terminal { // If launched from GUI, relaunch in Terminal.app relaunch_in_terminal()?; } diff --git a/qt/launcher/src/platform/unix.rs b/qt/launcher/src/platform/unix.rs index 4155f39d1..65a223f30 100644 --- a/qt/launcher/src/platform/unix.rs +++ b/qt/launcher/src/platform/unix.rs @@ -16,9 +16,7 @@ pub fn initial_terminal_setup(_config: &mut Config) { // No special terminal setup needed on Unix } -pub fn handle_terminal_launch() -> Result<()> { - print!("\x1B[2J\x1B[H"); // Clear screen and move cursor to top - println!("\x1B[1mPreparing to start Anki...\x1B[0m\n"); +pub fn ensure_terminal_shown() -> Result<()> { // Skip terminal relaunch on non-macOS Unix systems as we don't know which // terminal is installed Ok(()) diff --git a/qt/launcher/src/platform/windows.rs b/qt/launcher/src/platform/windows.rs index c536c8c76..4e3752d44 100644 --- a/qt/launcher/src/platform/windows.rs +++ b/qt/launcher/src/platform/windows.rs @@ -7,27 +7,77 @@ 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 handle_terminal_launch() -> Result<()> { - // uv will do this itself +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; + } + + 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(); + } +} + +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"); + 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) { use std::ffi::CString; use libc_stdhandle::*; - use winapi::um::wincon; - let console_attached = unsafe { wincon::AttachConsole(wincon::ATTACH_PARENT_PROCESS) }; - if console_attached == 0 { + 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(); let r = CString::new("r").unwrap(); @@ -113,6 +163,5 @@ pub fn get_exe_and_resources_dirs() -> Result<(PathBuf, PathBuf)> { } pub fn get_uv_binary_name() -> &'static str { - // Windows uses standard uv binary name "uv.exe" }