diff --git a/Cargo.lock b/Cargo.lock
index 804b1a3b4..83b636dbc 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -3687,6 +3687,13 @@ dependencies = [
"libc",
]
+[[package]]
+name = "launcher"
+version = "1.0.0"
+dependencies = [
+ "dirs 5.0.1",
+]
+
[[package]]
name = "lazy_static"
version = "1.5.0"
@@ -4918,9 +4925,9 @@ checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
[[package]]
name = "plist"
-version = "1.7.1"
+version = "1.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "eac26e981c03a6e53e0aee43c113e3202f5581d5360dae7bd2c70e800dd0451d"
+checksum = "3d77244ce2d584cd84f6a15f86195b8c9b2a0dfbfd817c09e0464244091a58ed"
dependencies = [
"base64 0.22.1",
"indexmap",
@@ -5249,9 +5256,9 @@ dependencies = [
[[package]]
name = "quick-xml"
-version = "0.32.0"
+version = "0.37.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1d3a6e5838b60e0e8fa7a43f22ade549a37d61f8bdbe636d0d7816191de969c2"
+checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb"
dependencies = [
"memchr",
]
diff --git a/Cargo.toml b/Cargo.toml
index db0b9f79f..483e165f6 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -12,6 +12,7 @@ members = [
"build/runner",
"ftl",
"pylib/rsbridge",
+ "qt/bundle/launcher",
"qt/bundle/mac",
"qt/bundle/win",
"rslib",
@@ -23,7 +24,6 @@ members = [
"rslib/sync",
"tools/minilints",
]
-exclude = ["qt/bundle"]
resolver = "2"
[workspace.dependencies.percent-encoding-iri]
diff --git a/qt/bundle/build-mac-launcher.sh b/qt/bundle/build-mac-launcher.sh
new file mode 100755
index 000000000..d5ef9dcd4
--- /dev/null
+++ b/qt/bundle/build-mac-launcher.sh
@@ -0,0 +1,44 @@
+#!/bin/bash
+
+set -e
+
+# Define output path
+OUTPUT_DIR="../../out/bundle"
+APP_BUNDLE="$OUTPUT_DIR/Anki.app"
+
+# Build rust binary in debug mode
+cargo build -p launcher
+(cd ../.. && ./ninja bundle:uv_universal)
+
+# Ensure output directory exists
+mkdir -p "$OUTPUT_DIR"
+
+# Remove existing app bundle
+rm -rf "$APP_BUNDLE"
+
+# Create app bundle structure
+mkdir -p "$APP_BUNDLE/Contents/MacOS" "$APP_BUNDLE/Contents/Resources"
+
+# Copy binaries
+TARGET_DIR=${CARGO_TARGET_DIR:-target}
+cp $TARGET_DIR/debug/launcher "$APP_BUNDLE/Contents/MacOS/"
+cp "$OUTPUT_DIR/uv" "$APP_BUNDLE/Contents/MacOS/"
+
+# Copy support files
+cp launcher/Info.plist "$APP_BUNDLE/Contents/"
+cp launcher/pyproject.toml "$APP_BUNDLE/Contents/Resources/"
+
+# Codesign
+for i in "$APP_BUNDLE/Contents/MacOS/uv" "$APP_BUNDLE/Contents/MacOS/launcher" "$APP_BUNDLE"; do
+ codesign --force -vvvv -o runtime -s "Developer ID Application:" \
+ --entitlements $c/desktop/anki/qt/bundle/mac/entitlements.python.xml \
+ "$i"
+done
+
+# Check
+codesign -vvv "$APP_BUNDLE"
+spctl -a "$APP_BUNDLE"
+
+# Mark as quarantined
+#xattr -w com.apple.quarantine "0181;$(date +%s);Safari;" "$APP_BUNDLE"
+
diff --git a/qt/bundle/launcher/Cargo.toml b/qt/bundle/launcher/Cargo.toml
new file mode 100644
index 000000000..d92fc9d5c
--- /dev/null
+++ b/qt/bundle/launcher/Cargo.toml
@@ -0,0 +1,7 @@
+[package]
+name = "launcher"
+version = "1.0.0"
+edition = "2024"
+
+[dependencies]
+dirs = "5.0"
diff --git a/qt/bundle/launcher/Info.plist b/qt/bundle/launcher/Info.plist
new file mode 100644
index 000000000..59b67605f
--- /dev/null
+++ b/qt/bundle/launcher/Info.plist
@@ -0,0 +1,51 @@
+
+
+
+
+ CFBundleDisplayName
+ Anki
+ CFBundleShortVersionString
+ 1.0
+ LSMinimumSystemVersion
+ 11
+ CFBundleDocumentTypes
+
+
+ CFBundleTypeExtensions
+
+ colpkg
+ apkg
+ ankiaddon
+
+ CFBundleTypeIconName
+ AppIcon
+ CFBundleTypeName
+ Anki File
+ CFBundleTypeRole
+ Editor
+
+
+ CFBundleExecutable
+ launcher
+ CFBundleIconName
+ AppIcon
+ CFBundleIdentifier
+ net.ankiweb.launcher
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleName
+ Anki
+ CFBundlePackageType
+ APPL
+ NSHighResolutionCapable
+
+ NSMicrophoneUsageDescription
+ The microphone will only be used when you tap the record button.
+ NSCameraUsageDescription
+ Add-ons may access your camera.
+ NSRequiresAquaSystemAppearance
+
+ NSSupportsAutomaticGraphicsSwitching
+
+
+
diff --git a/qt/bundle/launcher/pyproject.toml b/qt/bundle/launcher/pyproject.toml
new file mode 100644
index 000000000..b7ca25961
--- /dev/null
+++ b/qt/bundle/launcher/pyproject.toml
@@ -0,0 +1,17 @@
+[project]
+name = "anki-launcher"
+version = "0.1.0"
+description = "UV-based launcher for Anki."
+requires-python = ">=3.9"
+dependencies = [
+ "anki-release",
+]
+
+[tool.uv.sources]
+anki-release = { index = "testpypi" }
+
+[[tool.uv.index]]
+name = "testpypi"
+url = "https://test.pypi.org/simple/"
+publish-url = "https://test.pypi.org/legacy/"
+explicit = true
diff --git a/qt/bundle/launcher/src/main.rs b/qt/bundle/launcher/src/main.rs
new file mode 100644
index 000000000..470eed6a7
--- /dev/null
+++ b/qt/bundle/launcher/src/main.rs
@@ -0,0 +1,109 @@
+use std::os::unix::process::CommandExt;
+use std::process::Command;
+use std::process::Stdio;
+
+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");
+ std::process::exit(1);
+ };
+
+ 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 dist_pyproject_path = resources_dir.join("pyproject.toml");
+ let user_pyproject_path = uv_install_root.join("pyproject.toml");
+
+ let pyproject_has_changed =
+ !user_pyproject_path.exists() || !sync_complete_marker.exists() || {
+ let pyproject_toml_time = std::fs::metadata(&user_pyproject_path)
+ .unwrap()
+ .modified()
+ .unwrap();
+ let sync_complete_time = std::fs::metadata(&sync_complete_marker)
+ .unwrap()
+ .modified()
+ .unwrap();
+ pyproject_toml_time > sync_complete_time
+ };
+
+ // 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);
+ }
+ }
+
+ if pyproject_has_changed {
+ let uv_path: std::path::PathBuf = exe_dir.join("uv");
+
+ // 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();
+ }
+
+ // 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();
+ }
+
+ // 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).arg("--version").output();
+
+ // 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);
+ println!("Anki launched successfully");
+ } else {
+ // If venv already existed, exec as normal
+ println!(
+ "Anki return code: {:?}",
+ Command::new(uv_install_root.join(".venv/bin/anki")).exec()
+ );
+ }
+}
diff --git a/qt/bundle/mac/src/Info.plist b/qt/bundle/mac/src/Info.plist
index 5933838e4..e69de29bb 100644
--- a/qt/bundle/mac/src/Info.plist
+++ b/qt/bundle/mac/src/Info.plist
@@ -1,51 +0,0 @@
-
-
-
-
- CFBundleDisplayName
- Anki
- CFBundleShortVersionString
- 2.1.46
- LSMinimumSystemVersion
- 10.14.0
- CFBundleDocumentTypes
-
-
- CFBundleTypeExtensions
-
- colpkg
- apkg
- ankiaddon
-
- CFBundleTypeIconName
- AppIcon
- CFBundleTypeName
- Anki File
- CFBundleTypeRole
- Editor
-
-
- CFBundleExecutable
- anki
- CFBundleIconName
- AppIcon
- CFBundleIdentifier
- net.ankiweb.dtop
- CFBundleInfoDictionaryVersion
- 6.0
- CFBundleName
- Anki
- CFBundlePackageType
- APPL
- NSHighResolutionCapable
-
- NSMicrophoneUsageDescription
- The microphone will only be used when you tap the record button.
- NSCameraUsageDescription
- Add-ons may access your camera.
- NSRequiresAquaSystemAppearance
-
- NSSupportsAutomaticGraphicsSwitching
-
-
-