diff --git a/Cargo.lock b/Cargo.lock
index 03f9e63c8..04e7c6c76 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -3543,6 +3543,7 @@ dependencies = [
"anki_io",
"anki_process",
"anyhow",
+ "camino",
"dirs 6.0.0",
"embed-resource",
"libc",
diff --git a/Cargo.toml b/Cargo.toml
index d2ce2ce2a..61cca8649 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", "errhandlingapi", "consoleapi"] }
+winapi = { version = "0.3", features = ["wincon"] }
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/ftl/qt/qt-accel.ftl b/ftl/qt/qt-accel.ftl
index 6c832b368..3ab54eb24 100644
--- a/ftl/qt/qt-accel.ftl
+++ b/ftl/qt/qt-accel.ftl
@@ -46,3 +46,4 @@ qt-accel-zoom-editor-in = Zoom Editor &In
qt-accel-zoom-editor-out = Zoom Editor &Out
qt-accel-create-backup = Create &Backup
qt-accel-load-backup = &Revert to Backup
+qt-accel-upgrade-downgrade = Upgrade/Downgrade
diff --git a/ftl/qt/qt-misc.ftl b/ftl/qt/qt-misc.ftl
index 60c22ef8b..d7bbef990 100644
--- a/ftl/qt/qt-misc.ftl
+++ b/ftl/qt/qt-misc.ftl
@@ -73,7 +73,7 @@ qt-misc-second =
qt-misc-layout-auto-enabled = Responsive layout enabled
qt-misc-layout-vertical-enabled = Vertical layout enabled
qt-misc-layout-horizontal-enabled = Horizontal layout enabled
-qt-misc-please-restart-to-update-anki = Please restart Anki to update to the latest version.
+qt-misc-open-anki-launcher = Change to a different Anki version?
## deprecated- these strings will be removed in the future, and do not need
## to be translated
diff --git a/qt/aqt/forms/main.ui b/qt/aqt/forms/main.ui
index 0687d4ef3..bffc67ad0 100644
--- a/qt/aqt/forms/main.ui
+++ b/qt/aqt/forms/main.ui
@@ -46,7 +46,7 @@
0
0
667
- 24
+ 43
diff --git a/qt/aqt/main.py b/qt/aqt/main.py
index 8e01208a4..b261cd34e 100644
--- a/qt/aqt/main.py
+++ b/qt/aqt/main.py
@@ -1308,6 +1308,14 @@ title="{}" {}>{}""".format(
def onPrefs(self) -> None:
aqt.dialogs.open("Preferences", self)
+ def on_upgrade_downgrade(self) -> None:
+ if not askUser(tr.qt_misc_open_anki_launcher()):
+ return
+
+ from aqt.update import update_and_restart
+
+ update_and_restart()
+
def onNoteTypes(self) -> None:
import aqt.models
@@ -1389,6 +1397,8 @@ title="{}" {}>{}""".format(
##########################################################################
def setupMenus(self) -> None:
+ from aqt.update import have_launcher
+
m = self.form
# File
@@ -1418,6 +1428,9 @@ title="{}" {}>{}""".format(
qconnect(m.actionCreateFiltered.triggered, self.onCram)
qconnect(m.actionEmptyCards.triggered, self.onEmptyCards)
qconnect(m.actionNoteTypes.triggered, self.onNoteTypes)
+ qconnect(m.action_upgrade_downgrade.triggered, self.on_upgrade_downgrade)
+ if not have_launcher():
+ m.action_upgrade_downgrade.setVisible(False)
qconnect(m.actionPreferences.triggered, self.onPrefs)
# View
diff --git a/qt/aqt/update.py b/qt/aqt/update.py
index d8e92426c..61fec8e6b 100644
--- a/qt/aqt/update.py
+++ b/qt/aqt/update.py
@@ -1,7 +1,11 @@
# Copyright: Ankitects Pty Ltd and contributors
# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
+from __future__ import annotations
+
+import contextlib
import os
+import subprocess
from pathlib import Path
import aqt
@@ -10,7 +14,7 @@ from anki.collection import CheckForUpdateResponse, Collection
from anki.utils import dev_mode, int_time, int_version, is_mac, is_win, plat_desc
from aqt.operations import QueryOp
from aqt.qt import *
-from aqt.utils import show_info, show_warning, showText, tr
+from aqt.utils import openLink, show_warning, showText, tr
def check_for_update() -> None:
@@ -80,22 +84,56 @@ def prompt_to_update(mw: aqt.AnkiQt, ver: str) -> None:
# ignore this update
mw.pm.meta["suppressUpdate"] = ver
elif ret == QMessageBox.StandardButton.Yes:
- update_and_restart()
+ if have_launcher():
+ update_and_restart()
+ else:
+ openLink(aqt.appWebsiteDownloadSection)
+
+
+def _anki_launcher_path() -> str | None:
+ return os.getenv("ANKI_LAUNCHER")
+
+
+def have_launcher() -> bool:
+ return _anki_launcher_path() is not None
def update_and_restart() -> None:
- """Download and install the update, then restart Anki."""
- update_on_next_run()
- # todo: do this automatically in the future
- show_info(tr.qt_misc_please_restart_to_update_anki())
+ from aqt import mw
+
+ launcher = _anki_launcher_path()
+ assert launcher
+
+ _trigger_launcher_run()
+
+ with contextlib.suppress(ResourceWarning):
+ env = os.environ.copy()
+ creationflags = 0
+ if sys.platform == "win32":
+ creationflags = (
+ subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.DETACHED_PROCESS
+ )
+ subprocess.Popen(
+ [launcher],
+ start_new_session=True,
+ stdin=subprocess.DEVNULL,
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL,
+ env=env,
+ creationflags=creationflags,
+ )
+
+ mw.app.quit()
-def update_on_next_run() -> None:
+def _trigger_launcher_run() -> None:
"""Bump the mtime on pyproject.toml in the local data directory to trigger an update on next run."""
try:
# Get the local data directory equivalent to Rust's dirs::data_local_dir()
if is_win:
- data_dir = Path(os.environ.get("LOCALAPPDATA", ""))
+ from .winpaths import get_local_appdata
+
+ data_dir = Path(get_local_appdata())
elif is_mac:
data_dir = Path.home() / "Library" / "Application Support"
else: # Linux
diff --git a/qt/launcher/Cargo.toml b/qt/launcher/Cargo.toml
index 45ca11e9b..735cd892e 100644
--- a/qt/launcher/Cargo.toml
+++ b/qt/launcher/Cargo.toml
@@ -11,6 +11,7 @@ rust-version.workspace = true
anki_io.workspace = true
anki_process.workspace = true
anyhow.workspace = true
+camino.workspace = true
dirs.workspace = true
[target.'cfg(windows)'.dependencies]
@@ -22,5 +23,9 @@ libc-stdhandle.workspace = true
name = "build_win"
path = "src/bin/build_win.rs"
+[[bin]]
+name = "anki-console"
+path = "src/bin/anki_console.rs"
+
[target.'cfg(windows)'.build-dependencies]
embed-resource.workspace = true
diff --git a/qt/launcher/src/bin/anki_console.rs b/qt/launcher/src/bin/anki_console.rs
new file mode 100644
index 000000000..596377ba1
--- /dev/null
+++ b/qt/launcher/src/bin/anki_console.rs
@@ -0,0 +1,58 @@
+// Copyright: Ankitects Pty Ltd and contributors
+// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
+
+#![windows_subsystem = "console"]
+
+use std::env;
+use std::io::stdin;
+use std::process::Command;
+
+use anyhow::Context;
+use anyhow::Result;
+
+fn main() {
+ if let Err(e) = run() {
+ eprintln!("Error: {:#}", e);
+ std::process::exit(1);
+ }
+}
+
+fn run() -> Result<()> {
+ let current_exe = env::current_exe().context("Failed to get current executable path")?;
+ let exe_dir = current_exe
+ .parent()
+ .context("Failed to get executable directory")?;
+
+ let anki_exe = exe_dir.join("anki.exe");
+
+ if !anki_exe.exists() {
+ anyhow::bail!("anki.exe not found in the same directory");
+ }
+
+ // Forward all command line arguments to anki.exe
+ let args: Vec = env::args().skip(1).collect();
+
+ let mut cmd = Command::new(&anki_exe);
+ cmd.args(&args);
+
+ if std::env::var("ANKI_IMPLICIT_CONSOLE").is_err() {
+ // if directly invoked by the user, signal the launcher that the
+ // user wants a Python console
+ std::env::set_var("ANKI_CONSOLE", "1");
+ }
+
+ // Wait for the process to complete and forward its exit code
+ let status = cmd.status().context("Failed to execute anki.exe")?;
+ if !status.success() {
+ println!("\nPress enter to close.");
+ let mut input = String::new();
+ let _ = stdin().read_line(&mut input);
+ }
+
+ if let Some(code) = status.code() {
+ std::process::exit(code);
+ } else {
+ // Process was terminated by a signal
+ std::process::exit(1);
+ }
+}
diff --git a/qt/launcher/src/bin/build_win.rs b/qt/launcher/src/bin/build_win.rs
index ff385d9ea..3ad2c7ce0 100644
--- a/qt/launcher/src/bin/build_win.rs
+++ b/qt/launcher/src/bin/build_win.rs
@@ -114,6 +114,12 @@ fn copy_files(output_dir: &Path) -> Result<()> {
let launcher_dst = output_dir.join("anki.exe");
copy_file(&launcher_src, &launcher_dst)?;
+ // Copy anki-console binary
+ let console_src =
+ PathBuf::from(CARGO_TARGET_DIR).join("x86_64-pc-windows-msvc/release/anki-console.exe");
+ let console_dst = output_dir.join("anki-console.exe");
+ copy_file(&console_src, &console_dst)?;
+
// Copy uv.exe and uvw.exe
let uv_src = PathBuf::from("../../../out/extracted/uv/uv.exe");
let uv_dst = output_dir.join("uv.exe");
@@ -133,14 +139,12 @@ fn copy_files(output_dir: &Path) -> Result<()> {
output_dir.join(".python-version"),
)?;
- // Copy anki-console.bat
- copy_file("anki-console.bat", output_dir.join("anki-console.bat"))?;
-
Ok(())
}
fn sign_binaries(output_dir: &Path) -> Result<()> {
sign_file(&output_dir.join("anki.exe"))?;
+ sign_file(&output_dir.join("anki-console.exe"))?;
sign_file(&output_dir.join("uv.exe"))?;
Ok(())
}
diff --git a/qt/launcher/src/main.rs b/qt/launcher/src/main.rs
index 2ad3ac00c..b2535f410 100644
--- a/qt/launcher/src/main.rs
+++ b/qt/launcher/src/main.rs
@@ -16,21 +16,34 @@ use anki_io::modified_time;
use anki_io::read_file;
use anki_io::remove_file;
use anki_io::write_file;
+use anki_io::ToUtf8Path;
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::initial_terminal_setup;
-use crate::platform::launch_anki_detached;
+use crate::platform::launch_anki_after_update;
+use crate::platform::launch_anki_normally;
mod platform;
+// todo: -c appearing as app name now
+
+struct State {
+ has_existing_install: bool,
+ prerelease_marker: std::path::PathBuf,
+ uv_install_root: std::path::PathBuf,
+ uv_path: std::path::PathBuf,
+ user_pyproject_path: std::path::PathBuf,
+ user_python_version_path: std::path::PathBuf,
+ dist_pyproject_path: std::path::PathBuf,
+ dist_python_version_path: std::path::PathBuf,
+ uv_lock_path: std::path::PathBuf,
+ sync_complete_marker: std::path::PathBuf,
+}
+
#[derive(Debug, Clone)]
pub enum VersionKind {
PyOxidizer(String),
@@ -46,16 +59,8 @@ pub enum MainMenuChoice {
Quit,
}
-#[derive(Debug, Clone, Default)]
-pub struct Config {
- pub show_console: bool,
-}
-
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();
@@ -66,58 +71,92 @@ fn main() {
}
fn run() -> Result<()> {
- let mut config: Config = Config::default();
-
let uv_install_root = dirs::data_local_dir()
.context("Unable to determine data_dir")?
.join("AnkiProgramFiles");
- let sync_complete_marker = uv_install_root.join(".sync_complete");
- let prerelease_marker = uv_install_root.join("prerelease");
let (exe_dir, resources_dir) = get_exe_and_resources_dirs()?;
- let dist_pyproject_path = resources_dir.join("pyproject.toml");
- let user_pyproject_path = uv_install_root.join("pyproject.toml");
- let dist_python_version_path = resources_dir.join(".python-version");
- let user_python_version_path = uv_install_root.join(".python-version");
- let uv_lock_path = uv_install_root.join("uv.lock");
- let uv_path: std::path::PathBuf = exe_dir.join(get_uv_binary_name());
+
+ let state = State {
+ has_existing_install: uv_install_root.join(".sync_complete").exists(),
+ prerelease_marker: uv_install_root.join("prerelease"),
+ uv_install_root: uv_install_root.clone(),
+ uv_path: exe_dir.join(get_uv_binary_name()),
+ user_pyproject_path: uv_install_root.join("pyproject.toml"),
+ user_python_version_path: uv_install_root.join(".python-version"),
+ 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"),
+ };
// Create install directory and copy project files in
- create_dir_all(&uv_install_root)?;
- let had_user_pyproj = user_pyproject_path.exists();
+ create_dir_all(&state.uv_install_root)?;
+ let had_user_pyproj = state.user_pyproject_path.exists();
if !had_user_pyproj {
// during initial launcher testing, enable betas by default
- write_file(&prerelease_marker, "")?;
+ write_file(&state.prerelease_marker, "")?;
}
- copy_if_newer(&dist_pyproject_path, &user_pyproject_path)?;
- copy_if_newer(&dist_python_version_path, &user_python_version_path)?;
+ copy_if_newer(&state.dist_pyproject_path, &state.user_pyproject_path)?;
+ copy_if_newer(
+ &state.dist_python_version_path,
+ &state.user_python_version_path,
+ )?;
- 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)?;
+ let pyproject_has_changed = !state.sync_complete_marker.exists() || {
+ let pyproject_toml_time = modified_time(&state.user_pyproject_path)?;
+ let sync_complete_time = modified_time(&state.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)?;
+ // If venv is already up to date, launch Anki normally
+ let args: Vec = std::env::args().skip(1).collect();
+ let cmd = build_python_command(&state.uv_install_root, &args)?;
+ launch_anki_normally(cmd)?;
return Ok(());
}
- // we'll need to launch uv; reinvoke ourselves in a terminal so the user can see
+ // If we weren't in a terminal, respawn ourselves in one
ensure_terminal_shown()?;
+
print!("\x1B[2J\x1B[H"); // Clear screen and move cursor to top
println!("\x1B[1mAnki Launcher\x1B[0m\n");
- // Check if there's an existing installation before removing marker
- let has_existing_install = sync_complete_marker.exists();
+ main_menu_loop(&state)?;
+ // Write marker file to indicate we've completed the sync process
+ write_sync_marker(&state.sync_complete_marker)?;
+
+ #[cfg(target_os = "macos")]
+ {
+ let cmd = build_python_command(&state.uv_install_root, &[])?;
+ platform::mac::prepare_for_launch_after_update(cmd)?;
+ }
+
+ if cfg!(unix) && !cfg!(target_os = "macos") {
+ println!("\nPress enter to start Anki.");
+ let mut input = String::new();
+ let _ = stdin().read_line(&mut input);
+ } else {
+ // on Windows/macOS, the user needs to close the terminal/console
+ // currently, but ideas on how we can avoid this would be good!
+ println!("Anki will start shortly.");
+ println!("\x1B[1mYou can close this window.\x1B[0m\n");
+ }
+
+ let cmd = build_python_command(&state.uv_install_root, &[])?;
+ launch_anki_after_update(cmd)?;
+
+ Ok(())
+}
+
+fn main_menu_loop(state: &State) -> Result<()> {
loop {
- let menu_choice = get_main_menu_choice(has_existing_install, &prerelease_marker);
+ let menu_choice =
+ get_main_menu_choice(state.has_existing_install, &state.prerelease_marker);
match menu_choice {
MainMenuChoice::Quit => std::process::exit(0),
@@ -127,40 +166,40 @@ fn run() -> Result<()> {
}
MainMenuChoice::ToggleBetas => {
// Toggle beta prerelease file
- if prerelease_marker.exists() {
- let _ = remove_file(&prerelease_marker);
+ if state.prerelease_marker.exists() {
+ let _ = remove_file(&state.prerelease_marker);
println!("Beta releases disabled.");
} else {
- write_file(&prerelease_marker, "")?;
+ write_file(&state.prerelease_marker, "")?;
println!("Beta releases enabled.");
}
println!();
continue;
}
- _ => {
+ choice @ (MainMenuChoice::Latest | MainMenuChoice::Version(_)) => {
// 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(),
+ choice,
+ state.dist_pyproject_path.clone(),
+ state.user_pyproject_path.clone(),
+ state.dist_python_version_path.clone(),
+ state.user_python_version_path.clone(),
)?;
// Remove sync marker before attempting sync
- let _ = remove_file(&sync_complete_marker);
+ let _ = remove_file(&state.sync_complete_marker);
// Sync the venv
- let mut command = Command::new(&uv_path);
- command.current_dir(&uv_install_root).args([
+ let mut command = Command::new(&state.uv_path);
+ command.current_dir(&state.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)?;
+ if state.user_python_version_path.exists() {
+ let python_version = read_file(&state.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();
@@ -168,7 +207,7 @@ fn run() -> Result<()> {
}
// Set UV_PRERELEASE=allow if beta mode is enabled
- if prerelease_marker.exists() {
+ if state.prerelease_marker.exists() {
command.env("UV_PRERELEASE", "allow");
}
@@ -182,7 +221,7 @@ fn run() -> Result<()> {
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);
+ let _ = remove_file(&state.uv_lock_path);
println!("Install failed: {:#}", e);
println!();
continue;
@@ -191,22 +230,6 @@ fn run() -> Result<()> {
}
}
}
-
- // 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(())
}
@@ -403,3 +426,25 @@ fn parse_version_kind(version: &str) -> Option {
Some(VersionKind::Uv(version.to_string()))
}
}
+
+fn build_python_command(uv_install_root: &std::path::Path, args: &[String]) -> Result {
+ let python_exe = if cfg!(target_os = "windows") {
+ let show_console = std::env::var("ANKI_CONSOLE").is_ok();
+ if show_console {
+ uv_install_root.join(".venv/Scripts/python.exe")
+ } else {
+ uv_install_root.join(".venv/Scripts/pythonw.exe")
+ }
+ } else {
+ uv_install_root.join(".venv/bin/python")
+ };
+
+ let mut cmd = Command::new(python_exe);
+ cmd.args(["-c", "import aqt; 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());
+
+ Ok(cmd)
+}
diff --git a/qt/launcher/src/platform/mac.rs b/qt/launcher/src/platform/mac.rs
index 2369f538a..ab2c4b8fb 100644
--- a/qt/launcher/src/platform/mac.rs
+++ b/qt/launcher/src/platform/mac.rs
@@ -1,7 +1,8 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-use std::os::unix::process::CommandExt;
+use std::io;
+use std::io::Write;
use std::process::Command;
use std::sync::atomic::AtomicBool;
use std::sync::atomic::Ordering;
@@ -13,45 +14,7 @@ use anki_process::CommandExt as AnkiCommandExt;
use anyhow::Context;
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,
-};
-
-pub fn launch_anki_detached(anki_bin: &std::path::Path, _config: &crate::Config) -> Result<()> {
- use std::process::Stdio;
-
- let child = Command::new(anki_bin)
- .stdin(Stdio::null())
- .stdout(Stdio::null())
- .stderr(Stdio::null())
- .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(())
-}
-
-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"])
- .arg(current_exe)
- .ensure_spawn()?;
- std::process::exit(0);
-}
-
-pub fn handle_first_launch(anki_bin: &std::path::Path) -> Result<()> {
- use std::io::Write;
- use std::io::{
- self,
- };
-
+pub fn prepare_for_launch_after_update(mut cmd: Command) -> 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();
@@ -67,7 +30,7 @@ pub fn handle_first_launch(anki_bin: &std::path::Path) -> Result<()> {
}
});
- let _ = Command::new(anki_bin)
+ let _ = cmd
.env("ANKI_FIRST_RUN", "1")
.arg("--version")
.stdout(std::process::Stdio::null())
@@ -81,22 +44,11 @@ pub fn handle_first_launch(anki_bin: &std::path::Path) -> Result<()> {
Ok(())
}
-pub fn get_exe_and_resources_dirs() -> Result<(std::path::PathBuf, std::path::PathBuf)> {
- let exe_dir = std::env::current_exe()
- .context("Failed to get current executable path")?
- .parent()
- .context("Failed to get executable directory")?
- .to_owned();
-
- let resources_dir = exe_dir
- .parent()
- .context("Failed to get parent directory")?
- .join("Resources");
-
- Ok((exe_dir, resources_dir))
-}
-
-pub fn get_uv_binary_name() -> &'static str {
- // macOS uses standard uv binary name
- "uv"
+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"])
+ .arg(current_exe)
+ .ensure_spawn()?;
+ std::process::exit(0);
}
diff --git a/qt/launcher/src/platform/mod.rs b/qt/launcher/src/platform/mod.rs
index bb7208abe..9dc74f8e9 100644
--- a/qt/launcher/src/platform/mod.rs
+++ b/qt/launcher/src/platform/mod.rs
@@ -1,18 +1,108 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-#[cfg(unix)]
+#[cfg(all(unix, not(target_os = "macos")))]
mod unix;
#[cfg(target_os = "macos")]
-mod mac;
+pub mod mac;
#[cfg(target_os = "windows")]
-mod windows;
+pub mod windows;
-#[cfg(target_os = "macos")]
-pub use mac::*;
-#[cfg(all(unix, not(target_os = "macos")))]
-pub use unix::*;
-#[cfg(target_os = "windows")]
-pub use windows::*;
+use std::path::PathBuf;
+
+use anki_process::CommandExt;
+use anyhow::Context;
+use anyhow::Result;
+
+pub fn get_exe_and_resources_dirs() -> Result<(PathBuf, PathBuf)> {
+ let exe_dir = std::env::current_exe()
+ .context("Failed to get current executable path")?
+ .parent()
+ .context("Failed to get executable directory")?
+ .to_owned();
+
+ let resources_dir = if cfg!(target_os = "macos") {
+ // On macOS, resources are in ../Resources relative to the executable
+ exe_dir
+ .parent()
+ .context("Failed to get parent directory")?
+ .join("Resources")
+ } else {
+ // On other platforms, resources are in the same directory as executable
+ exe_dir.clone()
+ };
+
+ Ok((exe_dir, resources_dir))
+}
+
+pub fn get_uv_binary_name() -> &'static str {
+ if cfg!(target_os = "windows") {
+ "uv.exe"
+ } else if cfg!(target_os = "macos") {
+ "uv"
+ } else if cfg!(target_arch = "x86_64") {
+ "uv.amd64"
+ } else {
+ "uv.arm64"
+ }
+}
+
+pub fn launch_anki_after_update(mut cmd: std::process::Command) -> Result<()> {
+ use std::process::Stdio;
+
+ cmd.stdin(Stdio::null())
+ .stdout(Stdio::null())
+ .stderr(Stdio::null());
+
+ #[cfg(windows)]
+ {
+ use std::os::windows::process::CommandExt;
+ const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200;
+ const DETACHED_PROCESS: u32 = 0x00000008;
+ cmd.creation_flags(CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS);
+ }
+
+ #[cfg(unix)]
+ {
+ use std::os::unix::process::CommandExt;
+ cmd.process_group(0);
+ }
+
+ let child = cmd.ensure_spawn()?;
+ std::mem::forget(child);
+
+ Ok(())
+}
+
+pub fn launch_anki_normally(mut cmd: std::process::Command) -> Result<()> {
+ #[cfg(windows)]
+ {
+ crate::platform::windows::attach_to_parent_console();
+ cmd.ensure_success()?;
+ }
+ #[cfg(unix)]
+ cmd.ensure_exec()?;
+ Ok(())
+}
+
+#[cfg(windows)]
+pub use windows::ensure_terminal_shown;
+
+#[cfg(unix)]
+pub fn ensure_terminal_shown() -> Result<()> {
+ use std::io::IsTerminal;
+
+ let stdout_is_terminal = IsTerminal::is_terminal(&std::io::stdout());
+ if !stdout_is_terminal {
+ #[cfg(target_os = "macos")]
+ mac::relaunch_in_terminal()?;
+ #[cfg(not(target_os = "macos"))]
+ unix::relaunch_in_terminal()?;
+ }
+
+ // Set terminal title to "Anki Launcher"
+ print!("\x1b]2;Anki Launcher\x07");
+ Ok(())
+}
diff --git a/qt/launcher/src/platform/unix.rs b/qt/launcher/src/platform/unix.rs
index 324bf5aa3..0430bfa96 100644
--- a/qt/launcher/src/platform/unix.rs
+++ b/qt/launcher/src/platform/unix.rs
@@ -1,36 +1,11 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-#![allow(dead_code)]
-
-use std::io::IsTerminal;
-use std::path::PathBuf;
use std::process::Command;
-use anki_process::CommandExt as AnkiCommandExt;
use anyhow::Context;
use anyhow::Result;
-use crate::Config;
-
-pub fn initial_terminal_setup(_config: &mut Config) {
- // No special terminal setup needed on Unix
-}
-
-pub fn ensure_terminal_shown() -> Result<()> {
- 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")?;
@@ -72,52 +47,3 @@ pub fn relaunch_in_terminal() -> Result<()> {
// If no terminal worked, continue without relaunching
Ok(())
}
-
-pub fn get_anki_binary_path(uv_install_root: &std::path::Path) -> PathBuf {
- uv_install_root.join(".venv/bin/anki")
-}
-
-pub fn launch_anki_detached(anki_bin: &std::path::Path, config: &Config) -> Result<()> {
- // On non-macOS Unix systems, we don't need to detach since we never spawned a
- // terminal
- exec_anki(anki_bin, config)
-}
-
-pub fn handle_first_launch(_anki_bin: &std::path::Path) -> Result<()> {
- // No special first launch handling needed for generic Unix systems
- Ok(())
-}
-
-pub fn exec_anki(anki_bin: &std::path::Path, _config: &Config) -> Result<()> {
- let args: Vec = std::env::args().skip(1).collect();
- Command::new(anki_bin)
- .args(args)
- .ensure_exec()
- .map_err(anyhow::Error::new)
-}
-
-pub fn get_exe_and_resources_dirs() -> Result<(PathBuf, PathBuf)> {
- let exe_dir = std::env::current_exe()
- .context("Failed to get current executable path")?
- .parent()
- .context("Failed to get executable directory")?
- .to_owned();
-
- // On generic Unix systems, assume resources are in the same directory as
- // executable
- let resources_dir = exe_dir.clone();
-
- Ok((exe_dir, resources_dir))
-}
-
-pub fn get_uv_binary_name() -> &'static str {
- // Use architecture-specific uv binary for non-Mac Unix systems
- if cfg!(target_arch = "x86_64") {
- "uv.amd64"
- } else if cfg!(target_arch = "aarch64") {
- "uv.arm64"
- } else {
- // Fallback to generic uv for other architectures
- "uv"
- }
-}
diff --git a/qt/launcher/src/platform/windows.rs b/qt/launcher/src/platform/windows.rs
index 4e3752d44..0a701c07a 100644
--- a/qt/launcher/src/platform/windows.rs
+++ b/qt/launcher/src/platform/windows.rs
@@ -1,82 +1,71 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-use std::path::PathBuf;
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 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;
+ // We already have a console, no need to spawn anki-console.exe
+ return Ok(());
}
-
- 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();
}
+
+ if std::env::var("ANKI_IMPLICIT_CONSOLE").is_ok() && attach_to_parent_console() {
+ // Successfully attached to parent console
+ reconnect_stdio_to_console();
+ return Ok(());
+ }
+
+ // No console available, spawn anki-console.exe and exit
+ let current_exe = std::env::current_exe().context("Failed to get current executable path")?;
+ let exe_dir = current_exe
+ .parent()
+ .context("Failed to get executable directory")?;
+
+ let console_exe = exe_dir.join("anki-console.exe");
+
+ if !console_exe.exists() {
+ anyhow::bail!("anki-console.exe not found in the same directory");
+ }
+
+ // Spawn anki-console.exe without waiting
+ Command::new(&console_exe)
+ .env("ANKI_IMPLICIT_CONSOLE", "1")
+ .spawn()
+ .context("Failed to spawn anki-console.exe")?;
+
+ // Exit immediately after spawning
+ std::process::exit(0);
}
-fn attach_to_parent_console() -> bool {
+pub 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");
+ reconnect_stdio_to_console();
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) {
+/// Reconnect stdin/stdout/stderr to the console.
+fn reconnect_stdio_to_console() {
use std::ffi::CString;
use libc_stdhandle::*;
- 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();
@@ -89,79 +78,4 @@ pub fn initial_terminal_setup(config: &mut Config) {
libc::freopen(conout.as_ptr(), w.as_ptr(), stdout());
libc::freopen(conout.as_ptr(), w.as_ptr(), stderr());
}
-
- config.show_console = true;
-}
-
-pub fn get_anki_binary_path(uv_install_root: &std::path::Path) -> std::path::PathBuf {
- uv_install_root.join(".venv/Scripts/anki.exe")
-}
-
-fn build_python_command(
- anki_bin: &std::path::Path,
- args: &[String],
- config: &Config,
-) -> Result {
- let venv_dir = anki_bin
- .parent()
- .context("Failed to get venv Scripts directory")?
- .parent()
- .context("Failed to get venv directory")?;
-
- // Use python.exe if show_console is true, otherwise pythonw.exe
- let python_exe = if config.show_console {
- venv_dir.join("Scripts/python.exe")
- } else {
- venv_dir.join("Scripts/pythonw.exe")
- };
-
- let mut cmd = Command::new(python_exe);
- cmd.args(["-c", "import aqt; aqt.run()"]);
- cmd.args(args);
-
- Ok(cmd)
-}
-
-pub fn launch_anki_detached(anki_bin: &std::path::Path, config: &Config) -> Result<()> {
- use std::os::windows::process::CommandExt;
- use std::process::Stdio;
-
- const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200;
- const DETACHED_PROCESS: u32 = 0x00000008;
-
- let mut cmd = build_python_command(anki_bin, &[], config)?;
- cmd.stdin(Stdio::null())
- .stdout(Stdio::null())
- .stderr(Stdio::null())
- .creation_flags(CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS)
- .ensure_spawn()?;
- Ok(())
-}
-
-pub fn handle_first_launch(_anki_bin: &std::path::Path) -> Result<()> {
- Ok(())
-}
-
-pub fn exec_anki(anki_bin: &std::path::Path, config: &Config) -> Result<()> {
- let args: Vec = std::env::args().skip(1).collect();
- let mut cmd = build_python_command(anki_bin, &args, config)?;
- cmd.ensure_success()?;
- Ok(())
-}
-
-pub fn get_exe_and_resources_dirs() -> Result<(PathBuf, PathBuf)> {
- let exe_dir = std::env::current_exe()
- .context("Failed to get current executable path")?
- .parent()
- .context("Failed to get executable directory")?
- .to_owned();
-
- // On Windows, resources dir is the same as exe_dir
- let resources_dir = exe_dir.clone();
-
- Ok((exe_dir, resources_dir))
-}
-
-pub fn get_uv_binary_name() -> &'static str {
- "uv.exe"
}
diff --git a/qt/launcher/win/anki-console.bat b/qt/launcher/win/anki-console.bat
deleted file mode 100644
index a565fa7b6..000000000
--- a/qt/launcher/win/anki-console.bat
+++ /dev/null
@@ -1,5 +0,0 @@
-@echo off
-"%~dp0"\anki %*
-pause
-
-
diff --git a/qt/launcher/win/build.bat b/qt/launcher/win/build.bat
index b21831462..da574f210 100644
--- a/qt/launcher/win/build.bat
+++ b/qt/launcher/win/build.bat
@@ -1,5 +1,10 @@
@echo off
-set CODESIGN=1
-REM set NO_COMPRESS=1
+if "%NOCOMP%"=="1" (
+ set NO_COMPRESS=1
+ set CODESIGN=0
+) else (
+ set CODESIGN=1
+ set NO_COMPRESS=0
+)
cargo run --bin build_win
diff --git a/rslib/sync/Cargo.toml b/rslib/sync/Cargo.toml
index 7a8f8534a..d23b4f380 100644
--- a/rslib/sync/Cargo.toml
+++ b/rslib/sync/Cargo.toml
@@ -13,4 +13,9 @@ path = "main.rs"
name = "anki-sync-server"
[dependencies]
+
+[target.'cfg(windows)'.dependencies]
+anki = { workspace = true, features = ["native-tls"] }
+
+[target.'cfg(not(windows))'.dependencies]
anki = { workspace = true, features = ["rustls"] }
diff --git a/tools/update-launcher-env b/tools/update-launcher-env
new file mode 100755
index 000000000..c84569f55
--- /dev/null
+++ b/tools/update-launcher-env
@@ -0,0 +1,15 @@
+#!/bin/bash
+#
+# Install our latest anki/aqt code into the launcher venv
+
+set -e
+
+rm -rf out/wheels
+./ninja wheels
+if [[ "$OSTYPE" == "darwin"* ]]; then
+ export VIRTUAL_ENV=$HOME/Library/Application\ Support/AnkiProgramFiles/.venv
+else
+ export VIRTUAL_ENV=$HOME/.local/share/AnkiProgramFiles/.venv
+fi
+./out/extracted/uv/uv pip install out/wheels/*
+
diff --git a/tools/update-launcher-env.bat b/tools/update-launcher-env.bat
new file mode 100644
index 000000000..9b0b814c6
--- /dev/null
+++ b/tools/update-launcher-env.bat
@@ -0,0 +1,8 @@
+@echo off
+rem
+rem Install our latest anki/aqt code into the launcher venv
+
+rmdir /s /q out\wheels 2>nul
+call tools\ninja wheels
+set VIRTUAL_ENV=%LOCALAPPDATA%\AnkiProgramFiles\.venv
+for %%f in (out\wheels\*.whl) do out\extracted\uv\uv pip install "%%f"
\ No newline at end of file