Extend launcher to other platforms + more

- Switch to Qt 6.8 for repo default, as 6.7 depends on an older
libwebp/tiff which is unavailable on newer installs
- Drop tools/mac-x86, as we no longer need to test against Qt 5
- Add flags to cross compile wheels on Mac and Linux
- Bump glibc target to 2_36, building on Debian Stable
- Increase mpv timeout on macOS to allow for initial gatekeeper checks
- Ship both arm64 and amd64 uv on Linux, with a bash stub to pick
the appropriate arch.
This commit is contained in:
Damien Elmes 2025-06-15 13:13:39 +07:00
parent cefb1a2997
commit b32a972f1f
39 changed files with 2178 additions and 1123 deletions

View file

@ -35,6 +35,9 @@
},
"rust-analyzer.cargo.buildScripts.enable": true,
"python.analysis.typeCheckingMode": "off",
"python.analysis.exclude": [
"out/launcher/**"
],
"terminal.integrated.env.windows": {
"PATH": "c:\\msys64\\usr\\bin;${env:Path}"
}

98
Cargo.lock generated
View file

@ -1936,6 +1936,20 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f878075b9794c1e4ac788c95b728f26aa6366d32eeb10c7051389f898f7d067"
[[package]]
name = "embed-resource"
version = "2.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b68b6f9f63a0b6a38bc447d4ce84e2b388f3ec95c99c641c8ff0dd3ef89a6379"
dependencies = [
"cc",
"memchr",
"rustc_version",
"toml 0.8.21",
"vswhom",
"winreg 0.52.0",
]
[[package]]
name = "encoding_rs"
version = "0.8.35"
@ -3579,7 +3593,14 @@ dependencies = [
name = "launcher"
version = "1.0.0"
dependencies = [
"anki_io",
"anki_process",
"anyhow",
"dirs 5.0.1",
"embed-resource",
"libc",
"libc-stdhandle",
"winapi",
]
[[package]]
@ -3594,6 +3615,16 @@ version = "0.2.172"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d750af042f7ef4f724306de029d18836c26c1765a54a6a3f094cbd23a7267ffa"
[[package]]
name = "libc-stdhandle"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6dac2473dc28934c5e0b82250dab231c9d3b94160d91fe9ff483323b05797551"
dependencies = [
"cc",
"libc",
]
[[package]]
name = "libdbus-sys"
version = "0.2.5"
@ -3906,7 +3937,7 @@ dependencies = [
"shlex",
"tempfile",
"tokio",
"toml",
"toml 0.5.11",
"topological-sort",
"walkdir",
"warp",
@ -5400,7 +5431,7 @@ dependencies = [
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"winreg",
"winreg 0.50.0",
]
[[package]]
@ -5900,6 +5931,15 @@ dependencies = [
"syn 2.0.101",
]
[[package]]
name = "serde_spanned"
version = "0.6.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
dependencies = [
"serde",
]
[[package]]
name = "serde_tuple"
version = "0.5.0"
@ -6589,11 +6629,26 @@ dependencies = [
"serde",
]
[[package]]
name = "toml"
version = "0.8.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "900f6c86a685850b1bc9f6223b20125115ee3f31e01207d81655bbcc0aea9231"
dependencies = [
"serde",
"serde_spanned",
"toml_datetime",
"toml_edit",
]
[[package]]
name = "toml_datetime"
version = "0.6.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3da5db5a963e24bc68be8b17b6fa82814bb22ee8660f192bb182771d498f09a3"
dependencies = [
"serde",
]
[[package]]
name = "toml_edit"
@ -6602,10 +6657,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10558ed0bd2a1562e630926a2d1f0b98c827da99fabd3fe20920a59642504485"
dependencies = [
"indexmap",
"serde",
"serde_spanned",
"toml_datetime",
"toml_write",
"winnow",
]
[[package]]
name = "toml_write"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
[[package]]
name = "topological-sort"
version = "0.2.2"
@ -7020,6 +7084,26 @@ version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "vswhom"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be979b7f07507105799e854203b470ff7c78a1639e330a58f183b5fea574608b"
dependencies = [
"libc",
"vswhom-sys",
]
[[package]]
name = "vswhom-sys"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fb067e4cbd1ff067d1df46c9194b5de0e98efd2810bbc95c5d5e5f25a3231150"
dependencies = [
"cc",
"libc",
]
[[package]]
name = "walkdir"
version = "2.5.0"
@ -7878,6 +7962,16 @@ dependencies = [
"windows-sys 0.48.0",
]
[[package]]
name = "winreg"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a277a57398d4bfa075df44f501a17cfdf8542d224f0d36095a2adc7aee4ef0a5"
dependencies = [
"cfg-if",
"windows-sys 0.48.0",
]
[[package]]
name = "wiremock"
version = "0.6.3"

View file

@ -74,6 +74,7 @@ data-encoding = "2.6.0"
difflib = "0.4.0"
dirs = "5.0.1"
dunce = "1.0.5"
embed-resource = "2.4"
envy = "0.4.2"
flate2 = "1.0.34"
fluent = "0.16.1"
@ -92,6 +93,8 @@ intl-memoizer = "0.5.2"
itertools = "0.13.0"
junction = "1.2.0"
lazy_static = "1.5.0"
libc = "0.2"
libc-stdhandle = "0.1"
maplit = "1.0.2"
nom = "7.1.3"
num-format = "0.4.4"
@ -141,6 +144,7 @@ unic-ucd-category = "0.9.0"
unicode-normalization = "0.1.24"
walkdir = "2.5.0"
which = "5.0.0"
winapi = { version = "0.3", features = ["wincon"] }
wiremock = "0.6.2"
xz2 = "0.1.7"
zip = { version = "0.6.6", default-features = false, features = ["deflate", "time"] }

View file

@ -1,36 +1,20 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
#![allow(dead_code)]
#![allow(unused_imports)]
use std::env;
use anyhow::Result;
use ninja_gen::action::BuildAction;
use ninja_gen::archives::download_and_extract;
use ninja_gen::archives::empty_manifest;
use ninja_gen::archives::with_exe;
use ninja_gen::archives::OnlineArchive;
use ninja_gen::archives::Platform;
use ninja_gen::build::BuildProfile;
use ninja_gen::cargo::CargoBuild;
use ninja_gen::cargo::RustOutput;
use ninja_gen::command::RunCommand;
use ninja_gen::git::SyncSubmodule;
use ninja_gen::glob;
use ninja_gen::hashmap;
use ninja_gen::input::BuildInput;
use ninja_gen::inputs;
use ninja_gen::python::PythonEnvironment;
use ninja_gen::Build;
use ninja_gen::Utf8Path;
use crate::anki_version;
use crate::platform::overriden_python_target_platform;
use crate::platform::overriden_rust_target_triple;
pub fn setup_uv_universal(build: &mut Build) -> Result<()> {
if !cfg!(target_arch = "aarch64") {
return Ok(());
}
build.add_action(
"launcher:uv_universal",
RunCommand {

View file

@ -20,7 +20,7 @@ use ninja_gen::protobuf::check_proto;
use ninja_gen::protobuf::setup_protoc;
use ninja_gen::python::setup_uv;
use ninja_gen::Build;
use platform::overriden_python_target_platform;
use platform::overriden_python_venv_platform;
use pylib::build_pylib;
use pylib::check_pylib;
use python::check_python;
@ -50,7 +50,7 @@ fn main() -> Result<()> {
if env::var("OFFLINE_BUILD").is_err() {
setup_uv(
build,
overriden_python_target_platform().unwrap_or(build.host_platform),
overriden_python_venv_platform().unwrap_or(build.host_platform),
)?;
}
setup_venv(build)?;

View file

@ -7,18 +7,28 @@ use ninja_gen::archives::Platform;
/// Please see [`overriden_python_target_platform()`] for details.
pub fn overriden_rust_target_triple() -> Option<&'static str> {
overriden_python_target_platform().map(|p| p.as_rust_triple())
overriden_python_wheel_platform().map(|p| p.as_rust_triple())
}
/// Usually None to use the host architecture, except on Windows which
/// always uses x86_64.
/// On a Mac, set MAC_X86 to build for x86_64 on Apple Silicon.
pub fn overriden_python_target_platform() -> Option<Platform> {
/// always uses x86_64, since WebEngine is unavailable for ARM64.
pub fn overriden_python_venv_platform() -> Option<Platform> {
if cfg!(target_os = "windows") {
Some(Platform::WindowsX64)
} else if env::var("MAC_X86").is_ok() {
Some(Platform::MacX64)
} else {
None
}
}
/// Like [`overriden_python_venv_platform`], but:
/// If MAC_X86 is set, an X86 wheel will be built on macOS ARM.
/// If LIN_ARM64 is set, an ARM64 wheel will be built on Linux AMD64.
pub fn overriden_python_wheel_platform() -> Option<Platform> {
if env::var("MAC_X86").is_ok() {
Some(Platform::MacX64)
} else if env::var("LIN_ARM64").is_ok() {
Some(Platform::LinuxArm)
} else {
overriden_python_venv_platform()
}
}

View file

@ -14,7 +14,7 @@ use ninja_gen::python::PythonTest;
use ninja_gen::Build;
use crate::anki_version;
use crate::platform::overriden_python_target_platform;
use crate::platform::overriden_python_wheel_platform;
use crate::python::BuildWheel;
use crate::python::GenPythonProto;
@ -64,7 +64,7 @@ pub fn build_pylib(build: &mut Build) -> Result<()> {
BuildWheel {
name: "anki",
version: anki_version(),
platform: overriden_python_target_platform().or(Some(build.host_platform)),
platform: overriden_python_wheel_platform().or(Some(build.host_platform)),
deps: inputs![
":pylib:anki",
glob!("pylib/anki/**"),

View file

@ -113,8 +113,8 @@ impl BuildAction for BuildWheel {
// Calculate the wheel filename that uv will generate
let tag = if let Some(platform) = self.platform {
let platform_tag = match platform {
Platform::LinuxX64 => "manylinux_2_35_x86_64",
Platform::LinuxArm => "manylinux_2_35_aarch64",
Platform::LinuxX64 => "manylinux_2_36_x86_64",
Platform::LinuxArm => "manylinux_2_36_aarch64",
Platform::MacX64 => "macosx_12_0_x86_64",
Platform::MacArm => "macosx_12_0_arm64",
Platform::WindowsX64 => "win_amd64",

View file

@ -87,14 +87,27 @@ pub fn setup_uv(build: &mut Build, platform: Platform) -> Result<()> {
build.add_dependency("uv_binary", uv_binary);
// Our macOS packaging needs access to the x86 binary on ARM.
download_and_extract(
build,
"uv_mac_x86",
uv_archive(Platform::MacX64),
hashmap! { "bin" => [
with_exe("uv")
] },
)?;
if cfg!(target_arch = "aarch64") {
download_and_extract(
build,
"uv_mac_x86",
uv_archive(Platform::MacX64),
hashmap! { "bin" => [
with_exe("uv")
] },
)?;
}
// Our Linux packaging needs access to the ARM binary on x86
if cfg!(target_arch = "x86_64") {
download_and_extract(
build,
"uv_lin_arm",
uv_archive(Platform::LinuxArm),
hashmap! { "bin" => [
with_exe("uv")
] },
)?;
}
Ok(())
}

View file

@ -3905,6 +3905,15 @@
"license_file": null,
"description": "Derive Serialize and Deserialize that delegates to the underlying repr of a C-like enum."
},
{
"name": "serde_spanned",
"version": "0.6.9",
"authors": null,
"repository": "https://github.com/toml-rs/toml",
"license": "Apache-2.0 OR MIT",
"license_file": null,
"description": "Serde-compatible spanned Value"
},
{
"name": "serde_tuple",
"version": "0.5.0",
@ -4454,6 +4463,15 @@
"license_file": null,
"description": "Yet another format-preserving TOML parser."
},
{
"name": "toml_write",
"version": "0.1.2",
"authors": null,
"repository": "https://github.com/toml-rs/toml",
"license": "Apache-2.0 OR MIT",
"license_file": null,
"description": "A low-level interface for writing out TOML"
},
{
"name": "tower",
"version": "0.5.2",

2
ninja
View file

@ -8,7 +8,7 @@ else
out="$BUILD_ROOT"
fi
export CARGO_TARGET_DIR=$out/rust
export RECONFIGURE_KEY="${MAC_X86};${SOURCEMAP};${HMR}"
export RECONFIGURE_KEY="${MAC_X86};${LIN_ARM64};${SOURCEMAP};${HMR}"
if [ "$SKIP_RUNNER_BUILD" = "1" ]; then
echo "Runner not rebuilt."

View file

@ -177,7 +177,8 @@ class MPVBase:
startup.
"""
start = time.time()
while self.is_running() and time.time() < start + 10:
timeout = 60 if is_mac else 10
while self.is_running() and time.time() < start + timeout:
time.sleep(0.1)
if is_win:
# named pipe

View file

@ -1 +0,0 @@
release/pyproject.toml

View file

@ -1,7 +1,26 @@
[package]
name = "launcher"
version = "1.0.0"
edition = "2024"
authors.workspace = true
edition.workspace = true
license.workspace = true
publish = false
rust-version.workspace = true
[dependencies]
dirs = "5.0"
anki_io.workspace = true
anki_process.workspace = true
anyhow.workspace = true
dirs.workspace = true
[target.'cfg(windows)'.dependencies]
winapi.workspace = true
libc.workspace = true
libc-stdhandle.workspace = true
[[bin]]
name = "build_win"
path = "src/bin/build_win.rs"
[target.'cfg(windows)'.build-dependencies]
embed-resource.workspace = true

8
qt/launcher/build.rs Normal file
View file

@ -0,0 +1,8 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
fn main() {
#[cfg(windows)]
{
embed_resource::compile("win/anki-manifest.rc", embed_resource::NONE);
}
}

30
qt/launcher/lin/anki Normal file
View file

@ -0,0 +1,30 @@
#!/bin/bash
# Universal Anki launcher script
# Get the directory where this script is located
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# Determine architecture
ARCH=$(uname -m)
case "$ARCH" in
x86_64|amd64)
LAUNCHER="$SCRIPT_DIR/launcher.amd64"
;;
aarch64|arm64)
LAUNCHER="$SCRIPT_DIR/launcher.arm64"
;;
*)
echo "Error: Unsupported architecture: $ARCH"
echo "Supported architectures: x86_64, aarch64"
exit 1
;;
esac
# Check if launcher exists
if [ ! -f "$LAUNCHER" ]; then
echo "Error: Launcher not found: $LAUNCHER"
exit 1
fi
# Execute the appropriate launcher with all arguments
exec "$LAUNCHER" "$@"

66
qt/launcher/lin/build.sh Executable file
View file

@ -0,0 +1,66 @@
#!/bin/bash
set -e
# Add Linux cross-compilation target
rustup target add aarch64-unknown-linux-gnu
# Define output paths
OUTPUT_DIR="../../../out/launcher"
LAUNCHER_DIR="$OUTPUT_DIR/anki-launcher"
# Clean existing output directory
rm -rf "$LAUNCHER_DIR"
# Build binaries for both Linux architectures
cargo build -p launcher --release --target x86_64-unknown-linux-gnu
CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc \
cargo build -p launcher --release --target aarch64-unknown-linux-gnu
(cd ../../.. && ./ninja extract:uv_lin_arm)
# Create output directory
mkdir -p "$LAUNCHER_DIR"
# Copy binaries and support files
TARGET_DIR=${CARGO_TARGET_DIR:-../../../target}
# Copy launcher binaries with architecture suffixes
cp "$TARGET_DIR/x86_64-unknown-linux-gnu/release/launcher" "$LAUNCHER_DIR/launcher.amd64"
cp "$TARGET_DIR/aarch64-unknown-linux-gnu/release/launcher" "$LAUNCHER_DIR/launcher.arm64"
# Copy uv binaries with architecture suffixes
cp "../../../out/extracted/uv/uv" "$LAUNCHER_DIR/uv.amd64"
cp "../../../out/extracted/uv_lin_arm/uv" "$LAUNCHER_DIR/uv.arm64"
# Copy support files from lin directory
for file in README.md anki.1 anki.desktop anki.png anki.xml anki.xpm install.sh uninstall.sh anki; do
cp "$file" "$LAUNCHER_DIR/"
done
# Copy additional files from parent directory
cp ../pyproject.toml "$LAUNCHER_DIR/"
cp ../../../.python-version "$LAUNCHER_DIR/"
# Set executable permissions
chmod +x \
"$LAUNCHER_DIR/anki" \
"$LAUNCHER_DIR/launcher.amd64" \
"$LAUNCHER_DIR/launcher.arm64" \
"$LAUNCHER_DIR/uv.amd64" \
"$LAUNCHER_DIR/uv.arm64" \
"$LAUNCHER_DIR/install.sh" \
"$LAUNCHER_DIR/uninstall.sh"
# Set proper permissions and create tarball
chmod -R a+r "$LAUNCHER_DIR"
# Create tarball using the same options as the Rust template
ZSTD="zstd -c --long -T0 -18"
TRANSFORM="s%^.%anki-launcher%S"
TARBALL="$OUTPUT_DIR/anki-launcher.tar.zst"
tar -I "$ZSTD" --transform "$TRANSFORM" -cf "$TARBALL" -C "$LAUNCHER_DIR" .
echo "Build complete:"
echo "Universal launcher: $LAUNCHER_DIR"
echo "Tarball: $TARBALL"

View file

@ -13,7 +13,7 @@ fi
rm -rf "$PREFIX"/share/anki "$PREFIX"/bin/anki
mkdir -p "$PREFIX"/share/anki
cp -av --no-preserve=owner,context -- * "$PREFIX"/share/anki/
cp -av --no-preserve=owner,context -- * .python-version "$PREFIX"/share/anki/
mkdir -p "$PREFIX"/bin
ln -sf "$PREFIX"/share/anki/anki "$PREFIX"/bin/anki
# fix a previous packaging issue where we created this as a file

View file

@ -0,0 +1,81 @@
[project]
name = "anki-release"
version = "0.1.2"
description = "A package to lock Anki's dependencies"
requires-python = ">=3.9"
dependencies = [
"anki==0.1.2",
"aqt==0.1.2",
"anki-audio==0.1.0 ; sys_platform == 'darwin' or sys_platform == 'win32'",
"attrs==25.3.0",
"beautifulsoup4==4.12.3",
"blinker==1.9.0",
"certifi==2025.4.26",
"charset-normalizer==3.4.2",
"click==8.1.8 ; python_full_version < '3.10'",
"click==8.2.1 ; python_full_version >= '3.10'",
"colorama==0.4.6 ; sys_platform == 'win32'",
"decorator==5.2.1",
"distro==1.9.0 ; sys_platform != 'darwin' and sys_platform != 'win32'",
"flask==3.1.1",
"flask-cors==6.0.0",
"idna==3.10",
"importlib-metadata==8.7.0 ; python_full_version < '3.10'",
"itsdangerous==2.2.0",
"jinja2==3.1.6",
"jsonschema==4.24.0",
"jsonschema-specifications==2025.4.1",
"markdown==3.8",
"markupsafe==3.0.2",
"mock==5.2.0",
"orjson==3.10.18",
"pip-system-certs==4.0",
"protobuf==6.31.1",
"psutil==7.0.0 ; sys_platform == 'win32'",
"pyqt6==6.7.1",
"pyqt6-qt6==6.7.3",
"pyqt6-sip==13.10.2",
"pyqt6-webengine==6.7.0",
"pyqt6-webengine-qt6==6.7.3",
"pyqt6-webenginesubwheel-qt6==6.7.3",
"pysocks==1.7.1",
"pywin32==310 ; sys_platform == 'win32'",
"referencing==0.36.2",
"requests==2.32.3",
"rpds-py==0.25.1",
"send2trash==1.8.3",
"soupsieve==2.7",
"types-click==7.1.8",
"types-decorator==5.2.0.20250324",
"types-flask==1.1.6",
"types-flask-cors==6.0.0.20250520",
"types-jinja2==2.11.9",
"types-markdown==3.8.0.20250415",
"types-markupsafe==1.1.10",
"types-orjson==3.6.2",
"types-protobuf==6.30.2.20250516",
"types-pywin32==310.0.0.20250516",
"types-requests==2.32.0.20250602",
"types-waitress==3.0.1.20241117",
"types-werkzeug==1.0.9",
"typing-extensions==4.14.0",
"urllib3==2.4.0",
"waitress==3.0.2",
"werkzeug==3.1.3",
"wrapt==1.17.2",
"zipp==3.23.0 ; python_full_version < '3.10'",
]
[[tool.uv.index]]
name = "testpypi"
url = "https://test.pypi.org/simple/"
publish-url = "https://test.pypi.org/legacy/"
explicit = true
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
# hatch throws an error if nothing is included
[tool.hatch.build.targets.wheel]
include = ["no-such-file"]

View file

@ -0,0 +1,295 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
use std::env;
use std::path::Path;
use std::path::PathBuf;
use std::process::Command;
use anki_io::copy_file;
use anki_io::create_dir_all;
use anki_io::remove_dir_all;
use anki_io::write_file;
use anki_process::CommandExt;
use anyhow::Result;
const OUTPUT_DIR: &str = "../../../out/launcher";
const LAUNCHER_EXE_DIR: &str = "../../../out/launcher_exe";
const NSIS_DIR: &str = "../../../out/nsis";
const CARGO_TARGET_DIR: &str = "../../../out/rust";
const NSIS_PATH: &str = "C:\\Program Files (x86)\\NSIS\\makensis.exe";
fn main() -> Result<()> {
println!("Building Windows launcher...");
let output_dir = PathBuf::from(OUTPUT_DIR);
let launcher_exe_dir = PathBuf::from(LAUNCHER_EXE_DIR);
let nsis_dir = PathBuf::from(NSIS_DIR);
setup_directories(&output_dir, &launcher_exe_dir, &nsis_dir)?;
build_launcher_binary()?;
extract_nsis_plugins()?;
copy_files(&output_dir)?;
sign_binaries(&output_dir)?;
copy_nsis_files(&nsis_dir)?;
build_uninstaller(&output_dir, &nsis_dir)?;
sign_file(&output_dir.join("uninstall.exe"))?;
generate_install_manifest(&output_dir)?;
build_installer(&output_dir, &nsis_dir)?;
sign_file(&PathBuf::from("../../../out/launcher_exe/anki-install.exe"))?;
println!("Build completed successfully!");
println!("Output directory: {}", output_dir.display());
println!("Installer: ../../../out/launcher_exe/anki-install.exe");
Ok(())
}
fn setup_directories(output_dir: &Path, launcher_exe_dir: &Path, nsis_dir: &Path) -> Result<()> {
println!("Setting up directories...");
// Remove existing output directories
if output_dir.exists() {
remove_dir_all(output_dir)?;
}
if launcher_exe_dir.exists() {
remove_dir_all(launcher_exe_dir)?;
}
if nsis_dir.exists() {
remove_dir_all(nsis_dir)?;
}
// Create output directories
create_dir_all(output_dir)?;
create_dir_all(launcher_exe_dir)?;
create_dir_all(nsis_dir)?;
Ok(())
}
fn build_launcher_binary() -> Result<()> {
println!("Building launcher binary...");
env::set_var("CARGO_TARGET_DIR", CARGO_TARGET_DIR);
Command::new("cargo")
.args([
"build",
"-p",
"launcher",
"--release",
"--target",
"x86_64-pc-windows-msvc",
])
.ensure_success()?;
Ok(())
}
fn extract_nsis_plugins() -> Result<()> {
println!("Extracting NSIS plugins...");
// Change to the anki root directory and run tools/ninja.bat
Command::new("cmd")
.args([
"/c",
"cd",
"/d",
"..\\..\\..\\",
"&&",
"tools\\ninja.bat",
"extract:nsis_plugins",
])
.ensure_success()?;
Ok(())
}
fn copy_files(output_dir: &Path) -> Result<()> {
println!("Copying binaries...");
// Copy launcher binary as anki.exe
let launcher_src =
PathBuf::from(CARGO_TARGET_DIR).join("x86_64-pc-windows-msvc/release/launcher.exe");
let launcher_dst = output_dir.join("anki.exe");
copy_file(&launcher_src, &launcher_dst)?;
// Copy uv.exe
let uv_src = PathBuf::from("../../../out/extracted/uv/uv.exe");
let uv_dst = output_dir.join("uv.exe");
copy_file(&uv_src, &uv_dst)?;
println!("Copying support files...");
// Copy pyproject.toml
copy_file("../pyproject.toml", output_dir.join("pyproject.toml"))?;
// Copy .python-version
copy_file(
"../../../.python-version",
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("uv.exe"))?;
Ok(())
}
fn sign_file(file_path: &Path) -> Result<()> {
let codesign = env::var("CODESIGN").unwrap_or_default();
if codesign != "1" {
println!(
"Skipping code signing for {} (CODESIGN not set to 1)",
file_path.display()
);
return Ok(());
}
let signtool_path = find_signtool()?;
println!("Signing {}...", file_path.display());
Command::new(&signtool_path)
.args([
"sign",
"/sha1",
"dccfc6d312fc0432197bb7be951478e5866eebf8",
"/fd",
"sha256",
"/tr",
"http://time.certum.pl",
"/td",
"sha256",
"/v",
])
.arg(file_path)
.ensure_success()?;
Ok(())
}
fn find_signtool() -> Result<PathBuf> {
println!("Locating signtool.exe...");
let output = Command::new("where")
.args([
"/r",
"C:\\Program Files (x86)\\Windows Kits",
"signtool.exe",
])
.utf8_output()?;
// Find signtool.exe with "arm64" in the path (as per original batch logic)
for line in output.stdout.lines() {
if line.contains("\\arm64\\") {
let signtool_path = PathBuf::from(line.trim());
println!("Using signtool: {}", signtool_path.display());
return Ok(signtool_path);
}
}
anyhow::bail!("Could not find signtool.exe with arm64 architecture");
}
fn generate_install_manifest(output_dir: &Path) -> Result<()> {
println!("Generating install manifest...");
let mut manifest_content = String::new();
let entries = anki_io::read_dir_files(output_dir)?;
for entry in entries {
let entry = entry?;
let path = entry.path();
if let Some(file_name) = path.file_name() {
let file_name_str = file_name.to_string_lossy();
// Skip manifest file and uninstaller (can't delete itself)
if file_name_str != "anki.install-manifest" && file_name_str != "uninstall.exe" {
if let Ok(relative_path) = path.strip_prefix(output_dir) {
// Convert to Windows-style backslashes for NSIS
let windows_path = relative_path.display().to_string().replace('/', "\\");
// Use Windows line endings (\r\n) as expected by NSIS
manifest_content.push_str(&format!("{}\r\n", windows_path));
}
}
}
}
write_file(output_dir.join("anki.install-manifest"), manifest_content)?;
Ok(())
}
fn copy_nsis_files(nsis_dir: &Path) -> Result<()> {
println!("Copying NSIS support files...");
// Copy anki.template.nsi as anki.nsi
copy_file("anki.template.nsi", nsis_dir.join("anki.nsi"))?;
// Copy fileassoc.nsh
copy_file("fileassoc.nsh", nsis_dir.join("fileassoc.nsh"))?;
// Copy nsProcess.dll
copy_file(
"../../../out/extracted/nsis_plugins/nsProcess.dll",
nsis_dir.join("nsProcess.dll"),
)?;
Ok(())
}
fn build_uninstaller(output_dir: &Path, nsis_dir: &Path) -> Result<()> {
println!("Building uninstaller...");
let mut flags = vec!["-V3", "-DWRITE_UNINSTALLER"];
if env::var("NO_COMPRESS").unwrap_or_default() == "1" {
println!("NO_COMPRESS=1 detected, disabling compression");
flags.push("-DNO_COMPRESS");
}
run_nsis(
&PathBuf::from("anki.nsi"),
&flags,
nsis_dir, // Run from nsis directory
)?;
// Copy uninstaller from nsis directory to output directory
copy_file(
nsis_dir.join("uninstall.exe"),
output_dir.join("uninstall.exe"),
)?;
Ok(())
}
fn build_installer(_output_dir: &Path, nsis_dir: &Path) -> Result<()> {
println!("Building installer...");
let mut flags = vec!["-V3"];
if env::var("NO_COMPRESS").unwrap_or_default() == "1" {
println!("NO_COMPRESS=1 detected, disabling compression");
flags.push("-DNO_COMPRESS");
}
run_nsis(
&PathBuf::from("anki.nsi"),
&flags,
nsis_dir, // Run from nsis directory
)?;
Ok(())
}
fn run_nsis(script_path: &Path, flags: &[&str], working_dir: &Path) -> Result<()> {
let mut cmd = Command::new(NSIS_PATH);
cmd.args(flags).arg(script_path).current_dir(working_dir);
cmd.ensure_success()?;
Ok(())
}

View file

@ -1,114 +1,117 @@
use std::os::unix::process::CommandExt;
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
#![windows_subsystem = "windows"]
use std::io::stdin;
use std::process::Command;
use std::process::Stdio;
use anki_io::copy_file;
use anki_io::create_dir_all;
use anki_io::metadata;
use anki_io::remove_file;
use anki_io::write_file;
use anki_process::CommandExt;
use anyhow::Context;
use anyhow::Result;
use crate::platform::exec_anki;
use crate::platform::get_anki_binary_path;
use crate::platform::get_exe_and_resources_dirs;
use crate::platform::get_uv_binary_name;
use crate::platform::handle_first_launch;
use crate::platform::handle_terminal_launch;
use crate::platform::initial_terminal_setup;
use crate::platform::launch_anki_detached;
mod platform;
#[derive(Debug, Clone, Default)]
pub struct Config {
pub show_console: bool,
}
fn main() {
let Some(uv_install_root) =
dirs::data_local_dir().map(|data_dir| data_dir.join("AnkiProgramFiles"))
else {
println!("Unable to determine data_dir");
if let Err(e) = run() {
eprintln!("Error: {:#}", e);
eprintln!("Press enter to close...");
let mut input = String::new();
let _ = stdin().read_line(&mut input);
std::process::exit(1);
};
}
}
fn run() -> Result<()> {
let mut config = Config::default();
initial_terminal_setup(&mut config);
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 exe_dir = std::env::current_exe()
.unwrap()
.parent()
.unwrap()
.to_owned();
let resources_dir = exe_dir.parent().unwrap().join("Resources");
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 pyproject_has_changed =
!user_pyproject_path.exists() || !sync_complete_marker.exists() || {
let pyproject_toml_time = std::fs::metadata(&user_pyproject_path)
.unwrap()
let pyproject_toml_time = metadata(&user_pyproject_path)?
.modified()
.unwrap();
let sync_complete_time = std::fs::metadata(&sync_complete_marker)
.unwrap()
.context("Failed to get pyproject.toml modified time")?;
let sync_complete_time = metadata(&sync_complete_marker)?
.modified()
.unwrap();
pyproject_toml_time > sync_complete_time
};
.context("Failed to get sync marker modified time")?;
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
let anki_bin = get_anki_binary_path(&uv_install_root);
exec_anki(&anki_bin, &config)?;
return Ok(());
}
// we'll need to launch uv; reinvoke ourselves in a terminal so the user can see
if pyproject_has_changed {
let stdout_is_terminal = std::io::IsTerminal::is_terminal(&std::io::stdout());
if stdout_is_terminal {
print!("\x1B[2J\x1B[H"); // Clear screen and move cursor to top
println!("\x1B[1mPreparing to start Anki...\x1B[0m\n");
} else {
// If launched from GUI, relaunch in Terminal.app
let current_exe = std::env::current_exe().unwrap();
Command::new("open")
.args(["-a", "Terminal"])
.arg(current_exe)
.spawn()
.unwrap();
std::process::exit(0);
}
handle_terminal_launch()?;
// Create install directory and copy project files in
create_dir_all(&uv_install_root)?;
if !user_pyproject_path.exists() {
copy_file(&dist_pyproject_path, &user_pyproject_path)?;
copy_file(&dist_python_version_path, &user_python_version_path)?;
}
if pyproject_has_changed {
let uv_path: std::path::PathBuf = exe_dir.join("uv");
// Remove sync marker before attempting sync
let _ = remove_file(&sync_complete_marker);
// Create install directory and copy pyproject.toml in if missing
std::fs::create_dir_all(&uv_install_root).unwrap();
if !user_pyproject_path.exists() {
std::fs::copy(&dist_pyproject_path, &user_pyproject_path).unwrap();
std::fs::copy(&dist_python_version_path, &user_python_version_path).unwrap();
}
// Remove sync marker before attempting sync
let _ = std::fs::remove_file(&sync_complete_marker);
// Sync the venv
let sync_result = Command::new(&uv_path)
.current_dir(&uv_install_root)
.args(["sync"])
.status()
.unwrap();
if !sync_result.success() {
println!("uv sync failed");
println!("Press enter to close");
let mut input = String::new();
let _ = std::io::stdin().read_line(&mut input);
std::process::exit(1);
}
// Write marker file to indicate successful sync
std::fs::write(&sync_complete_marker, "").unwrap();
// Sync the venv
if let Err(e) = Command::new(&uv_path)
.current_dir(&uv_install_root)
.args(["sync", "--refresh"])
.ensure_success()
{
// If sync fails due to things like a missing wheel on pypi,
// we need to remove the lockfile or uv will cache the bad result.
let _ = remove_file(&uv_lock_path);
return Err(e.into());
}
// invoke anki from the synced venv
if pyproject_has_changed {
// Pre-validate by running --version to trigger any Gatekeeper checks
let anki_bin = uv_install_root.join(".venv/bin/anki");
println!("\n\x1B[1mThis may take a few minutes. Please wait...\x1B[0m");
let _ = Command::new(&anki_bin)
.env("ANKI_FIRST_RUN", "1")
.arg("--version")
.status();
// Write marker file to indicate successful sync
write_file(&sync_complete_marker, "")?;
// Then launch the binary as detached subprocess so the terminal can close
let child = Command::new(&anki_bin)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.process_group(0)
.spawn()
.unwrap();
std::mem::forget(child);
} else {
// If venv already existed, exec as normal
println!(
"Anki return code: {:?}",
Command::new(uv_install_root.join(".venv/bin/anki")).exec()
);
}
// First launch
let anki_bin = get_anki_binary_path(&uv_install_root);
handle_first_launch(&anki_bin)?;
// Then launch the binary as detached subprocess so the terminal can close
launch_anki_detached(&anki_bin, &config)?;
Ok(())
}

View file

@ -0,0 +1,80 @@
// 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::process::Command;
use anki_process::CommandExt as AnkiCommandExt;
use anyhow::Context;
use anyhow::Result;
// Re-export Unix functions that macOS uses
pub use super::unix::{
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);
Ok(())
}
pub fn handle_terminal_launch() -> Result<()> {
let stdout_is_terminal = std::io::IsTerminal::is_terminal(&std::io::stdout());
if stdout_is_terminal {
print!("\x1B[2J\x1B[H"); // Clear screen and move cursor to top
println!("\x1B[1mPreparing to start Anki...\x1B[0m\n");
} else {
// If launched from GUI, relaunch in Terminal.app
relaunch_in_terminal()?;
}
Ok(())
}
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<()> {
// Pre-validate by running --version to trigger any Gatekeeper checks
println!("\n\x1B[1mThis may take a few minutes. Please wait...\x1B[0m");
let _ = Command::new(anki_bin)
.env("ANKI_FIRST_RUN", "1")
.arg("--version")
.ensure_success();
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"
}

View file

@ -0,0 +1,18 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
#[cfg(unix)]
mod unix;
#[cfg(target_os = "macos")]
mod mac;
#[cfg(target_os = "windows")]
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::*;

View file

@ -0,0 +1,74 @@
// 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::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 handle_terminal_launch() -> Result<()> {
print!("\x1B[2J\x1B[H"); // Clear screen and move cursor to top
println!("\x1B[1mPreparing to start Anki...\x1B[0m\n");
// Skip terminal relaunch on non-macOS Unix systems as we don't know which
// terminal is installed
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

@ -0,0 +1,118 @@
// 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 crate::Config;
pub fn handle_terminal_launch() -> Result<()> {
// uv will do this itself
Ok(())
}
/// If parent process has a console (eg cmd.exe), redirect our output there.
/// Sets config.show_console to true if successfully attached to console.
pub fn initial_terminal_setup(config: &mut Config) {
use std::ffi::CString;
use libc_stdhandle::*;
use winapi::um::wincon;
let console_attached = unsafe { wincon::AttachConsole(wincon::ATTACH_PARENT_PROCESS) };
if console_attached == 0 {
return;
}
let conin = CString::new("CONIN$").unwrap();
let conout = CString::new("CONOUT$").unwrap();
let r = CString::new("r").unwrap();
let w = CString::new("w").unwrap();
// Python uses the CRT for I/O, and it requires the descriptors are reopened.
unsafe {
libc::freopen(conin.as_ptr(), r.as_ptr(), stdin());
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 {
// Windows uses standard uv binary name
"uv.exe"
}

View file

@ -1,9 +1,25 @@
<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">
<assemblyIdentity type="win32" name="Anki" version="1.0.0.0" />
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v2">
<security>
<requestedPrivileges xmlns="urn:schemas-microsoft-com:asm.v3">
<requestedExecutionLevel level="asInvoker" uiAccess="false" />
</requestedPrivileges>
</security>
</trustInfo>
<application xmlns="urn:schemas-microsoft-com:asm.v3">
<windowsSettings>
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true</dpiAware>
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness>
<longPathAware xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">true</longPathAware>
<activeCodePage xmlns="http://schemas.microsoft.com/SMI/2019/WindowsSettings">UTF-8</activeCodePage>
</windowsSettings>
</application>
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application>
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
<supportedOS Id="{1f676c76-80e1-4239-95bb-83d0f6d0da78}" />
</application>
</compatibility>
</assembly>

View file

@ -23,8 +23,8 @@ Name "Anki"
Unicode true
; The file to write (make relative to repo root instead of out/bundle)
OutFile "..\..\@@INSTALLER@@"
; The file to write (relative to nsis directory)
OutFile "..\launcher_exe\anki-install.exe"
; Non elevated
RequestExecutionLevel user
@ -62,7 +62,7 @@ Function .onInit
FunctionEnd
!ifdef WRITE_UNINSTALLER
!uninstfinalize 'copy "%1" "std\uninstall.exe"'
!uninstfinalize 'copy "%1" "uninstall.exe"'
!endif
;--------------------------------
@ -191,7 +191,7 @@ Section ""
; Add files to installer
!ifndef WRITE_UNINSTALLER
File /r ..\..\@@SRC@@\*.*
File /r ..\launcher\*.*
!endif
!insertmacro APP_ASSOCIATE_HKCU "apkg" "anki.apkg" \
@ -213,8 +213,8 @@ Section ""
WriteRegStr HKCU Software\Anki "Install_Dir64" "$INSTDIR"
; Write the uninstall keys for Windows
WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\Anki" "DisplayName" "Anki"
WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\Anki" "DisplayVersion" "@@VERSION@@"
WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\Anki" "DisplayName" "Anki Launcher"
WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\Anki" "DisplayVersion" "1.0.0"
WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\Anki" "UninstallString" '"$INSTDIR\uninstall.exe"'
WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\Anki" "QuietUninstallString" '"$INSTDIR\uninstall.exe" /S'
WriteRegDWORD HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\Anki" "NoModify" 1
@ -252,9 +252,15 @@ Section "Uninstall"
!insertmacro APP_UNASSOCIATE_HKCU "ankiaddon" "anki.ankiaddon"
!insertmacro UPDATEFILEASSOC
; Schedule uninstaller for deletion on reboot
Delete /REBOOTOK "$INSTDIR\uninstall.exe"
; try to remove top level folder if empty
RMDir "$INSTDIR"
; Remove AnkiProgramData folder created during runtime
RMDir /r "$LOCALAPPDATA\AnkiProgramFiles"
; Remove registry keys
DeleteRegKey HKCU "Software\Microsoft\Windows\CurrentVersion\Uninstall\Anki"
DeleteRegKey HKCU Software\Anki

View file

@ -0,0 +1,5 @@
@echo off
set CODESIGN=1
REM set NO_COMPRESS=1
cargo run --bin build_win

View file

@ -37,14 +37,14 @@ qt66 = [
"pyqt6-webengine-qt6==6.6.2",
"pyqt6_sip==13.6.0",
]
qt = [
qt67 = [
"pyqt6==6.7.1",
"pyqt6-qt6==6.7.3",
"pyqt6-webengine==6.7.0",
"pyqt6-webengine-qt6==6.7.3",
"pyqt6_sip==13.10.2",
]
qt68 = [
qt = [
"pyqt6==6.8.0",
"pyqt6-qt6==6.8.1",
"pyqt6-webengine==6.8.0",
@ -57,7 +57,7 @@ conflicts = [
[
{ extra = "qt" },
{ extra = "qt66" },
{ extra = "qt68" },
{ extra = "qt67" },
],
]

View file

@ -2,13 +2,12 @@
export UV_PUBLISH_TOKEN=$(pass show w/pypi-api-test)
# Get the project root (two levels up from qt/bundle)
# Get the project root (two levels up from qt/release)
PROJ_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
# Use extracted uv binary
UV="$PROJ_ROOT/out/extracted/uv/uv"
cd release
rm -rf dist
"$UV" build --wheel
"$UV" publish --index testpypi

80
qt/release/pyproject.toml Normal file
View file

@ -0,0 +1,80 @@
[project]
name = "anki-release"
version = "0.1.3"
description = "A package to lock Anki's dependencies"
requires-python = ">=3.9"
dependencies = [
"anki==0.1.2",
"aqt==0.1.2",
"anki-audio==0.1.0 ; sys_platform == 'darwin' or sys_platform == 'win32'",
"attrs==25.3.0",
"beautifulsoup4==4.12.3",
"blinker==1.9.0",
"certifi==2025.4.26",
"charset-normalizer==3.4.2",
"click==8.1.8 ; python_full_version < '3.10'",
"click==8.2.1 ; python_full_version >= '3.10'",
"colorama==0.4.6 ; sys_platform == 'win32'",
"decorator==5.2.1",
"distro==1.9.0 ; sys_platform != 'darwin' and sys_platform != 'win32'",
"flask==3.1.1",
"flask-cors==6.0.0",
"idna==3.10",
"importlib-metadata==8.7.0 ; python_full_version < '3.10'",
"itsdangerous==2.2.0",
"jinja2==3.1.6",
"jsonschema==4.24.0",
"jsonschema-specifications==2025.4.1",
"markdown==3.8",
"markupsafe==3.0.2",
"mock==5.2.0",
"orjson==3.10.18",
"pip-system-certs==4.0",
"protobuf==6.31.1",
"psutil==7.0.0 ; sys_platform == 'win32'",
"pyqt6==6.8.0",
"pyqt6-qt6==6.8.1",
"pyqt6-sip==13.10.2",
"pyqt6-webengine==6.8.0",
"pyqt6-webengine-qt6==6.8.1",
"pysocks==1.7.1",
"pywin32==310 ; sys_platform == 'win32'",
"referencing==0.36.2",
"requests==2.32.3",
"rpds-py==0.25.1",
"send2trash==1.8.3",
"soupsieve==2.7",
"types-click==7.1.8",
"types-decorator==5.2.0.20250324",
"types-flask==1.1.6",
"types-flask-cors==6.0.0.20250520",
"types-jinja2==2.11.9",
"types-markdown==3.8.0.20250415",
"types-markupsafe==1.1.10",
"types-orjson==3.6.2",
"types-protobuf==6.30.2.20250516",
"types-pywin32==310.0.0.20250516",
"types-requests==2.32.0.20250602",
"types-waitress==3.0.1.20241117",
"types-werkzeug==1.0.9",
"typing-extensions==4.14.0",
"urllib3==2.4.0",
"waitress==3.0.2",
"werkzeug==3.1.3",
"wrapt==1.17.2",
"zipp==3.23.0 ; python_full_version < '3.10'",
]
[[tool.uv.index]]
name = "testpypi"
url = "https://test.pypi.org/simple/"
publish-url = "https://test.pypi.org/legacy/"
explicit = true
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
# hatch throws an error if nothing is included
[tool.hatch.build.targets.wheel]
include = ["no-such-file"]

View file

@ -1,7 +1,13 @@
#!/bin/bash
set -e
# Get the project root (two levels up from qt/bundle)
test -f update-release.sh || {
echo "run from release folder"
exit 1
}
# Get the project root (two levels up from qt/release)
PROJ_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
# Use extracted uv binary
@ -10,15 +16,13 @@ UV="$PROJ_ROOT/out/extracted/uv/uv"
# Prompt for wheel version
read -p "Wheel version: " VERSION
# Create release directory if it doesn't exist
mkdir -p release
# Export dependencies using uv
echo "Exporting dependencies..."
rm -f pyproject.toml
DEPS=$("$UV" export --no-hashes --no-annotate --no-header --extra audio --extra qt --all-packages --no-dev --no-emit-workspace)
# Generate the pyproject.toml file
cat > release/pyproject.toml << EOF
cat > pyproject.toml << EOF
[project]
name = "anki-release"
version = "$VERSION"
@ -32,12 +36,12 @@ EOF
# Add the exported dependencies to the file
echo "$DEPS" | while IFS= read -r line; do
if [[ -n "$line" ]]; then
echo " \"$line\"," >> release/pyproject.toml
echo " \"$line\"," >> pyproject.toml
fi
done
# Complete the pyproject.toml file
cat >> release/pyproject.toml << 'EOF'
cat >> pyproject.toml << 'EOF'
]
[[tool.uv.index]]
@ -55,4 +59,4 @@ build-backend = "hatchling.build"
include = ["no-such-file"]
EOF
echo "Generated release/pyproject.toml with version $VERSION"
echo "Generated pyproject.toml with version $VERSION"

View file

@ -57,6 +57,9 @@ pub trait CommandExt {
fn ensure_success(&mut self) -> Result<&mut Self>;
fn utf8_output(&mut self) -> Result<Utf8Output>;
fn ensure_spawn(&mut self) -> Result<std::process::Child>;
#[cfg(unix)]
fn ensure_exec(&mut self) -> Result<()>;
}
impl CommandExt for Command {
@ -94,6 +97,23 @@ impl CommandExt for Command {
})?,
})
}
fn ensure_spawn(&mut self) -> Result<std::process::Child> {
self.spawn().with_context(|_| DidNotExecuteSnafu {
cmdline: get_cmdline(self),
})
}
#[cfg(unix)]
fn ensure_exec(&mut self) -> Result<()> {
use std::os::unix::process::CommandExt as UnixCommandExt;
let cmdline = get_cmdline(self);
let error = self.exec();
Err(Error::DidNotExecute {
cmdline,
source: error,
})
}
}
fn get_cmdline(arg: &mut Command) -> String {

12
tools/build-arm-lin Executable file
View file

@ -0,0 +1,12 @@
#!/bin/bash
set -e
# sudo apt install libc6-dev-arm64-cross gcc-aarch64-linux-gnu
rustup target add aarch64-unknown-linux-gnu
export CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc
export LIN_ARM64=1
RELEASE=2 ./ninja wheels:anki
echo "wheels are in out/wheels"

10
tools/build-x64-mac Executable file
View file

@ -0,0 +1,10 @@
#!/bin/bash
set -e
rustup target add x86_64-apple-darwin
export MAC_X86=1
RELEASE=2 ./ninja wheels:anki
echo "wheels are in out/wheels"

View file

@ -1,5 +1,7 @@
@echo off
pushd "%~dp0"\..
set RELEASE=1
if exist out\wheels rmdir /s /q out\wheels
set RELEASE=2
tools\ninja wheels || exit /b 1
echo wheels are in out/wheels
popd

View file

@ -1,17 +0,0 @@
#!/bin/bash
#
# Run a command with an alternative buildroot and Intel architecture target, for building Intel on an ARM Mac.
# Eg ./tools/mac-x86 ./tools/run-qt5.14
#
# Uses hard-coded paths to Python and build folders.
#
export BUILD_ROOT=~/Local/build/anki-x86
export NORMAL_BUILD_ROOT=~/Local/build/anki
export MAC_X86=1
# run provided command
$*
BUILD_ROOT=$NORMAL_BUILD_ROOT ./ninja just-to-restore-build-root-and-failure-is-expected

View file

@ -2,8 +2,6 @@
set -e
./tools/build
export UV_PUBLISH_TOKEN=$(pass show w/pypi-api-test)
out/extracted/uv/uv publish --index testpypi out/wheels/*

1894
uv.lock

File diff suppressed because it is too large Load diff