Fix Tauri build stack overflow: zip sidecar and extract on first launch
All checks were successful
Build macOS / Build (macOS) (push) Successful in 3m50s
Build Linux / Build (Linux) (push) Successful in 8m3s
Build Windows / Build (Windows) (push) Successful in 9m8s

Tauri's build script overflows the stack when processing resource globs
matching thousands of files from PyInstaller's ML output (torch, pyannote).

Instead of bundling the sidecar directory directly:
- CI zips the sidecar output into a single sidecar.zip
- Tauri bundles just the one zip file (no recursion)
- On first launch, Rust extracts the zip to the app data directory
- Versioned extraction dir (sidecar-{version}) ensures updates re-extract

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Claude
2026-03-21 07:12:22 -07:00
parent 12869e3757
commit 462a4b80f6
6 changed files with 118 additions and 42 deletions

View File

@@ -39,10 +39,9 @@ jobs:
working-directory: python
run: uv run --python ${{ env.PYTHON_VERSION }} python build_sidecar.py --cpu-only
- name: Place sidecar for Tauri
- name: Package sidecar for Tauri
run: |
cp -r python/dist/voice-to-notes-sidecar src-tauri/sidecar
chmod +x src-tauri/sidecar/voice-to-notes-sidecar
cd python/dist/voice-to-notes-sidecar && zip -r ../../../src-tauri/sidecar.zip .
# ── Tauri app ──
- name: Set up Node.js
@@ -65,7 +64,7 @@ jobs:
- name: Build Tauri app
run: npm run tauri build
env:
TAURI_CONFIG: '{"bundle":{"resources":["sidecar/**"]}}'
TAURI_CONFIG: '{"bundle":{"resources":["sidecar.zip"]}}'
# ── Release ──
- name: Upload to release

View File

@@ -39,10 +39,9 @@ jobs:
working-directory: python
run: uv run --python ${{ env.PYTHON_VERSION }} python build_sidecar.py --cpu-only
- name: Place sidecar for Tauri
- name: Package sidecar for Tauri
run: |
cp -r python/dist/voice-to-notes-sidecar src-tauri/sidecar
chmod +x src-tauri/sidecar/voice-to-notes-sidecar
cd python/dist/voice-to-notes-sidecar && zip -r ../../../src-tauri/sidecar.zip .
# ── Tauri app ──
- name: Set up Node.js
@@ -64,7 +63,7 @@ jobs:
- name: Build Tauri app
run: npm run tauri build
env:
TAURI_CONFIG: '{"bundle":{"resources":["sidecar/**"]}}'
TAURI_CONFIG: '{"bundle":{"resources":["sidecar.zip"]}}'
# ── Release ──
- name: Upload to release

View File

@@ -43,10 +43,10 @@ jobs:
working-directory: python
run: uv run --python ${{ env.PYTHON_VERSION }} python build_sidecar.py --cpu-only
- name: Place sidecar for Tauri
- name: Package sidecar for Tauri
shell: powershell
run: |
Copy-Item -Path python\dist\voice-to-notes-sidecar -Destination src-tauri\sidecar -Recurse -Force
Compress-Archive -Path python\dist\voice-to-notes-sidecar\* -DestinationPath src-tauri\sidecar.zip
# ── Tauri app ──
- name: Set up Node.js
@@ -73,7 +73,7 @@ jobs:
shell: powershell
run: npm run tauri build
env:
TAURI_CONFIG: '{"bundle":{"resources":["sidecar/**"]}}'
TAURI_CONFIG: '{"bundle":{"resources":["sidecar.zip"]}}'
# ── Release ──
- name: Upload to release

View File

@@ -20,6 +20,7 @@ serde = { version = "1", features = ["derive"] }
serde_json = "1"
rusqlite = { version = "0.31", features = ["bundled"] }
uuid = { version = "1", features = ["v4", "serde"] }
zip = { version = "2", default-features = false, features = ["deflate"] }
thiserror = "1"
chrono = { version = "0.4", features = ["serde"] }
tauri-plugin-dialog = "2.6.0"

View File

