Initial macOS launcher prototype

This commit is contained in:
Damien Elmes 2025-06-14 15:12:42 +07:00
parent b8a22f3078
commit 3d69083f67
8 changed files with 240 additions and 56 deletions

15
Cargo.lock generated
View file

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

View file

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

44
qt/bundle/build-mac-launcher.sh Executable file
View file

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

View file

@ -0,0 +1,7 @@
[package]
name = "launcher"
version = "1.0.0"
edition = "2024"
[dependencies]
dirs = "5.0"

View file

@ -0,0 +1,51 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDisplayName</key>
<string>Anki</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>LSMinimumSystemVersion</key>
<string>11</string>
<key>CFBundleDocumentTypes</key>
<array>
<dict>
<key>CFBundleTypeExtensions</key>
<array>
<string>colpkg</string>
<string>apkg</string>
<string>ankiaddon</string>
</array>
<key>CFBundleTypeIconName</key>
<string>AppIcon</string>
<key>CFBundleTypeName</key>
<string>Anki File</string>
<key>CFBundleTypeRole</key>
<string>Editor</string>
</dict>
</array>
<key>CFBundleExecutable</key>
<string>launcher</string>
<key>CFBundleIconName</key>
<string>AppIcon</string>
<key>CFBundleIdentifier</key>
<string>net.ankiweb.launcher</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>Anki</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>NSHighResolutionCapable</key>
<true/>
<key>NSMicrophoneUsageDescription</key>
<string>The microphone will only be used when you tap the record button.</string>
<key>NSCameraUsageDescription</key>
<string>Add-ons may access your camera.</string>
<key>NSRequiresAquaSystemAppearance</key>
<true/>
<key>NSSupportsAutomaticGraphicsSwitching</key>
<true/>
</dict>
</plist>

View file

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

View file

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

View file

@ -1,51 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDisplayName</key>
<string>Anki</string>
<key>CFBundleShortVersionString</key>
<string>2.1.46</string>
<key>LSMinimumSystemVersion</key>
<string>10.14.0</string>
<key>CFBundleDocumentTypes</key>
<array>
<dict>
<key>CFBundleTypeExtensions</key>
<array>
<string>colpkg</string>
<string>apkg</string>
<string>ankiaddon</string>
</array>
<key>CFBundleTypeIconName</key>
<string>AppIcon</string>
<key>CFBundleTypeName</key>
<string>Anki File</string>
<key>CFBundleTypeRole</key>
<string>Editor</string>
</dict>
</array>
<key>CFBundleExecutable</key>
<string>anki</string>
<key>CFBundleIconName</key>
<string>AppIcon</string>
<key>CFBundleIdentifier</key>
<string>net.ankiweb.dtop</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>Anki</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>NSHighResolutionCapable</key>
<true/>
<key>NSMicrophoneUsageDescription</key>
<string>The microphone will only be used when you tap the record button.</string>
<key>NSCameraUsageDescription</key>
<string>Add-ons may access your camera.</string>
<key>NSRequiresAquaSystemAppearance</key>
<true/>
<key>NSSupportsAutomaticGraphicsSwitching</key>
<true/>
</dict>
</plist>