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_process",
"anyhow",
"camino",
"dirs 6.0.0",
"embed-resource",
"libc",

View file

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

View file

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

View file

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

View file

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

View file

@ -1308,6 +1308,14 @@ title="{}" {}>{}</button>""".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="{}" {}>{}</button>""".format(
##########################################################################
def setupMenus(self) -> None:
from aqt.update import have_launcher
m = self.form
# File
@ -1418,6 +1428,9 @@ title="{}" {}>{}</button>""".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

View file

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

View file

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

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");
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(())
}

View file

@ -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::<bool, anyhow::Error>(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<String> = 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<VersionKind> {
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
// 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);
}

View file

@ -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(())
}

View file

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

View file

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

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"