diff --git a/.gitea/workflows/build-linux.yml b/.gitea/workflows/build-linux.yml index e0886f0..d80225f 100644 --- a/.gitea/workflows/build-linux.yml +++ b/.gitea/workflows/build-linux.yml @@ -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 diff --git a/.gitea/workflows/build-macos.yml b/.gitea/workflows/build-macos.yml index 21a61d3..40ea942 100644 --- a/.gitea/workflows/build-macos.yml +++ b/.gitea/workflows/build-macos.yml @@ -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 diff --git a/.gitea/workflows/build-windows.yml b/.gitea/workflows/build-windows.yml index 2a19c3b..608470a 100644 --- a/.gitea/workflows/build-windows.yml +++ b/.gitea/workflows/build-windows.yml @@ -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 diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 174dde3..ef15d3f 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -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" diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 1ae9249..1b8de1e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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 diff --git a/src-tauri/src/sidecar/mod.rs b/src-tauri/src/sidecar/mod.rs index 792018f..bd7ba28 100644 --- a/src-tauri/src/sidecar/mod.rs +++ b/src-tauri/src/sidecar/mod.rs @@ -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 = OnceLock::new(); +/// App data directory for extracting the sidecar archive. +static DATA_DIR: OnceLock = 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 { + /// 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 { let binary_name = if cfg!(target_os = "windows") { "voice-to-notes-sidecar.exe" } else { "voice-to-notes-sidecar" }; - let mut candidates: Vec = 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 { + let mut candidates: Vec = 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") {