Refactor launcher + various tweaks

- Launcher can now be accessed via Tools>Upgrade/Downgrade
- Anki closes automatically on update
- When launcher not available, show update link like in the past
- It appears that access to the modern console host requires an app
to be built with the windows console subsystem, so we introduce an
extra anki-console.exe binary to relaunch ourselves with. Solves
https://forums.ankiweb.net/t/new-online-installer-launcher/62745/50
- Windows now requires you to close the terminal like on a Mac,
as I couldn't figure out how to have it automatically close. Suggestions
welcome!
- Reduce the amount of duplicate/near-duplicate code in the various
platform files, and improve readability
- Add a helper to install the current code into the launcher env
- Fix cargo test failing to build on ARM64 Windows
This commit is contained in:
Damien Elmes 2025-06-26 16:21:39 +07:00
parent 73edf23954
commit de7de82f76
20 changed files with 435 additions and 354 deletions

1
Cargo.lock generated
View file

@ -3543,6 +3543,7 @@ dependencies = [
"anki_io", "anki_io",
"anki_process", "anki_process",
"anyhow", "anyhow",
"camino",
"dirs 6.0.0", "dirs 6.0.0",
"embed-resource", "embed-resource",
"libc", "libc",

View file

@ -138,7 +138,7 @@ unic-ucd-category = "0.9.0"
unicode-normalization = "0.1.24" unicode-normalization = "0.1.24"
walkdir = "2.5.0" walkdir = "2.5.0"
which = "8.0.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"] } windows = { version = "0.61.3", features = ["Media_SpeechSynthesis", "Media_Core", "Foundation_Collections", "Storage_Streams"] }
wiremock = "0.6.3" wiremock = "0.6.3"
xz2 = "0.1.7" xz2 = "0.1.7"

View file

@ -46,3 +46,4 @@ qt-accel-zoom-editor-in = Zoom Editor &In
qt-accel-zoom-editor-out = Zoom Editor &Out qt-accel-zoom-editor-out = Zoom Editor &Out
qt-accel-create-backup = Create &Backup qt-accel-create-backup = Create &Backup
qt-accel-load-backup = &Revert to Backup qt-accel-load-backup = &Revert to Backup
qt-accel-upgrade-downgrade = Upgrade/Downgrade

View file

@ -73,7 +73,7 @@ qt-misc-second =
qt-misc-layout-auto-enabled = Responsive layout enabled qt-misc-layout-auto-enabled = Responsive layout enabled
qt-misc-layout-vertical-enabled = Vertical layout enabled qt-misc-layout-vertical-enabled = Vertical layout enabled
qt-misc-layout-horizontal-enabled = Horizontal 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 ## deprecated- these strings will be removed in the future, and do not need
## to be translated ## to be translated

View file

@ -46,7 +46,7 @@
<x>0</x> <x>0</x>
<y>0</y> <y>0</y>
<width>667</width> <width>667</width>
<height>24</height> <height>43</height>
</rect> </rect>
</property> </property>
<widget class="QMenu" name="menuHelp"> <widget class="QMenu" name="menuHelp">
@ -93,6 +93,7 @@
<addaction name="actionAdd_ons"/> <addaction name="actionAdd_ons"/>
<addaction name="separator"/> <addaction name="separator"/>
<addaction name="actionNoteTypes"/> <addaction name="actionNoteTypes"/>
<addaction name="action_upgrade_downgrade"/>
<addaction name="actionPreferences"/> <addaction name="actionPreferences"/>
</widget> </widget>
<widget class="QMenu" name="menuqt_accel_view"> <widget class="QMenu" name="menuqt_accel_view">
@ -130,7 +131,7 @@
<string notr="true">Ctrl+P</string> <string notr="true">Ctrl+P</string>
</property> </property>
<property name="menuRole"> <property name="menuRole">
<enum>QAction::PreferencesRole</enum> <enum>QAction::MenuRole::PreferencesRole</enum>
</property> </property>
</action> </action>
<action name="actionAbout"> <action name="actionAbout">
@ -283,6 +284,11 @@
<string>qt_accel_load_backup</string> <string>qt_accel_load_backup</string>
</property> </property>
</action> </action>
<action name="action_upgrade_downgrade">
<property name="text">
<string>qt_accel_upgrade_downgrade</string>
</property>
</action>
</widget> </widget>
<resources> <resources>
<include location="icons.qrc"/> <include location="icons.qrc"/>

