From 6d6df1c91d9a3f0c0b213196578c2e7cb6c10ef8 Mon Sep 17 00:00:00 2001 From: llama Date: Sat, 18 Oct 2025 09:55:07 +0800 Subject: [PATCH] refactor state and uv code --- qt/launcher-gui/src-tauri/src/platform/mac.rs | 35 - qt/launcher-gui/src-tauri/src/platform/mod.rs | 56 +- qt/launcher-gui/src-tauri/src/state.rs | 122 ++++ qt/launcher-gui/src-tauri/src/uv.rs | 645 ++++++++++-------- 4 files changed, 524 insertions(+), 334 deletions(-) create mode 100644 qt/launcher-gui/src-tauri/src/state.rs diff --git a/qt/launcher-gui/src-tauri/src/platform/mac.rs b/qt/launcher-gui/src-tauri/src/platform/mac.rs index 3f5b0ce2e..bc9759fce 100644 --- a/qt/launcher-gui/src-tauri/src/platform/mac.rs +++ b/qt/launcher-gui/src-tauri/src/platform/mac.rs @@ -1,35 +1,14 @@ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html -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; -use std::sync::Arc; -use std::thread; -use std::time::Duration; use anki_process::CommandExt as AnkiCommandExt; -use anyhow::Context; use anyhow::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(); - - // Start progress indicator - let running = Arc::new(AtomicBool::new(true)); - let running_clone = running.clone(); - let progress_thread = thread::spawn(move || { - while running_clone.load(Ordering::Relaxed) { - print!("."); - io::stdout().flush().unwrap(); - thread::sleep(Duration::from_secs(1)); - } - }); let _ = cmd .env("ANKI_FIRST_RUN", "1") @@ -52,23 +31,9 @@ pub fn prepare_for_launch_after_update(mut cmd: Command, root: &Path) -> Result< } } - // Stop progress indicator - running.store(false, Ordering::Relaxed); - progress_thread.join().unwrap(); - println!(); // New line after dots 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(["-na", "Terminal"]) - .arg(current_exe) - .env_remove("ANKI_LAUNCHER_WANT_TERMINAL") - .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 diff --git a/qt/launcher-gui/src-tauri/src/platform/mod.rs b/qt/launcher-gui/src-tauri/src/platform/mod.rs index 20fbbc5e5..07af93720 100644 --- a/qt/launcher-gui/src-tauri/src/platform/mod.rs +++ b/qt/launcher-gui/src-tauri/src/platform/mod.rs @@ -4,7 +4,7 @@ #[cfg(all(unix, not(target_os = "macos")))] pub mod unix; -#[cfg(target_os = "macos")] +// #[cfg(target_os = "macos")] pub mod mac; #[cfg(target_os = "windows")] @@ -49,6 +49,58 @@ pub fn get_uv_binary_name() -> &'static str { } } +pub fn respawn_launcher() -> Result<()> { + use std::process::Stdio; + + let mut launcher_cmd = if cfg!(target_os = "macos") { + // On macOS, we need to launch the .app bundle, not the executable directly + let current_exe = + std::env::current_exe().context("Failed to get current executable path")?; + + // Navigate from Contents/MacOS/launcher to the .app bundle + let app_bundle = current_exe + .parent() // MacOS + .and_then(|p| p.parent()) // Contents + .and_then(|p| p.parent()) // .app + .context("Failed to find .app bundle")?; + + let mut cmd = std::process::Command::new("open"); + cmd.arg(app_bundle); + cmd + } else { + let current_exe = + std::env::current_exe().context("Failed to get current executable path")?; + std::process::Command::new(current_exe) + }; + + launcher_cmd + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + + // TODO: remove + launcher_cmd.env("ANKI_LAUNCHER_SKIP", "1"); + + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200; + const DETACHED_PROCESS: u32 = 0x00000008; + launcher_cmd.creation_flags(CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS); + } + + #[cfg(all(unix, not(target_os = "macos")))] + { + use std::os::unix::process::CommandExt; + launcher_cmd.process_group(0); + } + + let child = launcher_cmd.ensure_spawn()?; + std::mem::forget(child); + + Ok(()) +} + pub fn launch_anki_normally(mut cmd: std::process::Command) -> Result<()> { #[cfg(windows)] { @@ -56,7 +108,7 @@ pub fn launch_anki_normally(mut cmd: std::process::Command) -> Result<()> { cmd.ensure_spawn()?; } #[cfg(unix)] - cmd.ensure_spawn()?; + cmd.ensure_exec()?; Ok(()) } diff --git a/qt/launcher-gui/src-tauri/src/state.rs b/qt/launcher-gui/src-tauri/src/state.rs new file mode 100644 index 000000000..09cde5ef4 --- /dev/null +++ b/qt/launcher-gui/src-tauri/src/state.rs @@ -0,0 +1,122 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +use std::sync::Arc; + +pub use anki_proto::launcher::ExistingVersions; +use anki_proto::launcher::Mirror; +use anki_proto::launcher::Options as OptionsProto; +pub use anki_proto::launcher::Version; +pub use anki_proto::launcher::Versions; +use anyhow::anyhow; +use anyhow::Result; +use tokio::sync::watch; + +use crate::uv; + +pub struct Options { + allow_betas: bool, + download_caching: bool, + mirror: Mirror, +} + +impl From<&Options> for OptionsProto { + fn from(o: &Options) -> Self { + Self { + allow_betas: o.allow_betas, + download_caching: o.download_caching, + mirror: o.mirror.into(), + } + } +} + +impl From<&uv::Paths> for Options { + fn from(state: &uv::Paths) -> Self { + let allow_betas = state.prerelease_marker.exists(); + let download_caching = !state.no_cache_marker.exists(); + let mirror = if state.mirror_path.exists() { + Mirror::China + } else { + Mirror::Disabled + }; + Self { + allow_betas, + download_caching, + mirror, + } + } +} + +pub struct NormalState { + pub paths: Arc, + pub initial_options: Options, + pub current_versions: Option>>>, + pub available_versions: Option>>>, +} + +impl From for NormalState { + fn from(paths: uv::Paths) -> Self { + Self { + initial_options: Options::from(&paths), + current_versions: None, + available_versions: None, + paths: paths.into(), + } + } +} + +impl From for State { + fn from(state: NormalState) -> Self { + Self::Normal(state) + } +} + +pub enum State { + LaunchAnki(Arc), + Error(anyhow::Error), + Uninstall(Arc), + Normal(NormalState), +} + +impl State { + pub fn normal(&self) -> Result<&NormalState> { + match self { + State::Normal(state) => Ok(state), + _ => Err(anyhow!("unexpected state")), + } + } + + pub fn paths(&self) -> Result<&uv::Paths> { + match self { + State::LaunchAnki(paths) => Ok(paths), + State::Uninstall(paths) => Ok(paths), + State::Normal(state) => Ok(&state.paths), + _ => Err(anyhow!("unexpected state")), + } + } + + pub fn should_launch_anki(&self) -> bool { + matches!(self, State::LaunchAnki(_)) + } +} + +impl NormalState { + pub fn check_versions(&mut self) { + let (av_tx, av_rx) = tokio::sync::watch::channel(None); + let paths = self.paths.clone(); + tauri::async_runtime::spawn_blocking(move || { + let res = paths.get_releases(); + let _ = av_tx.send(Some(res)); + }); + + let (cv_tx, cv_rx) = tokio::sync::watch::channel(None); + let paths = self.paths.clone(); + tauri::async_runtime::spawn_blocking(move || { + let res = paths.check_versions(); + let _ = cv_tx.send(Some(res)); + }); + + self.current_versions = Some(cv_rx); + self.available_versions = Some(av_rx); + } +} diff --git a/qt/launcher-gui/src-tauri/src/uv.rs b/qt/launcher-gui/src-tauri/src/uv.rs index 05212dcfd..227bf3f3f 100644 --- a/qt/launcher-gui/src-tauri/src/uv.rs +++ b/qt/launcher-gui/src-tauri/src/uv.rs @@ -19,19 +19,19 @@ use anki_process::CommandExt as AnkiCommandExt; use anyhow::anyhow; use anyhow::Context; use anyhow::Result; -use tauri::AppHandle; -use tauri::Emitter; -use tauri::Runtime; use crate::platform; use crate::platform::ensure_os_supported; use crate::platform::get_exe_and_resources_dirs; use crate::platform::get_uv_binary_name; pub use crate::platform::launch_anki_normally; +use crate::platform::respawn_launcher; +use crate::state::ExistingVersions; +use crate::state::Version; +use crate::state::Versions; #[derive(Debug, Clone)] -pub struct State { - pub current_version: Option, +pub struct Paths { pub prerelease_marker: std::path::PathBuf, uv_install_root: std::path::PathBuf, uv_cache_dir: std::path::PathBuf, @@ -48,7 +48,6 @@ pub struct State { launcher_trigger_file: std::path::PathBuf, pub mirror_path: std::path::PathBuf, pub pyproject_modified_by_user: bool, - previous_version: Option, resources_dir: std::path::PathBuf, venv_folder: std::path::PathBuf, /// system Python + PyQt6 library mode @@ -61,186 +60,25 @@ pub enum VersionKind { Uv(String), } -#[derive(Debug)] -pub struct Releases { - pub latest: Vec, - pub all: Vec, -} - -pub fn init_state() -> Result> { - let uv_install_root = if let Ok(custom_root) = std::env::var("ANKI_LAUNCHER_VENV_ROOT") { - std::path::PathBuf::from(custom_root) - } else { - dirs::data_local_dir() - .context("Unable to determine data_dir")? - .join("AnkiProgramFiles") - }; - - let (exe_dir, resources_dir) = get_exe_and_resources_dirs()?; - - let mut state = State { - // TODO: return error instead of relying on member field here if os unsupported - current_version: None, - prerelease_marker: uv_install_root.join("prerelease"), - 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()), - uv_python_install_dir: uv_install_root.join("python"), - 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"), - launcher_trigger_file: uv_install_root.join(".want-launcher"), - mirror_path: uv_install_root.join("mirror"), - pyproject_modified_by_user: false, // calculated later - previous_version: None, - system_qt: (cfg!(unix) && !cfg!(target_os = "macos")) - && resources_dir.join("system_qt").exists(), - resources_dir, - venv_folder: uv_install_root.join(".venv"), - }; - - // Check for uninstall request from Windows uninstaller - if std::env::var("ANKI_LAUNCHER_UNINSTALL").is_ok() { - // handle_uninstall(&state)?; - println!("TODO: UNINSTALL"); - return Ok(None); - } - - // Create install directory - create_dir_all(&state.uv_install_root)?; - - let launcher_requested = - state.launcher_trigger_file.exists() || !state.user_pyproject_path.exists(); - - // Calculate whether user has custom edits that need syncing - let pyproject_time = file_timestamp_secs(&state.user_pyproject_path); - let sync_time = file_timestamp_secs(&state.sync_complete_marker); - state.pyproject_modified_by_user = pyproject_time > sync_time; - let pyproject_has_changed = state.pyproject_modified_by_user; - - let debug = cfg!(debug_assertions); - - if !launcher_requested && !pyproject_has_changed && !debug { - let args: Vec = std::env::args().skip(1).collect(); - let cmd = build_python_command(&state, &args)?; - launch_anki_normally(cmd)?; - return Ok(None); - } - - if launcher_requested { - // Remove the trigger file to make request ephemeral - let _ = remove_file(&state.launcher_trigger_file); - } - - // TODO: - let _ = ensure_os_supported(); - - // TODO: we should call this here instead of via getVersions - // check_versions(&mut state); - - Ok(Some(state)) -} - -pub fn post_install(state: &State) -> Result<()> { - // Write marker file to indicate we've completed the sync process - write_sync_marker(state)?; - - #[cfg(target_os = "macos")] - { - let cmd = build_python_command(&state, &[])?; - platform::mac::prepare_for_launch_after_update(cmd, &uv_install_root)?; - } - - // respawn the launcher as a disconnected subprocess for normal startup - // respawn_launcher()?; - let args: Vec = std::env::args().skip(1).collect(); - let cmd = build_python_command(state, &args)?; - launch_anki_normally(cmd)?; - - Ok(()) -} - -fn extract_aqt_version(state: &State) -> Option { - // Check if .venv exists first - if !state.venv_folder.exists() { - return None; - } - - let output = uv_command(state) - .ok()? - .env("VIRTUAL_ENV", &state.venv_folder) - .args(["pip", "show", "aqt"]) - .output(); - - let output = output.ok()?; - - if !output.status.success() { - return None; - } - - let stdout = String::from_utf8(output.stdout).ok()?; - for line in stdout.lines() { - if let Some(version) = line.strip_prefix("Version: ") { - return Some(version.trim().to_string()); - } - } - None -} - -pub fn check_versions(state: &mut State) -> Result<(Option, Option)> { - // If sync_complete_marker is missing, do nothing - if !state.sync_complete_marker.exists() { - return Ok((None, None)); - } - - // Determine current version by invoking uv pip show aqt - match extract_aqt_version(state) { - Some(version) => { - state.current_version = Some(normalize_version(&version)); - } - None => { - Err(anyhow::anyhow!( - "Warning: Could not determine current Anki version" - ))?; - } - } - - // Read previous version from "previous-version" file - let previous_version_path = state.uv_install_root.join("previous-version"); - if let Ok(content) = read_file(&previous_version_path) { - if let Ok(version_str) = String::from_utf8(content) { - let version = version_str.trim().to_string(); - if !version.is_empty() { - state.previous_version = Some(normalize_version(&version)); - } - } - } - - Ok(( - state.current_version.clone(), - state.previous_version.clone(), - )) -} - -pub fn handle_version_install_or_update( - app: AppHandle, - state: &State, +pub fn handle_version_install_or_update( + state: &Paths, version: &str, keep_existing: bool, -) -> Result<()> { - let version_kind = - parse_version_kind(version).ok_or_else(|| anyhow!("{version} is not a valid version!"))?; + previous_version_to_save: Option<&str>, + on_pty_data: F, +) -> Result<()> +where + F: Fn(String) + Send + 'static, +{ + let version_kind = parse_version_kind(version) + .ok_or_else(|| anyhow!(r#""{version}" is not a valid version!"#))?; if !keep_existing { apply_version_kind(&version_kind, state)?; } + // TODO: support this // Extract current version before syncing (but don't write to file yet) - let previous_version_to_save = state.current_version.clone(); + // let previous_version_to_save = state.current_version.clone(); // Remove sync marker before attempting sync let _ = remove_file(&state.sync_complete_marker); @@ -326,15 +164,15 @@ pub fn handle_version_install_or_update( .openpty(portable_pty::PtySize { // NOTE: must be the same as xterm.js', otherwise text won't wrap // TODO: maybe don't hardcode? - rows: 12, - cols: 60, + rows: 10, + cols: 50, pixel_width: 0, pixel_height: 0, }) - .unwrap(); + .with_context(|| "failed to open pty")?; - let mut reader = pair.master.try_clone_reader().unwrap(); - let mut writer = pair.master.take_writer().unwrap(); + let mut reader = pair.master.try_clone_reader()?; + let mut writer = pair.master.take_writer()?; tauri::async_runtime::spawn_blocking(move || { let mut buf = [0u8; 1024]; @@ -345,13 +183,13 @@ pub fn handle_version_install_or_update( Ok(0) => break, Ok(n) => { let output = String::from_utf8_lossy(&buf[..n]).to_string(); - // NOTE: windows requests curspr position before actually running child + // NOTE: windows requests cursor position before actually running child if output == "\x1b[6n" { writeln!(&mut writer, "\x1b[0;0R").unwrap(); } // cheaper to base64ise a string than jsonify an [u8] let data = data_encoding::BASE64.encode(&buf[..n]); - let _ = app.emit("pty-data", data); + on_pty_data(data); } Err(e) => { eprintln!("Error reading from PTY: {}", e); @@ -361,6 +199,8 @@ pub fn handle_version_install_or_update( } }); + let cmdline = command.as_unix_command_line()?; + let mut child = pair.slave.spawn_command(command).unwrap(); drop(pair.slave); println!("waiting on uv..."); @@ -368,8 +208,8 @@ pub fn handle_version_install_or_update( println!("uv exited with status: {:?}", status); match status { - Ok(_) => { - // Sync succeeded + // Sync succeeded + Ok(exit_status) if exit_status.success() => { if !keep_existing && matches!(version_kind, VersionKind::PyOxidizer(_)) { inject_helper_addon()?; } @@ -377,7 +217,7 @@ pub fn handle_version_install_or_update( // Now that sync succeeded, save the previous version if let Some(current_version) = previous_version_to_save { let previous_version_path = state.uv_install_root.join("previous-version"); - if let Err(e) = write_file(&previous_version_path, ¤t_version) { + if let Err(e) = write_file(&previous_version_path, current_version) { // TODO: println!("Warning: Could not save previous version: {e}"); } @@ -385,19 +225,21 @@ pub fn handle_version_install_or_update( Ok(()) } - Err(e) => { - // TODO: - // 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. + // 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. + Ok(exit_status) => { + let _ = remove_file(&state.uv_lock_path); + let code = exit_status.exit_code(); + Err(anyhow!("Failed to run ({code}): {cmdline}")) + } + Err(e) => { let _ = remove_file(&state.uv_lock_path); - println!("Install failed: {e:#}"); - println!(); Err(e.into()) } } } -pub fn set_allow_betas(state: &State, allow_betas: bool) -> Result<()> { +pub fn set_allow_betas(state: &Paths, allow_betas: bool) -> Result<()> { if allow_betas { write_file(&state.prerelease_marker, "")?; } else { @@ -406,7 +248,7 @@ pub fn set_allow_betas(state: &State, allow_betas: bool) -> Result<()> { Ok(()) } -pub fn set_cache_enabled(state: &State, cache_enabled: bool) -> Result<()> { +pub fn set_cache_enabled(state: &Paths, cache_enabled: bool) -> Result<()> { if cache_enabled { let _ = remove_file(&state.no_cache_marker); } else { @@ -419,15 +261,6 @@ pub fn set_cache_enabled(state: &State, cache_enabled: bool) -> Result<()> { Ok(()) } -fn write_sync_marker(state: &State) -> Result<()> { - let timestamp = SystemTime::now() - .duration_since(UNIX_EPOCH) - .context("Failed to get system time")? - .as_secs(); - write_file(&state.sync_complete_marker, timestamp.to_string())?; - Ok(()) -} - /// Get mtime of provided file, or 0 if unavailable fn file_timestamp_secs(path: &std::path::Path) -> i64 { modified_time(path) @@ -435,13 +268,13 @@ fn file_timestamp_secs(path: &std::path::Path) -> i64 { .unwrap_or_default() } -fn with_only_latest_patch(versions: &[String]) -> Vec { +fn with_only_latest_patch(versions: &[Version]) -> Vec { // Only show the latest patch release for a given (major, minor) let mut seen_major_minor = std::collections::HashSet::new(); versions .iter() .filter(|v| { - let (major, minor, _, _) = parse_version_for_filtering(v); + let (major, minor, _, _) = parse_version_for_filtering(&v.version); if major == 2 { return true; } @@ -481,12 +314,15 @@ fn parse_version_for_filtering(version_str: &str) -> (u32, u32, u32, bool) { (major, minor, patch, is_prerelease) } -fn normalize_version(version: &str) -> String { - let (major, minor, patch, _is_prerelease) = parse_version_for_filtering(version); +fn normalize_version(version: &str) -> Version { + let (major, minor, patch, is_prerelease) = parse_version_for_filtering(version); if major <= 2 { // Don't transform versions <= 2.x - return version.to_string(); + return Version { + version: version.to_string(), + is_prerelease, + }; } // For versions > 2, pad the minor version with leading zero if < 10 @@ -516,18 +352,20 @@ fn normalize_version(version: &str) -> String { } // Reconstruct the version - if version.matches('.').count() >= 2 { + let version = if version.matches('.').count() >= 2 { format!("{major}.{normalized_minor}.{patch}{prerelease_suffix}") } else { format!("{major}.{normalized_minor}{prerelease_suffix}") + }; + + Version { + version, + is_prerelease, } } -fn filter_and_normalize_versions( - all_versions: Vec, - include_prereleases: bool, -) -> Vec { - let mut valid_versions: Vec = all_versions +fn filter_and_normalize_versions1(all_versions: Vec) -> Vec { + let mut valid_versions: Vec = all_versions .into_iter() .map(|v| normalize_version(&v)) .collect(); @@ -535,57 +373,10 @@ fn filter_and_normalize_versions( // Reverse to get chronological order (newest first) valid_versions.reverse(); - if !include_prereleases { - valid_versions.retain(|v| { - let (_, _, _, is_prerelease) = parse_version_for_filtering(v); - !is_prerelease - }); - } - valid_versions } -fn fetch_versions(state: &State) -> Result> { - let versions_script = state.resources_dir.join("versions.py"); - - let mut cmd = uv_command(state)?; - cmd.args(["run", "--no-project", "--no-config", "--managed-python"]) - .args(["--with", "pip-system-certs,requests[socks]"]); - - let python_version = read_file(&state.dist_python_version_path)?; - let python_version_str = - String::from_utf8(python_version).context("Invalid UTF-8 in .python-version")?; - let version_trimmed = python_version_str.trim(); - if !version_trimmed.is_empty() { - cmd.args(["--python", version_trimmed]); - } - - cmd.arg(&versions_script); - - let output = match cmd.utf8_output() { - Ok(output) => output, - Err(e) => { - return Err(e.into()); - } - }; - let versions = serde_json::from_str(&output.stdout).context("Failed to parse versions JSON")?; - Ok(versions) -} - -pub fn get_releases(state: &State) -> Result { - let include_prereleases = state.prerelease_marker.exists(); - let all_versions = fetch_versions(state)?; - let all_versions = filter_and_normalize_versions(all_versions, include_prereleases); - - let latest_patches = with_only_latest_patch(&all_versions); - let latest_releases: Vec = latest_patches.into_iter().take(5).collect(); - Ok(Releases { - latest: latest_releases, - all: all_versions, - }) -} - -pub fn apply_version_kind(version_kind: &VersionKind, state: &State) -> Result<()> { +pub fn apply_version_kind(version_kind: &VersionKind, state: &Paths) -> Result<()> { let content = read_file(&state.dist_pyproject_path)?; let content_str = String::from_utf8(content).context("Invalid UTF-8 in pyproject.toml")?; let updated_content = match version_kind { @@ -745,7 +536,7 @@ fn get_anki_addons21_path() -> Result { // TODO: revert #[allow(unused)] -fn handle_uninstall(state: &State) -> Result { +fn handle_uninstall(state: &Paths) -> Result { // println!("{}", state.tr.launcher_uninstall_confirm()); print!("> "); let _ = stdout().flush(); @@ -795,7 +586,7 @@ fn handle_uninstall(state: &State) -> Result { Ok(true) } -fn uv_command(state: &State) -> Result { +fn uv_command(state: &Paths) -> Result { let mut command = Command::new(&state.uv_path); command.current_dir(&state.uv_install_root); @@ -825,7 +616,7 @@ fn uv_command(state: &State) -> Result { Ok(command) } -fn uv_pty_command(state: &State) -> Result { +fn uv_pty_command(state: &Paths) -> Result { let mut command = portable_pty::CommandBuilder::new(&state.uv_path); command.cwd(&state.uv_install_root); @@ -847,34 +638,7 @@ fn uv_pty_command(state: &State) -> Result { Ok(command) } -pub fn build_python_command(state: &State, args: &[String]) -> Result { - let python_exe = if cfg!(target_os = "windows") { - let show_console = std::env::var("ANKI_CONSOLE").is_ok(); - if show_console { - state.venv_folder.join("Scripts/python.exe") - } else { - state.venv_folder.join("Scripts/pythonw.exe") - } - } else { - state.venv_folder.join("bin/python") - }; - - let mut cmd = Command::new(&python_exe); - cmd.args(["-c", "import aqt, sys; sys.argv[0] = 'Anki'; 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()); - - // Set UV and Python paths for the Python code - cmd.env("ANKI_LAUNCHER_UV", state.uv_path.utf8()?.as_str()); - cmd.env("UV_PROJECT", state.uv_install_root.utf8()?.as_str()); - cmd.env_remove("SSLKEYLOGFILE"); - - Ok(cmd) -} - -fn get_mirror_urls(state: &State) -> Result> { +fn get_mirror_urls(state: &Paths) -> Result> { if !state.mirror_path.exists() { return Ok(None); } @@ -893,7 +657,7 @@ fn get_mirror_urls(state: &State) -> Result> { } } -pub fn set_mirror(state: &State, enabled: bool) -> Result<()> { +pub fn set_mirror(state: &Paths, enabled: bool) -> Result<()> { if enabled { let china_mirrors = "https://registry.npmmirror.com/-/binary/python-build-standalone/\nhttps://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple/"; write_file(&state.mirror_path, china_mirrors)?; @@ -903,12 +667,299 @@ pub fn set_mirror(state: &State, enabled: bool) -> Result<()> { Ok(()) } +impl crate::state::State { + pub fn init() -> Result { + let uv_install_root = if let Ok(custom_root) = std::env::var("ANKI_LAUNCHER_VENV_ROOT") { + std::path::PathBuf::from(custom_root) + } else { + dirs::data_local_dir() + .context("Unable to determine data_dir")? + .join("AnkiProgramFiles") + }; + + let (exe_dir, resources_dir) = get_exe_and_resources_dirs()?; + + let mut paths = Paths { + prerelease_marker: uv_install_root.join("prerelease"), + 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()), + uv_python_install_dir: uv_install_root.join("python"), + 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"), + launcher_trigger_file: uv_install_root.join(".want-launcher"), + mirror_path: uv_install_root.join("mirror"), + pyproject_modified_by_user: false, // calculated later + system_qt: (cfg!(unix) && !cfg!(target_os = "macos")) + && resources_dir.join("system_qt").exists(), + resources_dir, + venv_folder: uv_install_root.join(".venv"), + }; + + // Check for uninstall request from Windows uninstaller + if std::env::var("ANKI_LAUNCHER_UNINSTALL").is_ok() { + return Ok(Self::Uninstall(paths.into())); + } + + // Create install directory + create_dir_all(&paths.uv_install_root)?; + + let launcher_requested = + paths.launcher_trigger_file.exists() || !paths.user_pyproject_path.exists(); + + // TODO: remove + let skip = std::env::var("ANKI_LAUNCHER_SKIP").is_ok(); + + // Calculate whether user has custom edits that need syncing + let pyproject_time = file_timestamp_secs(&paths.user_pyproject_path); + let sync_time = file_timestamp_secs(&paths.sync_complete_marker); + paths.pyproject_modified_by_user = pyproject_time > sync_time; + let pyproject_has_changed = paths.pyproject_modified_by_user; + + #[allow(clippy::nonminimal_bool)] + let debug = true && cfg!(debug_assertions); + + if !launcher_requested && !pyproject_has_changed && (!debug || skip) { + return Ok(Self::LaunchAnki(paths.into())); + } + + if launcher_requested { + // Remove the trigger file to make request ephemeral + let _ = remove_file(&paths.launcher_trigger_file); + } + + ensure_os_supported()?; + + Ok(Self::Normal(paths.into())) + } +} + +impl Paths { + fn get_mirror_urls(&self) -> Result> { + if !self.mirror_path.exists() { + return Ok(None); + } + + let content = read_file(&self.mirror_path)?; + let content_str = String::from_utf8(content).context("Invalid UTF-8 in mirror file")?; + + let lines: Vec<&str> = content_str.lines().collect(); + if lines.len() >= 2 { + Ok(Some(( + lines[0].trim().to_string(), + lines[1].trim().to_string(), + ))) + } else { + Ok(None) + } + } + + fn uv_command(&self) -> Result { + let mut command = Command::new(&self.uv_path); + command.current_dir(&self.uv_install_root); + + // remove UV_* environment variables to avoid interference + for (key, _) in std::env::vars() { + if key.starts_with("UV_") { + command.env_remove(key); + } + } + command + .env_remove("VIRTUAL_ENV") + .env_remove("SSLKEYLOGFILE"); + + // Add mirror environment variable if enabled + if let Some((python_mirror, pypi_mirror)) = self.get_mirror_urls()? { + command + .env("UV_PYTHON_INSTALL_MIRROR", &python_mirror) + .env("UV_DEFAULT_INDEX", &pypi_mirror); + } + + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + + command.creation_flags(windows::Win32::System::Threading::CREATE_NO_WINDOW.0); + } + Ok(command) + } + + fn fetch_versions(&self) -> Result> { + let versions_script = self.resources_dir.join("versions.py"); + + let mut cmd = self.uv_command()?; + cmd.args(["run", "--no-project", "--no-config", "--managed-python"]) + .args(["--with", "pip-system-certs,requests[socks]"]); + + let python_version = read_file(&self.dist_python_version_path)?; + let python_version_str = + String::from_utf8(python_version).context("Invalid UTF-8 in .python-version")?; + let version_trimmed = python_version_str.trim(); + if !version_trimmed.is_empty() { + cmd.args(["--python", version_trimmed]); + } + + cmd.arg(&versions_script); + + let output = match cmd.utf8_output() { + Ok(output) => output, + Err(e) => { + return Err(e.into()); + } + }; + let versions = + serde_json::from_str(&output.stdout).context("Failed to parse versions JSON")?; + Ok(versions) + } + + pub fn get_releases(&self) -> Result { + let all_versions = self.fetch_versions()?; + let all_versions = filter_and_normalize_versions1(all_versions); + + let latest_patches = with_only_latest_patch(&all_versions); + let latest_releases: Vec = latest_patches.into_iter().take(5).collect(); + + Ok(Versions { + latest: latest_releases, + all: all_versions, + }) + } + + fn extract_aqt_version(&self) -> Option { + // Check if .venv exists first + if !self.venv_folder.exists() { + return None; + } + + let output = self + .uv_command() + .ok()? + .env("VIRTUAL_ENV", &self.venv_folder) + .args(["pip", "show", "aqt"]) + .output(); + + let output = output.ok()?; + + if !output.status.success() { + return None; + } + + let stdout = String::from_utf8(output.stdout).ok()?; + for line in stdout.lines() { + if let Some(version) = line.strip_prefix("Version: ") { + return Some(version.trim().to_string()); + } + } + None + } + + pub fn check_versions(&self) -> Result { + let mut res = ExistingVersions { + pyproject_modified_by_user: self.pyproject_modified_by_user, + ..Default::default() + }; + + // If sync_complete_marker is missing, do nothing + if !self.sync_complete_marker.exists() { + return Ok(res); + } + + // Determine current version by invoking uv pip show aqt + match self.extract_aqt_version() { + Some(version) => { + res.current = Some(normalize_version(&version)); + } + None => { + Err(anyhow::anyhow!( + "Warning: Could not determine current Anki version" + ))?; + } + } + + // Read previous version from "previous-version" file + let previous_version_path = self.uv_install_root.join("previous-version"); + if let Ok(content) = read_file(&previous_version_path) { + if let Ok(version_str) = String::from_utf8(content) { + let version = version_str.trim().to_string(); + if !version.is_empty() { + res.previous = Some(normalize_version(&version)); + } + } + } + + Ok(res) + } + + fn write_sync_marker(&self) -> Result<()> { + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .context("Failed to get system time")? + .as_secs(); + write_file(&self.sync_complete_marker, timestamp.to_string())?; + Ok(()) + } + + pub fn post_install(&self) -> Result { + // Write marker file to indicate we've completed the sync process + self.write_sync_marker()?; + + // whether or not anki needs to warm up + Ok(cfg!(target_os = "macos")) + } + + pub fn launch_anki(&self) -> Result<()> { + #[cfg(target_os = "macos")] + { + let cmd = self.build_python_command(&[])?; + platform::mac::prepare_for_launch_after_update(cmd, &self.uv_install_root)?; + } + + // respawn the launcher as a disconnected subprocess for normal startup + respawn_launcher() + } + + pub fn build_python_command(&self, args: &[String]) -> Result { + let python_exe = if cfg!(target_os = "windows") { + let show_console = std::env::var("ANKI_CONSOLE").is_ok(); + if show_console { + self.venv_folder.join("Scripts/python.exe") + } else { + self.venv_folder.join("Scripts/pythonw.exe") + } + } else { + self.venv_folder.join("bin/python") + }; + + let mut cmd = Command::new(&python_exe); + cmd.args(["-c", "import aqt, sys; sys.argv[0] = 'Anki'; 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()); + + // Set UV and Python paths for the Python code + cmd.env("ANKI_LAUNCHER_UV", self.uv_path.utf8()?.as_str()); + cmd.env("UV_PROJECT", self.uv_install_root.utf8()?.as_str()); + cmd.env_remove("SSLKEYLOGFILE"); + + Ok(cmd) + } +} + #[cfg(test)] mod tests { use super::*; #[test] fn test_normalize_version() { + let normalize_version = |v| normalize_version(v).version; + // Test versions <= 2.x (should not be transformed) assert_eq!(normalize_version("2.1.50"), "2.1.50");