@@ -28,8 +28,11 @@ pub fn run() {
.manage(app_state)
.setup(|app| {
// Tell the sidecar manager where Tauri placed bundled resources
if let Ok(resource_dir) = app.path().resource_dir() {
sidecar::init_resource_dir(resource_dir);
// and where to extract the sidecar archive
if let (Ok(resource_dir), Ok(data_dir)) =
(app.path().resource_dir(), app.path().app_local_data_dir())
{
sidecar::init_dirs(resource_dir, data_dir);
}
// Set the webview background to match the app's dark theme

View File

@@ -2,20 +2,22 @@ pub mod ipc;
pub mod messages;
use std::io::{BufRead, BufReader, Write};
use std::path::PathBuf;
use std::path::{Path, PathBuf};
use std::process::{Child, ChildStdin, Command, Stdio};
use std::sync::{Mutex, OnceLock};
use crate::sidecar::messages::IPCMessage;
/// Resource directory set by the Tauri app during setup.
/// Used to locate the bundled sidecar binary and its companion files.
static RESOURCE_DIR: OnceLock<PathBuf> = OnceLock::new();
/// App data directory for extracting the sidecar archive.
static DATA_DIR: OnceLock<PathBuf> = OnceLock::new();
/// Set the resource directory for sidecar resolution.
/// Initialize directories for sidecar resolution.
/// Must be called from the Tauri setup before any sidecar operations.
pub fn init_resource_dir(dir: PathBuf) {
RESOURCE_DIR.set(dir).ok();
pub fn init_dirs(resource_dir: PathBuf, data_dir: PathBuf) {
RESOURCE_DIR.set(resource_dir).ok();
DATA_DIR.set(data_dir).ok();
}
/// Get the global sidecar manager singleton.
@@ -53,50 +55,74 @@ impl SidecarManager {
/// Resolve the frozen sidecar binary path (production mode).
///
/// Searches for the PyInstaller-built sidecar in the Tauri resource directory
/// (set via `init_resource_dir`) and falls back to paths relative to the
/// current executable.
fn resolve_sidecar_path() -> Result<std::path::PathBuf, String> {
/// First checks if the sidecar is already extracted to the app data directory.
/// If not, looks for `sidecar.zip` in the Tauri resource directory and extracts it.
fn resolve_sidecar_path() -> Result<PathBuf, String> {
let binary_name = if cfg!(target_os = "windows") {
"voice-to-notes-sidecar.exe"
} else {
"voice-to-notes-sidecar"
};
let mut candidates: Vec<std::path::PathBuf> = Vec::new();
// Versioned extraction directory prevents stale sidecar after app updates
let extract_dir = DATA_DIR
.get()
.ok_or("App data directory not initialized")?
.join(format!("sidecar-{}", env!("CARGO_PKG_VERSION")));
// Primary: Tauri resource directory (set during app setup)
if let Some(resource_dir) = RESOURCE_DIR.get() {
// Resources are placed under sidecar/ subdirectory
candidates.push(resource_dir.join("sidecar").join(binary_name));
// Also check flat layout in resource dir
candidates.push(resource_dir.join(binary_name));
let binary_path = extract_dir.join(binary_name);
// Already extracted — use it directly
if binary_path.exists() {
return Ok(binary_path);
}
// Fallback: relative to the current executable
// Find sidecar.zip in resource dir or next to exe
let zip_path = Self::find_sidecar_zip()?;
Self::extract_zip(&zip_path, &extract_dir)?;
if !binary_path.exists() {
return Err(format!(
"Sidecar binary not found after extraction at {}",
binary_path.display()
));
}
// Make executable on Unix
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if let Ok(meta) = std::fs::metadata(&binary_path) {
let mut perms = meta.permissions();
perms.set_mode(0o755);
let _ = std::fs::set_permissions(&binary_path, perms);
}
}
Ok(binary_path)
}
/// Locate the bundled sidecar.zip archive.
fn find_sidecar_zip() -> Result<PathBuf, String> {
let mut candidates: Vec<PathBuf> = Vec::new();
if let Some(resource_dir) = RESOURCE_DIR.get() {
candidates.push(resource_dir.join("sidecar.zip"));
}
if let Ok(exe) = std::env::current_exe() {
if let Some(exe_dir) = exe.parent() {
// sidecar/ subdirectory next to exe (Windows MSI, Linux AppImage)
candidates.push(exe_dir.join("sidecar").join(binary_name));
// Flat layout next to exe
candidates.push(exe_dir.join(binary_name));
// PyInstaller onedir subdirectory
candidates.push(
exe_dir
.join("voice-to-notes-sidecar")
.join(binary_name),
);
candidates.push(exe_dir.join("sidecar.zip"));
}
}
for path in &candidates {
if path.exists() {
return Ok(path.canonicalize().unwrap_or_else(|_| path.clone()));
return Ok(path.clone());
}
}
Err(format!(
"Sidecar binary not found. Checked:\n{}",
"Sidecar archive not found. Checked:\n{}",
candidates
.iter()
.map(|p| format!(" {}", p.display()))
@@ -105,6 +131,54 @@ impl SidecarManager {
))
}
/// Extract a zip archive to the given directory.
fn extract_zip(zip_path: &Path, dest: &Path) -> Result<(), String> {
eprintln!(
"[sidecar-rs] Extracting sidecar from {} to {}",
zip_path.display(),
dest.display()
);
// Clean destination so we don't mix old and new files
if dest.exists() {
std::fs::remove_dir_all(dest)
.map_err(|e| format!("Failed to clean extraction dir: {e}"))?;
}
std::fs::create_dir_all(dest)
.map_err(|e| format!("Failed to create extraction dir: {e}"))?;
let file =
std::fs::File::open(zip_path).map_err(|e| format!("Cannot open sidecar zip: {e}"))?;
let mut archive =
zip::ZipArchive::new(file).map_err(|e| format!("Invalid sidecar zip: {e}"))?;
for i in 0..archive.len() {
let mut entry = archive
.by_index(i)
.map_err(|e| format!("Zip entry error: {e}"))?;
let name = entry.name().to_string();
let outpath = dest.join(&name);
if entry.is_dir() {
std::fs::create_dir_all(&outpath)
.map_err(|e| format!("Cannot create dir {}: {e}", outpath.display()))?;
} else {
if let Some(parent) = outpath.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| format!("Cannot create dir {}: {e}", parent.display()))?;
}
let mut outfile = std::fs::File::create(&outpath)
.map_err(|e| format!("Cannot create {}: {e}", outpath.display()))?;
std::io::copy(&mut entry, &mut outfile)
.map_err(|e| format!("Write error for {}: {e}", name))?;
}
}
eprintln!("[sidecar-rs] Sidecar extracted successfully");
Ok(())
}
/// Find a working Python command for the current platform.
fn find_python_command() -> &'static str {
if cfg!(target_os = "windows") {