View file

@ -1308,6 +1308,14 @@ title="{}" {}>{}</button>""".format(
def onPrefs(self) -> None: def onPrefs(self) -> None:
aqt.dialogs.open("Preferences", self) 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: def onNoteTypes(self) -> None:
import aqt.models import aqt.models
@ -1389,6 +1397,8 @@ title="{}" {}>{}</button>""".format(
########################################################################## ##########################################################################
def setupMenus(self) -> None: def setupMenus(self) -> None:
from aqt.update import have_launcher
m = self.form m = self.form
# File # File
@ -1418,6 +1428,9 @@ title="{}" {}>{}</button>""".format(
qconnect(m.actionCreateFiltered.triggered, self.onCram) qconnect(m.actionCreateFiltered.triggered, self.onCram)
qconnect(m.actionEmptyCards.triggered, self.onEmptyCards) qconnect(m.actionEmptyCards.triggered, self.onEmptyCards)
qconnect(m.actionNoteTypes.triggered, self.onNoteTypes) 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) qconnect(m.actionPreferences.triggered, self.onPrefs)
# View # View

View file

@ -1,7 +1,11 @@
# Copyright: Ankitects Pty Ltd and contributors # Copyright: Ankitects Pty Ltd and contributors
# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
from __future__ import annotations
import contextlib
import os import os
import subprocess
from pathlib import Path from pathlib import Path
import aqt 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 anki.utils import dev_mode, int_time, int_version, is_mac, is_win, plat_desc
from aqt.operations import QueryOp from aqt.operations import QueryOp
from aqt.qt import * 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: def check_for_update() -> None:
@ -80,22 +84,56 @@ def prompt_to_update(mw: aqt.AnkiQt, ver: str) -> None:
# ignore this update # ignore this update
mw.pm.meta["suppressUpdate"] = ver mw.pm.meta["suppressUpdate"] = ver
elif ret == QMessageBox.StandardButton.Yes: elif ret == QMessageBox.StandardButton.Yes:
if have_launcher():
update_and_restart() 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: def update_and_restart() -> None:
"""Download and install the update, then restart Anki.""" from aqt import mw
update_on_next_run()
# todo: do this automatically in the future launcher = _anki_launcher_path()
show_info(tr.qt_misc_please_restart_to_update_anki()) 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.""" """Bump the mtime on pyproject.toml in the local data directory to trigger an update on next run."""
try: try:
# Get the local data directory equivalent to Rust's dirs::data_local_dir() # Get the local data directory equivalent to Rust's dirs::data_local_dir()
if is_win: 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: elif is_mac:
data_dir = Path.home() / "Library" / "Application Support" data_dir = Path.home() / "Library" / "Application Support"
else: # Linux else: # Linux

View file

@ -11,6 +11,7 @@ rust-version.workspace = true
anki_io.workspace = true anki_io.workspace = true
anki_process.workspace = true anki_process.workspace = true
anyhow.workspace = true anyhow.workspace = true
camino.workspace = true
dirs.workspace = true dirs.workspace = true
[target.'cfg(windows)'.dependencies] [target.'cfg(windows)'.dependencies]
@ -22,5 +23,9 @@ libc-stdhandle.workspace = true
name = "build_win" name = "build_win"
path = "src/bin/build_win.rs" path = "src/bin/build_win.rs"
[[bin]]
name = "anki-console"
path = "src/bin/anki_console.rs"
[target.'cfg(windows)'.build-dependencies] [target.'cfg(windows)'.build-dependencies]
embed-resource.workspace = true embed-resource.workspace = true

View file

@ -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<String> = 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);
}
}

View file

@ -114,6 +114,12 @@ fn copy_files(output_dir: &Path) -> Result<()> {
let launcher_dst = output_dir.join("anki.exe"); let launcher_dst = output_dir.join("anki.exe");
copy_file(&launcher_src, &launcher_dst)?; 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 // Copy uv.exe and uvw.exe
let uv_src = PathBuf::from("../../../out/extracted/uv/uv.exe"); let uv_src = PathBuf::from("../../../out/extracted/uv/uv.exe");
let uv_dst = output_dir.join("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"), output_dir.join(".python-version"),
)?; )?;
// Copy anki-console.bat
copy_file("anki-console.bat", output_dir.join("anki-console.bat"))?;
Ok(()) Ok(())
} }
fn sign_binaries(output_dir: &Path) -> Result<()> { fn sign_binaries(output_dir: &Path) -> Result<()> {
sign_file(&output_dir.join("anki.exe"))?; sign_file(&output_dir.join("anki.exe"))?;
sign_file(&output_dir.join("anki-console.exe"))?;
sign_file(&output_dir.join("uv.exe"))?; sign_file(&output_dir.join("uv.exe"))?;
Ok(()) Ok(())
} }

View file

@ -16,21 +16,34 @@ use anki_io::modified_time;
use anki_io::read_file; use anki_io::read_file;
use anki_io::remove_file; use anki_io::remove_file;
use anki_io::write_file; use anki_io::write_file;
use anki_io::ToUtf8Path;
use anki_process::CommandExt; use anki_process::CommandExt;
use anyhow::Context; use anyhow::Context;
use anyhow::Result; use anyhow::Result;
use crate::platform::ensure_terminal_shown; 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_exe_and_resources_dirs;
use crate::platform::get_uv_binary_name; use crate::platform::get_uv_binary_name;
use crate::platform::handle_first_launch; use crate::platform::launch_anki_after_update;
use crate::platform::initial_terminal_setup; use crate::platform::launch_anki_normally;
use crate::platform::launch_anki_detached;
mod platform; 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)] #[derive(Debug, Clone)]
pub enum VersionKind { pub enum VersionKind {
PyOxidizer(String), PyOxidizer(String),
@ -46,16 +59,8 @@ pub enum MainMenuChoice {
Quit, Quit,
} }
#[derive(Debug, Clone, Default)]
pub struct Config {
pub show_console: bool,
}
fn main() { fn main() {
if let Err(e) = run() { if let Err(e) = run() {
let mut config: Config = Config::default();
initial_terminal_setup(&mut config);
eprintln!("Error: {:#}", e); eprintln!("Error: {:#}", e);
eprintln!("Press enter to close..."); eprintln!("Press enter to close...");
let mut input = String::new(); let mut input = String::new();
@ -66,58 +71,92 @@ fn main() {
} }
fn run() -> Result<()> { fn run() -> Result<()> {
let mut config: Config = Config::default();
let uv_install_root = dirs::data_local_dir() let uv_install_root = dirs::data_local_dir()
.context("Unable to determine data_dir")? .context("Unable to determine data_dir")?
.join("AnkiProgramFiles"); .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 (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 state = State {
let dist_python_version_path = resources_dir.join(".python-version"); has_existing_install: uv_install_root.join(".sync_complete").exists(),
let user_python_version_path = uv_install_root.join(".python-version"); prerelease_marker: uv_install_root.join("prerelease"),
let uv_lock_path = uv_install_root.join("uv.lock"); uv_install_root: uv_install_root.clone(),
let uv_path: std::path::PathBuf = exe_dir.join(get_uv_binary_name()); 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 install directory and copy project files in
create_dir_all(&uv_install_root)?; create_dir_all(&state.uv_install_root)?;
let had_user_pyproj = user_pyproject_path.exists(); let had_user_pyproj = state.user_pyproject_path.exists();
if !had_user_pyproj { if !had_user_pyproj {
// during initial launcher testing, enable betas by default // 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(&state.dist_pyproject_path, &state.user_pyproject_path)?;
copy_if_newer(&dist_python_version_path, &user_python_version_path)?; copy_if_newer(
&state.dist_python_version_path,
&state.user_python_version_path,
)?;
let pyproject_has_changed = !sync_complete_marker.exists() || { let pyproject_has_changed = !state.sync_complete_marker.exists() || {
let pyproject_toml_time = modified_time(&user_pyproject_path)?; let pyproject_toml_time = modified_time(&state.user_pyproject_path)?;
let sync_complete_time = modified_time(&sync_complete_marker)?; let sync_complete_time = modified_time(&state.sync_complete_marker)?;
Ok::<bool, anyhow::Error>(pyproject_toml_time > sync_complete_time) Ok::<bool, anyhow::Error>(pyproject_toml_time > sync_complete_time)
} }
.unwrap_or(true); .unwrap_or(true);
if !pyproject_has_changed { if !pyproject_has_changed {
// If venv is already up to date, exec as normal // If venv is already up to date, launch Anki normally
initial_terminal_setup(&mut config); let args: Vec<String> = std::env::args().skip(1).collect();
let anki_bin = get_anki_binary_path(&uv_install_root); let cmd = build_python_command(&state.uv_install_root, &args)?;
exec_anki(&anki_bin, &config)?; launch_anki_normally(cmd)?;
return Ok(()); 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()?; ensure_terminal_shown()?;
print!("\x1B[2J\x1B[H"); // Clear screen and move cursor to top print!("\x1B[2J\x1B[H"); // Clear screen and move cursor to top
println!("\x1B[1mAnki Launcher\x1B[0m\n"); println!("\x1B[1mAnki Launcher\x1B[0m\n");
// Check if there's an existing installation before removing marker main_menu_loop(&state)?;
let has_existing_install = sync_complete_marker.exists();
// 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 { 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 { match menu_choice {
MainMenuChoice::Quit => std::process::exit(0), MainMenuChoice::Quit => std::process::exit(0),
@ -127,40 +166,40 @@ fn run() -> Result<()> {
} }
MainMenuChoice::ToggleBetas => { MainMenuChoice::ToggleBetas => {
// Toggle beta prerelease file // Toggle beta prerelease file
if prerelease_marker.exists() { if state.prerelease_marker.exists() {
let _ = remove_file(&prerelease_marker); let _ = remove_file(&state.prerelease_marker);
println!("Beta releases disabled."); println!("Beta releases disabled.");
} else { } else {
write_file(&prerelease_marker, "")?; write_file(&state.prerelease_marker, "")?;
println!("Beta releases enabled."); println!("Beta releases enabled.");
} }
println!(); println!();
continue; continue;
} }
_ => { choice @ (MainMenuChoice::Latest | MainMenuChoice::Version(_)) => {
// For other choices, update project files and sync // For other choices, update project files and sync
update_pyproject_for_version( update_pyproject_for_version(
menu_choice.clone(), choice,
dist_pyproject_path.clone(), state.dist_pyproject_path.clone(),
user_pyproject_path.clone(), state.user_pyproject_path.clone(),
dist_python_version_path.clone(), state.dist_python_version_path.clone(),
user_python_version_path.clone(), state.user_python_version_path.clone(),
)?; )?;
// Remove sync marker before attempting sync // Remove sync marker before attempting sync
let _ = remove_file(&sync_complete_marker); let _ = remove_file(&state.sync_complete_marker);
// Sync the venv // Sync the venv
let mut command = Command::new(&uv_path); let mut command = Command::new(&state.uv_path);
command.current_dir(&uv_install_root).args([ command.current_dir(&state.uv_install_root).args([
"sync", "sync",
"--upgrade", "--upgrade",
"--managed-python", "--managed-python",
]); ]);
// Add python version if .python-version file exists // Add python version if .python-version file exists
if user_python_version_path.exists() { if state.user_python_version_path.exists() {
let python_version = read_file(&user_python_version_path)?; let python_version = read_file(&state.user_python_version_path)?;
let python_version_str = String::from_utf8(python_version) let python_version_str = String::from_utf8(python_version)
.context("Invalid UTF-8 in .python-version")?; .context("Invalid UTF-8 in .python-version")?;
let python_version_trimmed = python_version_str.trim(); let python_version_trimmed = python_version_str.trim();
@ -168,7 +207,7 @@ fn run() -> Result<()> {
} }
// Set UV_PRERELEASE=allow if beta mode is enabled // Set UV_PRERELEASE=allow if beta mode is enabled
if prerelease_marker.exists() { if state.prerelease_marker.exists() {
command.env("UV_PRERELEASE", "allow"); command.env("UV_PRERELEASE", "allow");
} }
@ -182,7 +221,7 @@ fn run() -> Result<()> {
Err(e) => { Err(e) => {
// If sync fails due to things like a missing wheel on pypi, // 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. // 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!("Install failed: {:#}", e);
println!(); println!();
continue; 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(()) Ok(())
} }
@ -403,3 +426,25 @@ fn parse_version_kind(version: &str) -> Option<VersionKind> {
Some(VersionKind::Uv(version.to_string())) Some(VersionKind::Uv(version.to_string()))
} }
} }
fn build_python_command(uv_install_root: &std::path::Path, 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 {
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)
}

View file

@ -1,7 +1,8 @@
// Copyright: Ankitects Pty Ltd and contributors // Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html // 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::process::Command;
use std::sync::atomic::AtomicBool; use std::sync::atomic::AtomicBool;
use std::sync::atomic::Ordering; use std::sync::atomic::Ordering;
@ -13,45 +14,7 @@ use anki_process::CommandExt as AnkiCommandExt;
use anyhow::Context; use anyhow::Context;
use anyhow::Result; use anyhow::Result;
// Re-export Unix functions that macOS uses pub fn prepare_for_launch_after_update(mut cmd: Command) -> Result<()> {
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,
};
// Pre-validate by running --version to trigger any Gatekeeper checks // Pre-validate by running --version to trigger any Gatekeeper checks
print!("\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(); 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") .env("ANKI_FIRST_RUN", "1")
.arg("--version") .arg("--version")
.stdout(std::process::Stdio::null()) .stdout(std::process::Stdio::null())
@ -81,22 +44,11 @@ pub fn handle_first_launch(anki_bin: &std::path::Path) -> Result<()> {
Ok(()) Ok(())
} }
pub fn get_exe_and_resources_dirs() -> Result<(std::path::PathBuf, std::path::PathBuf)> { pub fn relaunch_in_terminal() -> Result<()> {
let exe_dir = std::env::current_exe() let current_exe = std::env::current_exe().context("Failed to get current executable path")?;
.context("Failed to get current executable path")? Command::new("open")
.parent() .args(["-a", "Terminal"])
.context("Failed to get executable directory")? .arg(current_exe)
.to_owned(); .ensure_spawn()?;
std::process::exit(0);
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"
} }

View file

@ -1,18 +1,108 @@
// Copyright: Ankitects Pty Ltd and contributors // Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html // 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; mod unix;
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
mod mac; pub mod mac;
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
mod windows; pub mod windows;
#[cfg(target_os = "macos")] use std::path::PathBuf;
pub use mac::*;
#[cfg(all(unix, not(target_os = "macos")))] use anki_process::CommandExt;
pub use unix::*; use anyhow::Context;
#[cfg(target_os = "windows")] use anyhow::Result;
pub use windows::*;
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(())
}

View file

@ -1,36 +1,11 @@
// Copyright: Ankitects Pty Ltd and contributors // Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html // 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 std::process::Command;
use anki_process::CommandExt as AnkiCommandExt;
use anyhow::Context; use anyhow::Context;
use anyhow::Result; 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<()> { pub fn relaunch_in_terminal() -> Result<()> {
let current_exe = std::env::current_exe().context("Failed to get current executable path")?; 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 // If no terminal worked, continue without relaunching
Ok(()) 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<String> = 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"
}
}

View file

@ -1,82 +1,71 @@
// Copyright: Ankitects Pty Ltd and contributors // Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
use std::path::PathBuf;
use std::process::Command; use std::process::Command;
use anki_process::CommandExt;
use anyhow::Context; use anyhow::Context;
use anyhow::Result; use anyhow::Result;
use winapi::um::consoleapi;
use winapi::um::errhandlingapi;
use winapi::um::wincon; use winapi::um::wincon;
use crate::Config;
pub fn ensure_terminal_shown() -> Result<()> { 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 { unsafe {
if !wincon::GetConsoleWindow().is_null() { if !wincon::GetConsoleWindow().is_null() {
return; // We already have a console, no need to spawn anki-console.exe
return Ok(());
}
} }
if consoleapi::AllocConsole() == 0 { if std::env::var("ANKI_IMPLICIT_CONSOLE").is_ok() && attach_to_parent_console() {
let error_code = errhandlingapi::GetLastError(); // Successfully attached to parent console
eprintln!("unexpected AllocConsole error: {}", error_code); reconnect_stdio_to_console();
return; return Ok(());
} }
// This black magic triggers Windows to switch to the new // No console available, spawn anki-console.exe and exit
// ANSI-supporting console host, which is usually only available let current_exe = std::env::current_exe().context("Failed to get current executable path")?;
// when the app is built with the console subsystem. let exe_dir = current_exe
let _ = Command::new("cmd").args(&["/C", ""]).status(); .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 { unsafe {
if !wincon::GetConsoleWindow().is_null() { if !wincon::GetConsoleWindow().is_null() {
// we have a console already // we have a console already
println!("attach: already had console, false");
return false; return false;
} }
if wincon::AttachConsole(wincon::ATTACH_PARENT_PROCESS) != 0 { if wincon::AttachConsole(wincon::ATTACH_PARENT_PROCESS) != 0 {
// successfully attached to parent // successfully attached to parent
println!("attach: true"); reconnect_stdio_to_console();
true true
} else { } else {
println!("attach: false");
false false
} }
} }
} }
/// If parent process has a console (eg cmd.exe), redirect our output there. /// Reconnect stdin/stdout/stderr to the console.
/// Sets config.show_console to true if successfully attached to console. fn reconnect_stdio_to_console() {
pub fn initial_terminal_setup(config: &mut Config) {
use std::ffi::CString; use std::ffi::CString;
use libc_stdhandle::*; use libc_stdhandle::*;
if !attach_to_parent_console() {
return;
}
// we launched without a console, so we'll need to open stdin/out/err // we launched without a console, so we'll need to open stdin/out/err
let conin = CString::new("CONIN$").unwrap(); let conin = CString::new("CONIN$").unwrap();
let conout = CString::new("CONOUT$").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(), stdout());
libc::freopen(conout.as_ptr(), w.as_ptr(), stderr()); 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<Command> {
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<String> = 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"
} }

View file

@ -1,5 +0,0 @@
@echo off
"%~dp0"\anki %*
pause

View file

@ -1,5 +1,10 @@
@echo off @echo off
set CODESIGN=1 if "%NOCOMP%"=="1" (
REM set NO_COMPRESS=1 set NO_COMPRESS=1
set CODESIGN=0
) else (
set CODESIGN=1
set NO_COMPRESS=0
)
cargo run --bin build_win cargo run --bin build_win

View file

@ -13,4 +13,9 @@ path = "main.rs"
name = "anki-sync-server" name = "anki-sync-server"
[dependencies] [dependencies]
[target.'cfg(windows)'.dependencies]
anki = { workspace = true, features = ["native-tls"] }
[target.'cfg(not(windows))'.dependencies]
anki = { workspace = true, features = ["rustls"] } anki = { workspace = true, features = ["rustls"] }

15
tools/update-launcher-env Executable file
View file

@ -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/*

View file

@ -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"