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