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

View File

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

View File

@@ -43,10 +43,10 @@ jobs:
working-directory: python working-directory: python
run: uv run --python ${{ env.PYTHON_VERSION }} python build_sidecar.py --cpu-only 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 shell: powershell
run: | 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 ── # ── Tauri app ──
- name: Set up Node.js - name: Set up Node.js
@@ -73,7 +73,7 @@ jobs:
shell: powershell shell: powershell
run: npm run tauri build run: npm run tauri build
env: env:
TAURI_CONFIG: '{"bundle":{"resources":["sidecar/**"]}}' TAURI_CONFIG: '{"bundle":{"resources":["sidecar.zip"]}}'
# ── Release ── # ── Release ──
- name: Upload to release - name: Upload to release

View File

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

View File

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

View File

@@ -2,20 +2,22 @@ pub mod ipc;
pub mod messages; pub mod messages;
use std::io::{BufRead, BufReader, Write}; use std::io::{BufRead, BufReader, Write};
use std::path::PathBuf; use std::path::{Path, PathBuf};
use std::process::{Child, ChildStdin, Command, Stdio}; use std::process::{Child, ChildStdin, Command, Stdio};
use std::sync::{Mutex, OnceLock}; use std::sync::{Mutex, OnceLock};
use crate::sidecar::messages::IPCMessage; use crate::sidecar::messages::IPCMessage;
/// Resource directory set by the Tauri app during setup. /// 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(); 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. /// Must be called from the Tauri setup before any sidecar operations.
pub fn init_resource_dir(dir: PathBuf) { pub fn init_dirs(resource_dir: PathBuf, data_dir: PathBuf) {
RESOURCE_DIR.set(dir).ok(); RESOURCE_DIR.set(resource_dir).ok();
DATA_DIR.set(data_dir).ok();
} }
/// Get the global sidecar manager singleton. /// Get the global sidecar manager singleton.
@@ -53,50 +55,74 @@ impl SidecarManager {
/// Resolve the frozen sidecar binary path (production mode). /// Resolve the frozen sidecar binary path (production mode).
/// ///
/// Searches for the PyInstaller-built sidecar in the Tauri resource directory /// First checks if the sidecar is already extracted to the app data directory.
/// (set via `init_resource_dir`) and falls back to paths relative to the /// If not, looks for `sidecar.zip` in the Tauri resource directory and extracts it.
/// current executable. fn resolve_sidecar_path() -> Result<PathBuf, String> {
fn resolve_sidecar_path() -> Result<std::path::PathBuf, String> {
let binary_name = if cfg!(target_os = "windows") { let binary_name = if cfg!(target_os = "windows") {
"voice-to-notes-sidecar.exe" "voice-to-notes-sidecar.exe"
} else { } else {
"voice-to-notes-sidecar" "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) let binary_path = extract_dir.join(binary_name);
if let Some(resource_dir) = RESOURCE_DIR.get() {
// Resources are placed under sidecar/ subdirectory // Already extracted — use it directly
candidates.push(resource_dir.join("sidecar").join(binary_name)); if binary_path.exists() {
// Also check flat layout in resource dir return Ok(binary_path);
candidates.push(resource_dir.join(binary_name));
} }
// 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 Ok(exe) = std::env::current_exe() {
if let Some(exe_dir) = exe.parent() { if let Some(exe_dir) = exe.parent() {
// sidecar/ subdirectory next to exe (Windows MSI, Linux AppImage) candidates.push(exe_dir.join("sidecar.zip"));
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),
);
} }
} }
for path in &candidates { for path in &candidates {
if path.exists() { if path.exists() {
return Ok(path.canonicalize().unwrap_or_else(|_| path.clone())); return Ok(path.clone());
} }
} }
Err(format!( Err(format!(
"Sidecar binary not found. Checked:\n{}", "Sidecar archive not found. Checked:\n{}",
candidates candidates
.iter() .iter()
.map(|p| format!(" {}", p.display())) .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. /// Find a working Python command for the current platform.
fn find_python_command() -> &'static str { fn find_python_command() -> &'static str {
if cfg!(target_os = "windows") { if cfg!(target_os = "windows") {