mirror of
https://github.com/ankitects/anki.git
synced 2025-11-07 05:07:10 -05:00
refactor state and uv code
This commit is contained in:
parent
920fbdba7a
commit
6d6df1c91d
4 changed files with 524 additions and 334 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
}
|
||||
|
||||
|
|
|
|||
122
qt/launcher-gui/src-tauri/src/state.rs
Normal file
122
qt/launcher-gui/src-tauri/src/state.rs
Normal file
|
|
@ -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<uv::Paths>,
|
||||
pub initial_options: Options,
|
||||
pub current_versions: Option<watch::Receiver<Option<Result<ExistingVersions>>>>,
|
||||
pub available_versions: Option<watch::Receiver<Option<Result<Versions>>>>,
|
||||
}
|
||||
|
||||
impl From<uv::Paths> 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<NormalState> for State {
|
||||
fn from(state: NormalState) -> Self {
|
||||
Self::Normal(state)
|
||||
}
|
||||
}
|
||||
|
||||
pub enum State {
|
||||
LaunchAnki(Arc<uv::Paths>),
|
||||
Error(anyhow::Error),
|
||||
Uninstall(Arc<uv::Paths>),
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<String>,
|
||||
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<String>,
|
||||
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<String>,
|
||||
pub all: Vec<String>,
|
||||
}
|
||||
|
||||
pub fn init_state() -> Result<Option<State>> {
|
||||
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<String> = 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<String> = 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<String> {
|
||||
// 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<String>, Option<String>)> {
|
||||
// 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<R: Runtime>(
|
||||
app: AppHandle<R>,
|
||||
state: &State,
|
||||
pub fn handle_version_install_or_update<F>(
|
||||
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<R: Runtime>(
|
|||
.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<R: Runtime>(
|
|||
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<R: Runtime>(
|
|||
}
|
||||
});
|
||||
|
||||
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<R: Runtime>(
|
|||
println!("uv exited with status: {:?}", status);
|
||||
|
||||
match status {
|
||||
Ok(_) => {
|
||||
// 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<R: Runtime>(
|
|||
// 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<R: Runtime>(
|
|||
|
||||
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.
|
||||
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<String> {
|
||||
fn with_only_latest_patch(versions: &[Version]) -> Vec<Version> {
|
||||
// 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<String>,
|
||||
include_prereleases: bool,
|
||||
) -> Vec<String> {
|
||||
let mut valid_versions: Vec<String> = all_versions
|
||||
fn filter_and_normalize_versions1(all_versions: Vec<String>) -> Vec<Version> {
|
||||
let mut valid_versions: Vec<Version> = 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<Vec<String>> {
|
||||
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<Releases> {
|
||||
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<String> = 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<std::path::PathBuf> {
|
|||
|
||||
// TODO: revert
|
||||
#[allow(unused)]
|
||||
fn handle_uninstall(state: &State) -> Result<bool> {
|
||||
fn handle_uninstall(state: &Paths) -> Result<bool> {
|
||||
// println!("{}", state.tr.launcher_uninstall_confirm());
|
||||
print!("> ");
|
||||
let _ = stdout().flush();
|
||||
|
|
@ -795,7 +586,7 @@ fn handle_uninstall(state: &State) -> Result<bool> {
|
|||
Ok(true)
|
||||
}
|
||||
|
||||
fn uv_command(state: &State) -> Result<Command> {
|
||||
fn uv_command(state: &Paths) -> Result<Command> {
|
||||
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<Command> {
|
|||
Ok(command)
|
||||
}
|
||||
|
||||
fn uv_pty_command(state: &State) -> Result<portable_pty::CommandBuilder> {
|
||||
fn uv_pty_command(state: &Paths) -> Result<portable_pty::CommandBuilder> {
|
||||
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<portable_pty::CommandBuilder> {
|
|||
Ok(command)
|
||||
}
|
||||
|
||||
pub fn build_python_command(state: &State, args: &[String]) -> Result<Command> {
|
||||
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<Option<(String, String)>> {
|
||||
fn get_mirror_urls(state: &Paths) -> Result<Option<(String, String)>> {
|
||||
if !state.mirror_path.exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
|
@ -893,7 +657,7 @@ fn get_mirror_urls(state: &State) -> Result<Option<(String, String)>> {
|
|||
}
|
||||
}
|
||||
|
||||
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<Self> {
|
||||
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<Option<(String, String)>> {
|
||||
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<Command> {
|
||||
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<Vec<String>> {
|
||||
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<Versions> {
|
||||
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<Version> = latest_patches.into_iter().take(5).collect();
|
||||
|
||||
Ok(Versions {
|
||||
latest: latest_releases,
|
||||
all: all_versions,
|
||||
})
|
||||
}
|
||||
|
||||
fn extract_aqt_version(&self) -> Option<String> {
|
||||
// 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<ExistingVersions> {
|
||||
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<bool> {
|
||||
// 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<Command> {
|
||||
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");
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue