mirror of
https://github.com/ankitects/anki.git
synced 2025-09-18 22:12:21 -04:00
Show recent versions in launcher
Did it with Python to avoid bloating the launcher binary with network code
This commit is contained in:
parent
7fe201d6bd
commit
2e74101ca4
7 changed files with 253 additions and 29 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
@ -3549,6 +3549,7 @@ dependencies = [
|
||||||
"embed-resource",
|
"embed-resource",
|
||||||
"libc",
|
"libc",
|
||||||
"libc-stdhandle",
|
"libc-stdhandle",
|
||||||
|
"serde_json",
|
||||||
"widestring",
|
"widestring",
|
||||||
"windows 0.61.3",
|
"windows 0.61.3",
|
||||||
]
|
]
|
||||||
|
|
|
@ -13,6 +13,7 @@ anki_process.workspace = true
|
||||||
anyhow.workspace = true
|
anyhow.workspace = true
|
||||||
camino.workspace = true
|
camino.workspace = true
|
||||||
dirs.workspace = true
|
dirs.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
|
|
||||||
[target.'cfg(all(unix, not(target_os = "macos")))'.dependencies]
|
[target.'cfg(all(unix, not(target_os = "macos")))'.dependencies]
|
||||||
libc.workspace = true
|
libc.workspace = true
|
||||||
|
|
|
@ -61,6 +61,7 @@ done
|
||||||
# Copy additional files from parent directory
|
# Copy additional files from parent directory
|
||||||
cp ../pyproject.toml "$LAUNCHER_DIR/"
|
cp ../pyproject.toml "$LAUNCHER_DIR/"
|
||||||
cp ../../../.python-version "$LAUNCHER_DIR/"
|
cp ../../../.python-version "$LAUNCHER_DIR/"
|
||||||
|
cp ../versions.py "$LAUNCHER_DIR/"
|
||||||
|
|
||||||
# Set executable permissions
|
# Set executable permissions
|
||||||
chmod +x \
|
chmod +x \
|
||||||
|
|
|
@ -35,6 +35,7 @@ cp Info.plist "$APP_LAUNCHER/Contents/"
|
||||||
cp icon/Assets.car "$APP_LAUNCHER/Contents/Resources/"
|
cp icon/Assets.car "$APP_LAUNCHER/Contents/Resources/"
|
||||||
cp ../pyproject.toml "$APP_LAUNCHER/Contents/Resources/"
|
cp ../pyproject.toml "$APP_LAUNCHER/Contents/Resources/"
|
||||||
cp ../../../.python-version "$APP_LAUNCHER/Contents/Resources/"
|
cp ../../../.python-version "$APP_LAUNCHER/Contents/Resources/"
|
||||||
|
cp ../versions.py "$APP_LAUNCHER/Contents/Resources/"
|
||||||
|
|
||||||
# Codesign
|
# Codesign
|
||||||
for i in "$APP_LAUNCHER/Contents/MacOS/uv" "$APP_LAUNCHER/Contents/MacOS/launcher" "$APP_LAUNCHER"; do
|
for i in "$APP_LAUNCHER/Contents/MacOS/uv" "$APP_LAUNCHER/Contents/MacOS/launcher" "$APP_LAUNCHER"; do
|
||||||
|
|
|
@ -139,6 +139,9 @@ fn copy_files(output_dir: &Path) -> Result<()> {
|
||||||
output_dir.join(".python-version"),
|
output_dir.join(".python-version"),
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
|
// Copy versions.py
|
||||||
|
copy_file("../versions.py", output_dir.join("versions.py"))?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -47,6 +47,7 @@ struct State {
|
||||||
uv_lock_path: std::path::PathBuf,
|
uv_lock_path: std::path::PathBuf,
|
||||||
sync_complete_marker: std::path::PathBuf,
|
sync_complete_marker: std::path::PathBuf,
|
||||||
previous_version: Option<String>,
|
previous_version: Option<String>,
|
||||||
|
resources_dir: std::path::PathBuf,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
|
@ -100,6 +101,7 @@ fn run() -> Result<()> {
|
||||||
uv_lock_path: uv_install_root.join("uv.lock"),
|
uv_lock_path: uv_install_root.join("uv.lock"),
|
||||||
sync_complete_marker: uv_install_root.join(".sync_complete"),
|
sync_complete_marker: uv_install_root.join(".sync_complete"),
|
||||||
previous_version: None,
|
previous_version: None,
|
||||||
|
resources_dir,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Check for uninstall request from Windows uninstaller
|
// Check for uninstall request from Windows uninstaller
|
||||||
|
@ -225,7 +227,7 @@ fn check_versions(state: &mut State) {
|
||||||
|
|
||||||
fn main_menu_loop(state: &State) -> Result<()> {
|
fn main_menu_loop(state: &State) -> Result<()> {
|
||||||
loop {
|
loop {
|
||||||
let menu_choice = get_main_menu_choice(state);
|
let menu_choice = get_main_menu_choice(state)?;
|
||||||
|
|
||||||
match menu_choice {
|
match menu_choice {
|
||||||
MainMenuChoice::Quit => std::process::exit(0),
|
MainMenuChoice::Quit => std::process::exit(0),
|
||||||
|
@ -379,16 +381,18 @@ fn write_sync_marker(sync_complete_marker: &std::path::Path) -> Result<()> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_main_menu_choice(state: &State) -> MainMenuChoice {
|
fn get_main_menu_choice(state: &State) -> Result<MainMenuChoice> {
|
||||||
loop {
|
loop {
|
||||||
println!("1) Latest Anki (just press enter)");
|
println!("1) Latest Anki (just press enter)");
|
||||||
println!("2) Choose a version");
|
println!("2) Choose a version");
|
||||||
if let Some(current_version) = &state.current_version {
|
if let Some(current_version) = &state.current_version {
|
||||||
println!("3) Keep existing version ({current_version})");
|
let normalized_current = normalize_version(current_version);
|
||||||
|
println!("3) Keep existing version ({normalized_current})");
|
||||||
}
|
}
|
||||||
if let Some(prev_version) = &state.previous_version {
|
if let Some(prev_version) = &state.previous_version {
|
||||||
if state.current_version.as_ref() != Some(prev_version) {
|
if state.current_version.as_ref() != Some(prev_version) {
|
||||||
println!("4) Revert to previous version ({prev_version})");
|
let normalized_prev = normalize_version(prev_version);
|
||||||
|
println!("4) Revert to previous version ({normalized_prev})");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
println!();
|
println!();
|
||||||
|
@ -415,9 +419,14 @@ fn get_main_menu_choice(state: &State) -> MainMenuChoice {
|
||||||
|
|
||||||
println!();
|
println!();
|
||||||
|
|
||||||
return match input {
|
return Ok(match input {
|
||||||
"" | "1" => MainMenuChoice::Latest,
|
"" | "1" => MainMenuChoice::Latest,
|
||||||
"2" => MainMenuChoice::Version(get_version_kind()),
|
"2" => {
|
||||||
|
match get_version_kind(state)? {
|
||||||
|
Some(version_kind) => MainMenuChoice::Version(version_kind),
|
||||||
|
None => continue, // Return to main menu
|
||||||
|
}
|
||||||
|
}
|
||||||
"3" => {
|
"3" => {
|
||||||
if state.current_version.is_some() {
|
if state.current_version.is_some() {
|
||||||
MainMenuChoice::KeepExisting
|
MainMenuChoice::KeepExisting
|
||||||
|
@ -430,7 +439,7 @@ fn get_main_menu_choice(state: &State) -> MainMenuChoice {
|
||||||
if let Some(prev_version) = &state.previous_version {
|
if let Some(prev_version) = &state.previous_version {
|
||||||
if state.current_version.as_ref() != Some(prev_version) {
|
if state.current_version.as_ref() != Some(prev_version) {
|
||||||
if let Some(version_kind) = parse_version_kind(prev_version) {
|
if let Some(version_kind) = parse_version_kind(prev_version) {
|
||||||
return MainMenuChoice::Version(version_kind);
|
return Ok(MainMenuChoice::Version(version_kind));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -445,12 +454,26 @@ fn get_main_menu_choice(state: &State) -> MainMenuChoice {
|
||||||
println!("Invalid input. Please try again.");
|
println!("Invalid input. Please try again.");
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
};
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_version_kind() -> VersionKind {
|
fn get_version_kind(state: &State) -> Result<Option<VersionKind>> {
|
||||||
loop {
|
println!("Please wait...");
|
||||||
|
|
||||||
|
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.iter().take(5).collect();
|
||||||
|
let releases_str = latest_releases
|
||||||
|
.iter()
|
||||||
|
.map(|v| v.as_str())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(", ");
|
||||||
|
println!("Latest releases: {releases_str}");
|
||||||
|
|
||||||
println!("Enter the version you want to install:");
|
println!("Enter the version you want to install:");
|
||||||
print!("> ");
|
print!("> ");
|
||||||
let _ = stdout().flush();
|
let _ = stdout().flush();
|
||||||
|
@ -460,21 +483,152 @@ fn get_version_kind() -> VersionKind {
|
||||||
let input = input.trim();
|
let input = input.trim();
|
||||||
|
|
||||||
if input.is_empty() {
|
if input.is_empty() {
|
||||||
println!("Please enter a version.");
|
return Ok(None);
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
match parse_version_kind(input) {
|
// Normalize the input version for comparison
|
||||||
Some(version_kind) => {
|
let normalized_input = normalize_version(input);
|
||||||
|
|
||||||
|
// Check if the version exists in the available versions
|
||||||
|
let version_exists = all_versions.iter().any(|v| v == &normalized_input);
|
||||||
|
|
||||||
|
match (parse_version_kind(input), version_exists) {
|
||||||
|
(Some(version_kind), true) => {
|
||||||
println!();
|
println!();
|
||||||
return version_kind;
|
Ok(Some(version_kind))
|
||||||
}
|
}
|
||||||
None => {
|
(None, true) => {
|
||||||
println!("Invalid version format. Please enter a version like 25.07.1 or 24.11 (minimum 2.1.50)");
|
println!("Versions before 2.1.50 can't be installedn");
|
||||||
continue;
|
Ok(None)
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
println!("Invalid version.\n");
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn with_only_latest_patch(versions: &[String]) -> Vec<String> {
|
||||||
|
// 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);
|
||||||
|
if major == 2 {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
let major_minor = (major, minor);
|
||||||
|
if seen_major_minor.contains(&major_minor) {
|
||||||
|
false
|
||||||
|
} else {
|
||||||
|
seen_major_minor.insert(major_minor);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.cloned()
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_version_for_filtering(version_str: &str) -> (u32, u32, u32, bool) {
|
||||||
|
// Remove any build metadata after +
|
||||||
|
let version_str = version_str.split('+').next().unwrap_or(version_str);
|
||||||
|
|
||||||
|
// Check for prerelease markers
|
||||||
|
let is_prerelease = ["a", "b", "rc", "alpha", "beta"]
|
||||||
|
.iter()
|
||||||
|
.any(|marker| version_str.to_lowercase().contains(marker));
|
||||||
|
|
||||||
|
// Extract numeric parts (stop at first non-digit/non-dot character)
|
||||||
|
let numeric_end = version_str
|
||||||
|
.find(|c: char| !c.is_ascii_digit() && c != '.')
|
||||||
|
.unwrap_or(version_str.len());
|
||||||
|
let numeric_part = &version_str[..numeric_end];
|
||||||
|
|
||||||
|
let parts: Vec<&str> = numeric_part.split('.').collect();
|
||||||
|
|
||||||
|
let major = parts.first().and_then(|s| s.parse().ok()).unwrap_or(0);
|
||||||
|
let minor = parts.get(1).and_then(|s| s.parse().ok()).unwrap_or(0);
|
||||||
|
let patch = parts.get(2).and_then(|s| s.parse().ok()).unwrap_or(0);
|
||||||
|
|
||||||
|
(major, minor, patch, is_prerelease)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_version(version: &str) -> String {
|
||||||
|
let (major, minor, patch, _is_prerelease) = parse_version_for_filtering(version);
|
||||||
|
|
||||||
|
if major <= 2 {
|
||||||
|
// Don't transform versions <= 2.x
|
||||||
|
return version.to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
// For versions > 2, pad the minor version with leading zero if < 10
|
||||||
|
let normalized_minor = if minor < 10 {
|
||||||
|
format!("0{minor}")
|
||||||
|
} else {
|
||||||
|
minor.to_string()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Find any prerelease suffix
|
||||||
|
let mut prerelease_suffix = "";
|
||||||
|
|
||||||
|
// Look for prerelease markers after the numeric part
|
||||||
|
let numeric_end = version
|
||||||
|
.find(|c: char| !c.is_ascii_digit() && c != '.')
|
||||||
|
.unwrap_or(version.len());
|
||||||
|
if numeric_end < version.len() {
|
||||||
|
let suffix_part = &version[numeric_end..];
|
||||||
|
let suffix_lower = suffix_part.to_lowercase();
|
||||||
|
|
||||||
|
for marker in ["alpha", "beta", "rc", "a", "b"] {
|
||||||
|
if suffix_lower.starts_with(marker) {
|
||||||
|
prerelease_suffix = &version[numeric_end..];
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reconstruct the version
|
||||||
|
if version.matches('.').count() >= 2 {
|
||||||
|
format!("{major}.{normalized_minor}.{patch}{prerelease_suffix}")
|
||||||
|
} else {
|
||||||
|
format!("{major}.{normalized_minor}{prerelease_suffix}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn filter_and_normalize_versions(
|
||||||
|
all_versions: Vec<String>,
|
||||||
|
include_prereleases: bool,
|
||||||
|
) -> Vec<String> {
|
||||||
|
let mut valid_versions: Vec<String> = all_versions
|
||||||
|
.into_iter()
|
||||||
|
.map(|v| normalize_version(&v))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// 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 = Command::new(&state.uv_path);
|
||||||
|
cmd.current_dir(&state.uv_install_root)
|
||||||
|
.args(["run", "--no-project"])
|
||||||
|
.arg(&versions_script);
|
||||||
|
|
||||||
|
let output = cmd.utf8_output()?;
|
||||||
|
let versions = serde_json::from_str(&output.stdout).context("Failed to parse versions JSON")?;
|
||||||
|
Ok(versions)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn update_pyproject_for_version(
|
fn update_pyproject_for_version(
|
||||||
|
@ -714,9 +868,7 @@ fn build_python_command(state: &State, args: &[String]) -> Result<Command> {
|
||||||
cmd.env("ANKI_LAUNCHER", std::env::current_exe()?.utf8()?.as_str());
|
cmd.env("ANKI_LAUNCHER", std::env::current_exe()?.utf8()?.as_str());
|
||||||
|
|
||||||
// Set UV and Python paths for the Python code
|
// Set UV and Python paths for the Python code
|
||||||
let (exe_dir, _) = get_exe_and_resources_dirs()?;
|
cmd.env("ANKI_LAUNCHER_UV", state.uv_path.utf8()?.as_str());
|
||||||
let uv_path = exe_dir.join(get_uv_binary_name());
|
|
||||||
cmd.env("ANKI_LAUNCHER_UV", uv_path.utf8()?.as_str());
|
|
||||||
cmd.env("UV_PROJECT", state.uv_install_root.utf8()?.as_str());
|
cmd.env("UV_PROJECT", state.uv_install_root.utf8()?.as_str());
|
||||||
|
|
||||||
// Set UV_PRERELEASE=allow if beta mode is enabled
|
// Set UV_PRERELEASE=allow if beta mode is enabled
|
||||||
|
@ -726,3 +878,29 @@ fn build_python_command(state: &State, args: &[String]) -> Result<Command> {
|
||||||
|
|
||||||
Ok(cmd)
|
Ok(cmd)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_normalize_version() {
|
||||||
|
// Test versions <= 2.x (should not be transformed)
|
||||||
|
assert_eq!(normalize_version("2.1.50"), "2.1.50");
|
||||||
|
|
||||||
|
// Test basic versions > 2 with zero-padding
|
||||||
|
assert_eq!(normalize_version("25.7"), "25.07");
|
||||||
|
assert_eq!(normalize_version("25.07"), "25.07");
|
||||||
|
assert_eq!(normalize_version("25.10"), "25.10");
|
||||||
|
assert_eq!(normalize_version("24.6.1"), "24.06.1");
|
||||||
|
assert_eq!(normalize_version("24.06.1"), "24.06.1");
|
||||||
|
|
||||||
|
// Test prerelease versions
|
||||||
|
assert_eq!(normalize_version("25.7a1"), "25.07a1");
|
||||||
|
assert_eq!(normalize_version("25.7.1a1"), "25.07.1a1");
|
||||||
|
|
||||||
|
// Test versions with patch = 0
|
||||||
|
assert_eq!(normalize_version("25.7.0"), "25.07.0");
|
||||||
|
assert_eq!(normalize_version("25.7.0a1"), "25.07.0a1");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
39
qt/launcher/versions.py
Normal file
39
qt/launcher/versions.py
Normal file
|
@ -0,0 +1,39 @@
|
||||||
|
# Copyright: Ankitects Pty Ltd and contributors
|
||||||
|
# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Fetch and return all versions from PyPI, sorted by upload time."""
|
||||||
|
url = "https://pypi.org/pypi/aqt/json"
|
||||||
|
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(url, timeout=30) as response:
|
||||||
|
data = json.loads(response.read().decode("utf-8"))
|
||||||
|
releases = data.get("releases", {})
|
||||||
|
|
||||||
|
# Create list of (version, upload_time) tuples
|
||||||
|
version_times = []
|
||||||
|
for version, files in releases.items():
|
||||||
|
if files: # Only include versions that have files
|
||||||
|
# Use the upload time of the first file for each version
|
||||||
|
upload_time = files[0].get("upload_time_iso_8601")
|
||||||
|
if upload_time:
|
||||||
|
version_times.append((version, upload_time))
|
||||||
|
|
||||||
|
# Sort by upload time
|
||||||
|
version_times.sort(key=lambda x: x[1])
|
||||||
|
|
||||||
|
# Extract just the version names
|
||||||
|
versions = [version for version, _ in version_times]
|
||||||
|
print(json.dumps(versions))
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error fetching versions: {e}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
Loading…
Reference in a new issue