Add uninstall support to launcher

This commit is contained in:
Damien Elmes 2025-06-29 13:32:06 +07:00
parent f89ab00236
commit 731e7d5b5c
7 changed files with 266 additions and 8 deletions

View file

@ -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"

View file

@ -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<std::path::PathBuf> {
fn get_anki_base_path() -> Result<std::path::PathBuf> {
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<std::path::PathBuf> {
.join("Anki2")
};
Ok(anki_base_path.join("addons21"))
Ok(anki_base_path)
}
fn get_anki_addons21_path() -> Result<std::path::PathBuf> {
Ok(get_anki_base_path()?.join("addons21"))
}
fn handle_uninstall(state: &State) -> Result<bool> {
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<Command> {

View file

@ -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;
}
}
}

View file

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

View file

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

View file

@ -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<std::path::PathBuf> {
// 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<std::path::PathBuf> {
unsafe {
let mut hkey: HKEY = std::ptr::null_mut();
// Convert the registry path to wide string
let subkey: Vec<u16> = 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<u16> = 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<u16> = 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
}
}
}

View file

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