From eb6c977e082f076dcac3056dc28ea8ecfad4e661 Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Sun, 22 Jun 2025 20:25:15 +0700 Subject: [PATCH 1/4] Add menu to launcher, and improve terminal display on Windows --- Cargo.toml | 2 +- qt/launcher/src/bin/build_win.rs | 5 +- qt/launcher/src/main.rs | 348 +++++++++++++++++++++++++--- qt/launcher/src/platform/mac.rs | 7 +- qt/launcher/src/platform/unix.rs | 4 +- qt/launcher/src/platform/windows.rs | 61 ++++- 6 files changed, 378 insertions(+), 49 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 61cca8649..d2ce2ce2a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -138,7 +138,7 @@ unic-ucd-category = "0.9.0" unicode-normalization = "0.1.24" walkdir = "2.5.0" which = "8.0.0" -winapi = { version = "0.3", features = ["wincon"] } +winapi = { version = "0.3", features = ["wincon", "errhandlingapi", "consoleapi"] } windows = { version = "0.61.3", features = ["Media_SpeechSynthesis", "Media_Core", "Foundation_Collections", "Storage_Streams"] } wiremock = "0.6.3" xz2 = "0.1.7" diff --git a/qt/launcher/src/bin/build_win.rs b/qt/launcher/src/bin/build_win.rs index 959034438..ff385d9ea 100644 --- a/qt/launcher/src/bin/build_win.rs +++ b/qt/launcher/src/bin/build_win.rs @@ -114,10 +114,13 @@ fn copy_files(output_dir: &Path) -> Result<()> { let launcher_dst = output_dir.join("anki.exe"); copy_file(&launcher_src, &launcher_dst)?; - // Copy uv.exe + // Copy uv.exe and uvw.exe let uv_src = PathBuf::from("../../../out/extracted/uv/uv.exe"); let uv_dst = output_dir.join("uv.exe"); copy_file(&uv_src, &uv_dst)?; + let uv_src = PathBuf::from("../../../out/extracted/uv/uvw.exe"); + let uv_dst = output_dir.join("uvw.exe"); + copy_file(&uv_src, &uv_dst)?; println!("Copying support files..."); diff --git a/qt/launcher/src/main.rs b/qt/launcher/src/main.rs index 4f397ad99..40896caa9 100644 --- a/qt/launcher/src/main.rs +++ b/qt/launcher/src/main.rs @@ -4,28 +4,48 @@ #![windows_subsystem = "windows"] use std::io::stdin; +use std::io::stdout; +use std::io::Write; use std::process::Command; +use std::time::SystemTime; +use std::time::UNIX_EPOCH; use anki_io::copy_if_newer; use anki_io::create_dir_all; use anki_io::modified_time; +use anki_io::read_file; use anki_io::remove_file; use anki_io::write_file; use anki_process::CommandExt; use anyhow::Context; use anyhow::Result; +use crate::platform::ensure_terminal_shown; use crate::platform::exec_anki; use crate::platform::get_anki_binary_path; use crate::platform::get_exe_and_resources_dirs; use crate::platform::get_uv_binary_name; use crate::platform::handle_first_launch; -use crate::platform::handle_terminal_launch; use crate::platform::initial_terminal_setup; use crate::platform::launch_anki_detached; mod platform; +#[derive(Debug, Clone)] +pub enum VersionKind { + PyOxidizer(String), + Uv(String), +} + +#[derive(Debug, Clone)] +pub enum MainMenuChoice { + Latest, + KeepExisting, + Version(VersionKind), + ToggleBetas, + Quit, +} + #[derive(Debug, Clone, Default)] pub struct Config { pub show_console: bool, @@ -33,6 +53,9 @@ pub struct Config { fn main() { if let Err(e) = run() { + let mut config: Config = Config::default(); + initial_terminal_setup(&mut config); + eprintln!("Error: {:#}", e); eprintln!("Press enter to close..."); let mut input = String::new(); @@ -43,8 +66,7 @@ fn main() { } fn run() -> Result<()> { - let mut config = Config::default(); - initial_terminal_setup(&mut config); + let mut config: Config = Config::default(); let uv_install_root = dirs::data_local_dir() .context("Unable to determine data_dir")? @@ -62,60 +84,320 @@ fn run() -> Result<()> { // Create install directory and copy project files in create_dir_all(&uv_install_root)?; + let had_user_pyproj = user_pyproject_path.exists(); + if !had_user_pyproj { + // during initial launcher testing, enable betas by default + write_file(&prerelease_marker, "")?; + } + copy_if_newer(&dist_pyproject_path, &user_pyproject_path)?; copy_if_newer(&dist_python_version_path, &user_python_version_path)?; - let pyproject_has_changed = - !user_pyproject_path.exists() || !sync_complete_marker.exists() || { - let pyproject_toml_time = modified_time(&user_pyproject_path)?; - let sync_complete_time = modified_time(&sync_complete_marker)?; - Ok::(pyproject_toml_time > sync_complete_time) - } - .unwrap_or(true); + let pyproject_has_changed = !sync_complete_marker.exists() || { + let pyproject_toml_time = modified_time(&user_pyproject_path)?; + let sync_complete_time = modified_time(&sync_complete_marker)?; + Ok::(pyproject_toml_time > sync_complete_time) + } + .unwrap_or(true); if !pyproject_has_changed { // If venv is already up to date, exec as normal + initial_terminal_setup(&mut config); let anki_bin = get_anki_binary_path(&uv_install_root); exec_anki(&anki_bin, &config)?; return Ok(()); } // we'll need to launch uv; reinvoke ourselves in a terminal so the user can see - handle_terminal_launch()?; + ensure_terminal_shown()?; + print!("\x1B[2J\x1B[H"); // Clear screen and move cursor to top + println!("\x1B[1mAnki Launcher\x1B[0m\n"); - // Remove sync marker before attempting sync - let _ = remove_file(&sync_complete_marker); + // Check if there's an existing installation before removing marker + let has_existing_install = sync_complete_marker.exists(); - // Sync the venv - let mut command = Command::new(&uv_path); - command - .current_dir(&uv_install_root) - .args(["sync", "--upgrade", "--managed-python"]); + loop { + let menu_choice = get_main_menu_choice(has_existing_install, &prerelease_marker); - // Set UV_PRERELEASE=allow if prerelease file exists - if prerelease_marker.exists() { - command.env("UV_PRERELEASE", "allow"); + match menu_choice { + MainMenuChoice::Quit => std::process::exit(0), + MainMenuChoice::KeepExisting => { + // Skip sync, just launch existing installation + break; + } + MainMenuChoice::ToggleBetas => { + // Toggle beta prerelease file + if prerelease_marker.exists() { + let _ = remove_file(&prerelease_marker); + println!("Beta releases disabled."); + } else { + write_file(&prerelease_marker, "")?; + println!("Beta releases enabled."); + } + println!(); + continue; + } + _ => { + // For other choices, update project files and sync + update_pyproject_for_version( + menu_choice.clone(), + dist_pyproject_path.clone(), + user_pyproject_path.clone(), + dist_python_version_path.clone(), + user_python_version_path.clone(), + )?; + + // Remove sync marker before attempting sync + let _ = remove_file(&sync_complete_marker); + + // Sync the venv + let mut command = Command::new(&uv_path); + command.current_dir(&uv_install_root).args([ + "sync", + "--upgrade", + "--managed-python", + ]); + + // Add python version if .python-version file exists + if user_python_version_path.exists() { + let python_version = read_file(&user_python_version_path)?; + let python_version_str = String::from_utf8(python_version) + .context("Invalid UTF-8 in .python-version")?; + let python_version_trimmed = python_version_str.trim(); + command.args(["--python", python_version_trimmed]); + } + + // Set UV_PRERELEASE=allow if beta mode is enabled + if prerelease_marker.exists() { + command.env("UV_PRERELEASE", "allow"); + } + + match command.ensure_success() { + Ok(_) => { + // Sync succeeded, break out of loop + break; + } + Err(e) => { + // 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. + let _ = remove_file(&uv_lock_path); + println!("Install failed: {:#}", e); + println!(); + continue; + } + } + } + } } - // temporarily force it on during initial beta testing - command.env("UV_PRERELEASE", "allow"); - - if let Err(e) = command.ensure_success() { - // 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. - let _ = remove_file(&uv_lock_path); - return Err(e.into()); - } - - // Write marker file to indicate successful sync - write_file(&sync_complete_marker, "")?; + // Write marker file to indicate we've completed the sync process + write_sync_marker(&sync_complete_marker)?; // First launch let anki_bin = get_anki_binary_path(&uv_install_root); handle_first_launch(&anki_bin)?; + println!("\nPress enter to start Anki."); + + let mut input = String::new(); + let _ = stdin().read_line(&mut input); + // Then launch the binary as detached subprocess so the terminal can close launch_anki_detached(&anki_bin, &config)?; Ok(()) } + +fn write_sync_marker(sync_complete_marker: &std::path::Path) -> Result<()> { + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .context("Failed to get system time")? + .as_secs(); + write_file(sync_complete_marker, timestamp.to_string())?; + Ok(()) +} + +fn get_main_menu_choice( + has_existing_install: bool, + prerelease_marker: &std::path::Path, +) -> MainMenuChoice { + loop { + println!("1) Latest Anki (just press enter)"); + println!("2) Choose a version"); + if has_existing_install { + println!("3) Keep existing version"); + } + println!(); + + let betas_enabled = prerelease_marker.exists(); + println!( + "4) Allow betas: {}", + if betas_enabled { "on" } else { "off" } + ); + println!("5) Quit"); + print!("> "); + let _ = stdout().flush(); + + let mut input = String::new(); + let _ = stdin().read_line(&mut input); + let input = input.trim(); + + println!(); + + return match input { + "" | "1" => MainMenuChoice::Latest, + "2" => MainMenuChoice::Version(get_version_kind()), + "3" => { + if has_existing_install { + MainMenuChoice::KeepExisting + } else { + println!("Invalid input. Please try again.\n"); + continue; + } + } + "4" => MainMenuChoice::ToggleBetas, + "5" => MainMenuChoice::Quit, + _ => { + println!("Invalid input. Please try again."); + continue; + } + }; + } +} + +fn get_version_kind() -> VersionKind { + loop { + println!("Enter the version you want to install:"); + print!("> "); + let _ = stdout().flush(); + + let mut input = String::new(); + let _ = stdin().read_line(&mut input); + let input = input.trim(); + + if input.is_empty() { + println!("Please enter a version."); + continue; + } + + match parse_version_kind(input) { + Some(version_kind) => { + println!(); + return version_kind; + } + None => { + println!("Invalid version format. Please enter a version like 24.10 or 25.06.1 (minimum 2.1.50)"); + continue; + } + } + } +} + +fn update_pyproject_for_version( + menu_choice: MainMenuChoice, + dist_pyproject_path: std::path::PathBuf, + user_pyproject_path: std::path::PathBuf, + dist_python_version_path: std::path::PathBuf, + user_python_version_path: std::path::PathBuf, +) -> Result<()> { + match menu_choice { + MainMenuChoice::Latest => { + let content = read_file(&dist_pyproject_path)?; + write_file(&user_pyproject_path, &content)?; + let python_version_content = read_file(&dist_python_version_path)?; + write_file(&user_python_version_path, &python_version_content)?; + } + MainMenuChoice::KeepExisting => { + // Do nothing - keep existing pyproject.toml and .python-version + } + MainMenuChoice::ToggleBetas => { + // This should not be reached as ToggleBetas is handled in the loop + unreachable!("ToggleBetas should be handled in the main loop"); + } + MainMenuChoice::Version(version_kind) => { + let content = read_file(&dist_pyproject_path)?; + let content_str = + String::from_utf8(content).context("Invalid UTF-8 in pyproject.toml")?; + let updated_content = match &version_kind { + VersionKind::PyOxidizer(version) => { + // Replace package name and add PyQt6 dependencies + content_str.replace( + "anki-release", + &format!( + concat!( + "aqt[qt6]=={}\",\n", + " \"pyqt6==6.6.1\",\n", + " \"pyqt6-qt6==6.6.2\",\n", + " \"pyqt6-webengine==6.6.0\",\n", + " \"pyqt6-webengine-qt6==6.6.2\",\n", + " \"pyqt6_sip==13.6.0" + ), + version + ), + ) + } + VersionKind::Uv(version) => { + content_str.replace("anki-release", &format!("anki-release=={}", version)) + } + }; + write_file(&user_pyproject_path, &updated_content)?; + + // Update .python-version based on version kind + match &version_kind { + VersionKind::PyOxidizer(_) => { + write_file(&user_python_version_path, "3.9")?; + } + VersionKind::Uv(_) => { + copy_if_newer(&dist_python_version_path, &user_python_version_path)?; + } + } + } + MainMenuChoice::Quit => { + std::process::exit(0); + } + } + Ok(()) +} + +fn parse_version_kind(version: &str) -> Option { + let numeric_chars: String = version + .chars() + .filter(|c| c.is_ascii_digit() || *c == '.') + .collect(); + + let parts: Vec<&str> = numeric_chars.split('.').collect(); + + if parts.len() < 2 { + return None; + } + + let major: u32 = match parts[0].parse() { + Ok(val) => val, + Err(_) => return None, + }; + + let minor: u32 = match parts[1].parse() { + Ok(val) => val, + Err(_) => return None, + }; + + let patch: u32 = if parts.len() >= 3 { + match parts[2].parse() { + Ok(val) => val, + Err(_) => return None, + } + } else { + 0 // Default patch to 0 if not provided + }; + + // Reject versions < 2.1.50 + if major == 2 && (minor != 1 || patch < 50) { + return None; + } + + if major < 25 || (major == 25 && minor < 6) { + Some(VersionKind::PyOxidizer(version.to_string())) + } else { + Some(VersionKind::Uv(version.to_string())) + } +} diff --git a/qt/launcher/src/platform/mac.rs b/qt/launcher/src/platform/mac.rs index b5157dd4b..1e7e4a695 100644 --- a/qt/launcher/src/platform/mac.rs +++ b/qt/launcher/src/platform/mac.rs @@ -28,12 +28,9 @@ pub fn launch_anki_detached(anki_bin: &std::path::Path, _config: &crate::Config) Ok(()) } -pub fn handle_terminal_launch() -> Result<()> { +pub fn ensure_terminal_shown() -> Result<()> { let stdout_is_terminal = std::io::IsTerminal::is_terminal(&std::io::stdout()); - if stdout_is_terminal { - print!("\x1B[2J\x1B[H"); // Clear screen and move cursor to top - println!("\x1B[1mPreparing to start Anki...\x1B[0m\n"); - } else { + if !stdout_is_terminal { // If launched from GUI, relaunch in Terminal.app relaunch_in_terminal()?; } diff --git a/qt/launcher/src/platform/unix.rs b/qt/launcher/src/platform/unix.rs index 4155f39d1..65a223f30 100644 --- a/qt/launcher/src/platform/unix.rs +++ b/qt/launcher/src/platform/unix.rs @@ -16,9 +16,7 @@ pub fn initial_terminal_setup(_config: &mut Config) { // No special terminal setup needed on Unix } -pub fn handle_terminal_launch() -> Result<()> { - print!("\x1B[2J\x1B[H"); // Clear screen and move cursor to top - println!("\x1B[1mPreparing to start Anki...\x1B[0m\n"); +pub fn ensure_terminal_shown() -> Result<()> { // Skip terminal relaunch on non-macOS Unix systems as we don't know which // terminal is installed Ok(()) diff --git a/qt/launcher/src/platform/windows.rs b/qt/launcher/src/platform/windows.rs index c536c8c76..4e3752d44 100644 --- a/qt/launcher/src/platform/windows.rs +++ b/qt/launcher/src/platform/windows.rs @@ -7,27 +7,77 @@ use std::process::Command; use anki_process::CommandExt; use anyhow::Context; use anyhow::Result; +use winapi::um::consoleapi; +use winapi::um::errhandlingapi; +use winapi::um::wincon; use crate::Config; -pub fn handle_terminal_launch() -> Result<()> { - // uv will do this itself +pub fn ensure_terminal_shown() -> Result<()> { + ensure_console(); + // // Check if we're already relaunched to prevent infinite recursion + // if std::env::var("ANKI_LAUNCHER_IN_TERMINAL").is_ok() { + // println!("Recurse: Preparing to start Anki...\n"); + // return Ok(()); + // } + + // if have_console { + // } else { + // relaunch_in_cmd()?; + // } Ok(()) } +fn ensure_console() { + unsafe { + if !wincon::GetConsoleWindow().is_null() { + return; + } + + if consoleapi::AllocConsole() == 0 { + let error_code = errhandlingapi::GetLastError(); + eprintln!("unexpected AllocConsole error: {}", error_code); + return; + } + + // This black magic triggers Windows to switch to the new + // ANSI-supporting console host, which is usually only available + // when the app is built with the console subsystem. + let _ = Command::new("cmd").args(&["/C", ""]).status(); + } +} + +fn attach_to_parent_console() -> bool { + unsafe { + if !wincon::GetConsoleWindow().is_null() { + // we have a console already + println!("attach: already had console, false"); + return false; + } + + if wincon::AttachConsole(wincon::ATTACH_PARENT_PROCESS) != 0 { + // successfully attached to parent + println!("attach: true"); + true + } else { + println!("attach: false"); + false + } + } +} + /// If parent process has a console (eg cmd.exe), redirect our output there. /// Sets config.show_console to true if successfully attached to console. pub fn initial_terminal_setup(config: &mut Config) { use std::ffi::CString; use libc_stdhandle::*; - use winapi::um::wincon; - let console_attached = unsafe { wincon::AttachConsole(wincon::ATTACH_PARENT_PROCESS) }; - if console_attached == 0 { + if !attach_to_parent_console() { return; } + // we launched without a console, so we'll need to open stdin/out/err let conin = CString::new("CONIN$").unwrap(); let conout = CString::new("CONOUT$").unwrap(); let r = CString::new("r").unwrap(); @@ -113,6 +163,5 @@ pub fn get_exe_and_resources_dirs() -> Result<(PathBuf, PathBuf)> { } pub fn get_uv_binary_name() -> &'static str { - // Windows uses standard uv binary name "uv.exe" } From d2f818fad286f08d86bc5243cb8bb7c83e3263e9 Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Sun, 22 Jun 2025 20:57:53 +0700 Subject: [PATCH 2/4] macOS launcher improvements - do mpv initial run in parallel - improve messages, show dots regularly --- qt/aqt/package.py | 74 ++++++++++++++++----------------- qt/launcher/mac/build.sh | 10 ++--- qt/launcher/src/main.rs | 2 + qt/launcher/src/platform/mac.rs | 39 ++++++++++++++++- 4 files changed, 81 insertions(+), 44 deletions(-) diff --git a/qt/aqt/package.py b/qt/aqt/package.py index a0642fca0..f1834b594 100644 --- a/qt/aqt/package.py +++ b/qt/aqt/package.py @@ -23,47 +23,45 @@ def first_run_setup() -> None: if not is_mac: return - def _dot(): - print(".", flush=True, end="") - - _dot() - import anki.collection - - _dot() - import PyQt6.sip - - _dot() - import PyQt6.QtCore - - _dot() - import PyQt6.QtGui - - _dot() - import PyQt6.QtNetwork - - _dot() - import PyQt6.QtQuick - - _dot() - import PyQt6.QtWebChannel - - _dot() - import PyQt6.QtWebEngineCore - - _dot() - import PyQt6.QtWebEngineWidgets - - _dot() + # Import anki_audio first and spawn commands import anki_audio - import PyQt6.QtWidgets audio_pkg_path = Path(anki_audio.__file__).parent - # Invoke mpv and lame - cmd = [Path(""), "--version"] + # Start mpv and lame commands concurrently + processes = [] for cmd_name in ["mpv", "lame"]: - _dot() - cmd[0] = audio_pkg_path / cmd_name - subprocess.run([str(cmd[0]), str(cmd[1])], check=True, capture_output=True) + cmd_path = audio_pkg_path / cmd_name + proc = subprocess.Popen( + [str(cmd_path), "--version"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + processes.append(proc) - print() + # Continue with other imports while commands run + import concurrent.futures + + import bs4 + import flask + import flask_cors + import markdown + import PyQt6.QtCore + import PyQt6.QtGui + import PyQt6.QtNetwork + import PyQt6.QtQuick + import PyQt6.QtWebChannel + import PyQt6.QtWebEngineCore + import PyQt6.QtWebEngineWidgets + import PyQt6.QtWidgets + import PyQt6.sip + import requests + import waitress + + import anki.collection + + from . import _macos_helper + + # Wait for both commands to complete + for proc in processes: + proc.wait() diff --git a/qt/launcher/mac/build.sh b/qt/launcher/mac/build.sh index eb4483488..0ec39ad8f 100755 --- a/qt/launcher/mac/build.sh +++ b/qt/launcher/mac/build.sh @@ -47,8 +47,8 @@ done codesign -vvv "$APP_LAUNCHER" spctl -a "$APP_LAUNCHER" -# Notarize -./notarize.sh "$OUTPUT_DIR" - -# Bundle -./dmg/build.sh "$OUTPUT_DIR" \ No newline at end of file +# Notarize and bundle (skip if NODMG is set) +if [ -z "$NODMG" ]; then + ./notarize.sh "$OUTPUT_DIR" + ./dmg/build.sh "$OUTPUT_DIR" +fi \ No newline at end of file diff --git a/qt/launcher/src/main.rs b/qt/launcher/src/main.rs index 40896caa9..2ad3ac00c 100644 --- a/qt/launcher/src/main.rs +++ b/qt/launcher/src/main.rs @@ -172,6 +172,8 @@ fn run() -> Result<()> { command.env("UV_PRERELEASE", "allow"); } + println!("\x1B[1mUpdating Anki...\x1B[0m\n"); + match command.ensure_success() { Ok(_) => { // Sync succeeded, break out of loop diff --git a/qt/launcher/src/platform/mac.rs b/qt/launcher/src/platform/mac.rs index 1e7e4a695..57d6bcc73 100644 --- a/qt/launcher/src/platform/mac.rs +++ b/qt/launcher/src/platform/mac.rs @@ -3,6 +3,11 @@ use std::os::unix::process::CommandExt; 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; @@ -25,6 +30,9 @@ pub fn launch_anki_detached(anki_bin: &std::path::Path, _config: &crate::Config) .process_group(0) .ensure_spawn()?; std::mem::forget(child); + + println!("Anki will start shortly."); + println!("\x1B[1mYou can close this window.\x1B[0m\n"); Ok(()) } @@ -34,6 +42,10 @@ pub fn ensure_terminal_shown() -> Result<()> { // If launched from GUI, relaunch in Terminal.app relaunch_in_terminal()?; } + + // Set terminal title to "Anki Launcher" + print!("\x1b]0;Anki Launcher\x07"); + Ok(()) } @@ -47,12 +59,37 @@ fn relaunch_in_terminal() -> Result<()> { } pub fn handle_first_launch(anki_bin: &std::path::Path) -> Result<()> { + use std::io::Write; + use std::io::{ + self, + }; + // Pre-validate by running --version to trigger any Gatekeeper checks - println!("\n\x1B[1mThis may take a few minutes. Please wait...\x1B[0m"); + 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 _ = Command::new(anki_bin) .env("ANKI_FIRST_RUN", "1") .arg("--version") + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) .ensure_success(); + + // Stop progress indicator + running.store(false, Ordering::Relaxed); + progress_thread.join().unwrap(); + println!(); // New line after dots Ok(()) } From b250a2f7245cb41f10581bfca2f89106fc0e366c Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Sun, 22 Jun 2025 21:52:44 +0700 Subject: [PATCH 3/4] Add terminal support for *nix --- qt/launcher/lin/build.sh | 43 ++++++++++++++++++------- qt/launcher/src/platform/mac.rs | 16 ++-------- qt/launcher/src/platform/unix.rs | 55 ++++++++++++++++++++++++++++++-- 3 files changed, 87 insertions(+), 27 deletions(-) diff --git a/qt/launcher/lin/build.sh b/qt/launcher/lin/build.sh index e4ddce243..de96a1b50 100755 --- a/qt/launcher/lin/build.sh +++ b/qt/launcher/lin/build.sh @@ -1,9 +1,15 @@ #!/bin/bash +# +# This script currently only supports universal builds on x86_64. +# set -e # Add Linux cross-compilation target rustup target add aarch64-unknown-linux-gnu +# Detect host architecture +HOST_ARCH=$(uname -m) + # Define output paths OUTPUT_DIR="../../../out/launcher" @@ -12,11 +18,18 @@ LAUNCHER_DIR="$OUTPUT_DIR/anki-launcher" # Clean existing output directory rm -rf "$LAUNCHER_DIR" -# Build binaries for both Linux architectures -cargo build -p launcher --release --target x86_64-unknown-linux-gnu -CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc \ +# Build binaries based on host architecture +if [ "$HOST_ARCH" = "aarch64" ]; then + # On aarch64 host, only build for aarch64 cargo build -p launcher --release --target aarch64-unknown-linux-gnu -(cd ../../.. && ./ninja extract:uv_lin_arm) +else + # On other hosts, build for both architectures + cargo build -p launcher --release --target x86_64-unknown-linux-gnu + CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc \ + cargo build -p launcher --release --target aarch64-unknown-linux-gnu + # Extract uv_lin_arm for cross-compilation + (cd ../../.. && ./ninja extract:uv_lin_arm) +fi # Create output directory mkdir -p "$LAUNCHER_DIR" @@ -24,13 +37,21 @@ mkdir -p "$LAUNCHER_DIR" # Copy binaries and support files TARGET_DIR=${CARGO_TARGET_DIR:-../../../target} -# Copy launcher binaries with architecture suffixes -cp "$TARGET_DIR/x86_64-unknown-linux-gnu/release/launcher" "$LAUNCHER_DIR/launcher.amd64" -cp "$TARGET_DIR/aarch64-unknown-linux-gnu/release/launcher" "$LAUNCHER_DIR/launcher.arm64" - -# Copy uv binaries with architecture suffixes -cp "../../../out/extracted/uv/uv" "$LAUNCHER_DIR/uv.amd64" -cp "../../../out/extracted/uv_lin_arm/uv" "$LAUNCHER_DIR/uv.arm64" +# Copy binaries with architecture suffixes +if [ "$HOST_ARCH" = "aarch64" ]; then + # On aarch64 host, copy arm64 binary to both locations + cp "$TARGET_DIR/aarch64-unknown-linux-gnu/release/launcher" "$LAUNCHER_DIR/launcher.amd64" + cp "$TARGET_DIR/aarch64-unknown-linux-gnu/release/launcher" "$LAUNCHER_DIR/launcher.arm64" + # Copy uv binary to both locations + cp "../../../out/extracted/uv/uv" "$LAUNCHER_DIR/uv.amd64" + cp "../../../out/extracted/uv/uv" "$LAUNCHER_DIR/uv.arm64" +else + # On other hosts, copy architecture-specific binaries + cp "$TARGET_DIR/x86_64-unknown-linux-gnu/release/launcher" "$LAUNCHER_DIR/launcher.amd64" + cp "$TARGET_DIR/aarch64-unknown-linux-gnu/release/launcher" "$LAUNCHER_DIR/launcher.arm64" + cp "../../../out/extracted/uv/uv" "$LAUNCHER_DIR/uv.amd64" + cp "../../../out/extracted/uv_lin_arm/uv" "$LAUNCHER_DIR/uv.arm64" +fi # Copy support files from lin directory for file in README.md anki.1 anki.desktop anki.png anki.xml anki.xpm install.sh uninstall.sh anki; do diff --git a/qt/launcher/src/platform/mac.rs b/qt/launcher/src/platform/mac.rs index 57d6bcc73..2369f538a 100644 --- a/qt/launcher/src/platform/mac.rs +++ b/qt/launcher/src/platform/mac.rs @@ -15,6 +15,7 @@ use anyhow::Result; // Re-export Unix functions that macOS uses pub use super::unix::{ + ensure_terminal_shown, exec_anki, get_anki_binary_path, initial_terminal_setup, @@ -36,20 +37,7 @@ pub fn launch_anki_detached(anki_bin: &std::path::Path, _config: &crate::Config) Ok(()) } -pub fn ensure_terminal_shown() -> Result<()> { - let stdout_is_terminal = std::io::IsTerminal::is_terminal(&std::io::stdout()); - if !stdout_is_terminal { - // If launched from GUI, relaunch in Terminal.app - relaunch_in_terminal()?; - } - - // Set terminal title to "Anki Launcher" - print!("\x1b]0;Anki Launcher\x07"); - - Ok(()) -} - -fn relaunch_in_terminal() -> Result<()> { +pub fn relaunch_in_terminal() -> Result<()> { let current_exe = std::env::current_exe().context("Failed to get current executable path")?; Command::new("open") .args(["-a", "Terminal"]) diff --git a/qt/launcher/src/platform/unix.rs b/qt/launcher/src/platform/unix.rs index 65a223f30..324bf5aa3 100644 --- a/qt/launcher/src/platform/unix.rs +++ b/qt/launcher/src/platform/unix.rs @@ -3,6 +3,7 @@ #![allow(dead_code)] +use std::io::IsTerminal; use std::path::PathBuf; use std::process::Command; @@ -17,8 +18,58 @@ pub fn initial_terminal_setup(_config: &mut Config) { } pub fn ensure_terminal_shown() -> Result<()> { - // Skip terminal relaunch on non-macOS Unix systems as we don't know which - // terminal is installed + let stdout_is_terminal = IsTerminal::is_terminal(&std::io::stdout()); + if !stdout_is_terminal { + // If launched from GUI, try to relaunch in a terminal + crate::platform::relaunch_in_terminal()?; + } + + // Set terminal title to "Anki Launcher" + print!("\x1b]2;Anki Launcher\x07"); + + Ok(()) +} + +#[cfg(not(target_os = "macos"))] +pub fn relaunch_in_terminal() -> Result<()> { + let current_exe = std::env::current_exe().context("Failed to get current executable path")?; + + // Try terminals in order of preference + let terminals = [ + ("x-terminal-emulator", vec!["-e"]), + ("gnome-terminal", vec!["--"]), + ("konsole", vec!["-e"]), + ("xfce4-terminal", vec!["-e"]), + ("alacritty", vec!["-e"]), + ("kitty", vec![]), + ("foot", vec![]), + ("urxvt", vec!["-e"]), + ("xterm", vec!["-e"]), + ]; + + for (terminal_cmd, args) in &terminals { + // Check if terminal exists + if Command::new("which") + .arg(terminal_cmd) + .output() + .map(|o| o.status.success()) + .unwrap_or(false) + { + // Try to launch the terminal + let mut cmd = Command::new(terminal_cmd); + if args.is_empty() { + cmd.arg(¤t_exe); + } else { + cmd.args(args).arg(¤t_exe); + } + + if cmd.spawn().is_ok() { + std::process::exit(0); + } + } + } + + // If no terminal worked, continue without relaunching Ok(()) } From a73f1507ba6a0e2174b8f9487d83a87278cc5d3d Mon Sep 17 00:00:00 2001 From: llama Date: Wed, 25 Jun 2025 19:08:25 +0800 Subject: [PATCH 4/4] use KeyboardEvent.key instead of code (#4114) --- ts/lib/tag-editor/TagInput.svelte | 2 +- ts/lib/tslib/keys.ts | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/ts/lib/tag-editor/TagInput.svelte b/ts/lib/tag-editor/TagInput.svelte index a8d76bcee..31d3b51f6 100644 --- a/ts/lib/tag-editor/TagInput.svelte +++ b/ts/lib/tag-editor/TagInput.svelte @@ -166,7 +166,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html } function onKeydown(event: KeyboardEvent): void { - switch (event.code) { + switch (event.key) { case "Enter": onEnter(event); break; diff --git a/ts/lib/tslib/keys.ts b/ts/lib/tslib/keys.ts index 9bd6b42d8..58f571fac 100644 --- a/ts/lib/tslib/keys.ts +++ b/ts/lib/tslib/keys.ts @@ -90,7 +90,7 @@ export function keyToPlatformString(key: string): string { } export function isArrowLeft(event: KeyboardEvent): boolean { - if (event.code === "ArrowLeft") { + if (event.key === "ArrowLeft") { return true; } @@ -98,7 +98,7 @@ export function isArrowLeft(event: KeyboardEvent): boolean { } export function isArrowRight(event: KeyboardEvent): boolean { - if (event.code === "ArrowRight") { + if (event.key === "ArrowRight") { return true; } @@ -106,7 +106,7 @@ export function isArrowRight(event: KeyboardEvent): boolean { } export function isArrowUp(event: KeyboardEvent): boolean { - if (event.code === "ArrowUp") { + if (event.key === "ArrowUp") { return true; } @@ -114,7 +114,7 @@ export function isArrowUp(event: KeyboardEvent): boolean { } export function isArrowDown(event: KeyboardEvent): boolean { - if (event.code === "ArrowDown") { + if (event.key === "ArrowDown") { return true; }