diff --git a/Cargo.toml b/Cargo.toml index 61cca8649..2c6eee2af 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", "winreg"] } 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/main.rs b/qt/launcher/src/main.rs index 3cfbca45a..fdfa47e9e 100644 --- a/qt/launcher/src/main.rs +++ b/qt/launcher/src/main.rs @@ -37,6 +37,7 @@ struct State { uv_install_root: std::path::PathBuf, uv_cache_dir: std::path::PathBuf, no_cache_marker: std::path::PathBuf, + anki_base_folder: std::path::PathBuf, uv_path: std::path::PathBuf, user_pyproject_path: std::path::PathBuf, user_python_version_path: std::path::PathBuf, @@ -59,6 +60,7 @@ pub enum MainMenuChoice { Version(VersionKind), ToggleBetas, ToggleCache, + Uninstall, Quit, } @@ -86,6 +88,7 @@ fn run() -> Result<()> { uv_install_root: uv_install_root.clone(), uv_cache_dir: uv_install_root.join("cache"), no_cache_marker: uv_install_root.join("nocache"), + anki_base_folder: get_anki_base_path()?, 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"), @@ -95,6 +98,13 @@ fn run() -> Result<()> { sync_complete_marker: uv_install_root.join(".sync_complete"), }; + // Check for uninstall request from Windows uninstaller + if std::env::var("ANKI_LAUNCHER_UNINSTALL").is_ok() { + ensure_terminal_shown()?; + handle_uninstall(&state)?; + return Ok(()); + } + // Create install directory and copy project files in create_dir_all(&state.uv_install_root)?; let had_user_pyproj = state.user_pyproject_path.exists(); @@ -200,6 +210,12 @@ fn main_menu_loop(state: &State) -> Result<()> { println!(); continue; } + MainMenuChoice::Uninstall => { + if handle_uninstall(state)? { + std::process::exit(0); + } + continue; + } choice @ (MainMenuChoice::Latest | MainMenuChoice::Version(_)) => { // For other choices, update project files and sync update_pyproject_for_version( @@ -295,7 +311,9 @@ fn get_main_menu_choice( "5) Cache downloads: {}", if cache_enabled { "on" } else { "off" } ); - println!("6) Quit"); + println!(); + println!("6) Uninstall"); + println!("7) Quit"); print!("> "); let _ = stdout().flush(); @@ -318,7 +336,8 @@ fn get_main_menu_choice( } "4" => MainMenuChoice::ToggleBetas, "5" => MainMenuChoice::ToggleCache, - "6" => MainMenuChoice::Quit, + "6" => MainMenuChoice::Uninstall, + "7" => MainMenuChoice::Quit, _ => { println!("Invalid input. Please try again."); continue; @@ -378,6 +397,9 @@ fn update_pyproject_for_version( MainMenuChoice::ToggleCache => { unreachable!(); } + MainMenuChoice::Uninstall => { + unreachable!(); + } MainMenuChoice::Version(version_kind) => { let content = read_file(&dist_pyproject_path)?; let content_str = @@ -494,7 +516,7 @@ fn inject_helper_addon(_uv_install_root: &std::path::Path) -> Result<()> { Ok(()) } -fn get_anki_addons21_path() -> Result { +fn get_anki_base_path() -> Result { let anki_base_path = if cfg!(target_os = "windows") { // Windows: %APPDATA%\Anki2 dirs::config_dir() @@ -512,7 +534,61 @@ fn get_anki_addons21_path() -> Result { .join("Anki2") }; - Ok(anki_base_path.join("addons21")) + Ok(anki_base_path) +} + +fn get_anki_addons21_path() -> Result { + Ok(get_anki_base_path()?.join("addons21")) +} + +fn handle_uninstall(state: &State) -> Result { + println!("Uninstall Anki's program files? (y/n)"); + print!("> "); + let _ = stdout().flush(); + + let mut input = String::new(); + let _ = stdin().read_line(&mut input); + let input = input.trim().to_lowercase(); + + if input != "y" { + println!("Uninstall cancelled."); + println!(); + return Ok(false); + } + + // Remove program files + if state.uv_install_root.exists() { + anki_io::remove_dir_all(&state.uv_install_root)?; + println!("Program files removed."); + } + + println!(); + println!("Remove all profiles/cards? (y/n)"); + print!("> "); + let _ = stdout().flush(); + + let mut input = String::new(); + let _ = stdin().read_line(&mut input); + let input = input.trim().to_lowercase(); + + if input == "y" && state.anki_base_folder.exists() { + anki_io::remove_dir_all(&state.anki_base_folder)?; + println!("User data removed."); + } + + println!(); + + // Platform-specific messages + #[cfg(target_os = "macos")] + platform::mac::finalize_uninstall(); + + #[cfg(target_os = "windows")] + platform::windows::finalize_uninstall(); + + #[cfg(all(unix, not(target_os = "macos")))] + platform::unix::finalize_uninstall(); + + Ok(true) } fn build_python_command(uv_install_root: &std::path::Path, args: &[String]) -> Result { diff --git a/qt/launcher/src/platform/mac.rs b/qt/launcher/src/platform/mac.rs index 292e48726..f97d7fd07 100644 --- a/qt/launcher/src/platform/mac.rs +++ b/qt/launcher/src/platform/mac.rs @@ -67,3 +67,32 @@ pub fn relaunch_in_terminal() -> Result<()> { .ensure_spawn()?; std::process::exit(0); } + +pub fn finalize_uninstall() { + if let Ok(exe_path) = std::env::current_exe() { + // Find the .app bundle by walking up the directory tree + let mut app_bundle_path = exe_path.as_path(); + while let Some(parent) = app_bundle_path.parent() { + if let Some(name) = parent.file_name() { + if name.to_string_lossy().ends_with(".app") { + let result = Command::new("trash").arg(parent).output(); + + match result { + Ok(output) if output.status.success() => { + println!("Anki has been uninstalled."); + return; + } + _ => { + // Fall back to manual instructions + println!( + "Please manually drag Anki.app to the trash to complete uninstall." + ); + } + } + return; + } + } + app_bundle_path = parent; + } + } +} diff --git a/qt/launcher/src/platform/mod.rs b/qt/launcher/src/platform/mod.rs index c94f1d1ac..235058757 100644 --- a/qt/launcher/src/platform/mod.rs +++ b/qt/launcher/src/platform/mod.rs @@ -2,7 +2,7 @@ // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html #[cfg(all(unix, not(target_os = "macos")))] -mod unix; +pub mod unix; #[cfg(target_os = "macos")] pub mod mac; diff --git a/qt/launcher/src/platform/unix.rs b/qt/launcher/src/platform/unix.rs index 0430bfa96..f37ec81eb 100644 --- a/qt/launcher/src/platform/unix.rs +++ b/qt/launcher/src/platform/unix.rs @@ -47,3 +47,21 @@ pub fn relaunch_in_terminal() -> Result<()> { // If no terminal worked, continue without relaunching Ok(()) } + +pub fn finalize_uninstall() { + use std::io::stdin; + use std::io::stdout; + use std::io::Write; + + let uninstall_script = std::path::Path::new("/usr/local/share/anki/uninstall.sh"); + + if uninstall_script.exists() { + println!("To finish uninstalling, run 'sudo /usr/local/share/anki/uninstall.sh'"); + } else { + println!("Anki has been uninstalled."); + } + println!("Press enter to quit."); + let _ = stdout().flush(); + let mut input = String::new(); + let _ = stdin().read_line(&mut input); +} diff --git a/qt/launcher/src/platform/windows.rs b/qt/launcher/src/platform/windows.rs index 0a701c07a..ae22b8907 100644 --- a/qt/launcher/src/platform/windows.rs +++ b/qt/launcher/src/platform/windows.rs @@ -1,11 +1,17 @@ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html +use std::ffi::OsStr; +use std::io::stdin; +use std::os::windows::ffi::OsStrExt; use std::process::Command; use anyhow::Context; use anyhow::Result; +use winapi::shared::minwindef::HKEY; use winapi::um::wincon; +use winapi::um::winnt::KEY_READ; +use winapi::um::winreg; pub fn ensure_terminal_shown() -> Result<()> { unsafe { @@ -79,3 +85,122 @@ fn reconnect_stdio_to_console() { libc::freopen(conout.as_ptr(), w.as_ptr(), stderr()); } } + +pub fn finalize_uninstall() { + let uninstaller_path = get_uninstaller_path(); + + match uninstaller_path { + Some(path) => { + println!("Launching Windows uninstaller..."); + let result = Command::new(&path).env("ANKI_LAUNCHER", "1").spawn(); + + match result { + Ok(_) => { + println!("Uninstaller launched successfully."); + return; + } + Err(e) => { + println!("Failed to launch uninstaller: {}", e); + println!("You can manually run: {}", path.display()); + } + } + } + None => { + println!("Windows uninstaller not found."); + println!("You may need to uninstall via Windows Settings > Apps."); + } + } + println!("Press enter to close..."); + let mut input = String::new(); + let _ = stdin().read_line(&mut input); +} + +fn get_uninstaller_path() -> Option { + // Try to read install directory from registry + if let Some(install_dir) = read_registry_install_dir() { + let uninstaller = install_dir.join("uninstall.exe"); + if uninstaller.exists() { + return Some(uninstaller); + } + } + + // Fall back to default location + let default_dir = dirs::data_local_dir()?.join("Programs").join("Anki"); + let uninstaller = default_dir.join("uninstall.exe"); + if uninstaller.exists() { + return Some(uninstaller); + } + + None +} + +fn read_registry_install_dir() -> Option { + unsafe { + let mut hkey: HKEY = std::ptr::null_mut(); + + // Convert the registry path to wide string + let subkey: Vec = OsStr::new("SOFTWARE\\Anki") + .encode_wide() + .chain(std::iter::once(0)) + .collect(); + + // Open the registry key + let result = winreg::RegOpenKeyExW( + winreg::HKEY_CURRENT_USER, + subkey.as_ptr(), + 0, + KEY_READ, + &mut hkey, + ); + + if result != 0 { + return None; + } + + // Query the Install_Dir64 value + let value_name: Vec = OsStr::new("Install_Dir64") + .encode_wide() + .chain(std::iter::once(0)) + .collect(); + + let mut value_type = 0u32; + let mut data_size = 0u32; + + // First call to get the size + let result = winreg::RegQueryValueExW( + hkey, + value_name.as_ptr(), + std::ptr::null_mut(), + &mut value_type, + std::ptr::null_mut(), + &mut data_size, + ); + + if result != 0 || data_size == 0 { + winreg::RegCloseKey(hkey); + return None; + } + + // Allocate buffer and read the value + let mut buffer: Vec = vec![0; (data_size / 2) as usize]; + let result = winreg::RegQueryValueExW( + hkey, + value_name.as_ptr(), + std::ptr::null_mut(), + &mut value_type, + buffer.as_mut_ptr() as *mut u8, + &mut data_size, + ); + + winreg::RegCloseKey(hkey); + + if result == 0 { + // Convert wide string back to PathBuf + let len = buffer.iter().position(|&x| x == 0).unwrap_or(buffer.len()); + let path_str = String::from_utf16_lossy(&buffer[..len]); + Some(std::path::PathBuf::from(path_str)) + } else { + None + } + } +} diff --git a/qt/launcher/win/anki.template.nsi b/qt/launcher/win/anki.template.nsi index 7b2bfd8fc..84dedf9c8 100644 --- a/qt/launcher/win/anki.template.nsi +++ b/qt/launcher/win/anki.template.nsi @@ -250,8 +250,18 @@ FunctionEnd ; Uninstaller function un.onInit - MessageBox MB_OKCANCEL "This will remove Anki's program files, but will not delete your card data. If you wish to delete your card data as well, you can do so via File>Switch Profile inside Anki first. Are you sure you wish to uninstall Anki?" /SD IDOK IDOK next - Quit + ; Check for ANKI_LAUNCHER environment variable + ReadEnvStr $R0 "ANKI_LAUNCHER" + ${If} $R0 != "" + ; Wait for launcher to exit + Sleep 2000 + Goto next + ${Else} + ; Try to launch anki.exe with ANKI_LAUNCHER_UNINSTALL=1 + IfFileExists "$INSTDIR\anki.exe" 0 next + nsExec::Exec 'cmd /c "set ANKI_LAUNCHER_UNINSTALL=1 && start /b "" "$INSTDIR\anki.exe""' + Quit + ${EndIf} next: functionEnd