Download sidecar on first launch instead of bundling
Some checks failed
Release / Bump version and tag (push) Successful in 13s
Release / Build (macOS) (push) Failing after 4m55s
Release / Build (Windows) (push) Failing after 14m58s
Release / Build (Linux) (push) Failing after 17m18s

Major refactor: sidecar is no longer bundled in the installer. Instead,
it's downloaded on first launch with a setup screen offering CPU vs CUDA
choice. This solves the 2GB+ installer size limit and decouples app/sidecar.

Backend:
- New commands: check_sidecar, download_sidecar, check_sidecar_update
- Streaming download with progress events via reqwest
- Added reqwest + futures-util dependencies
- Removed sidecar.zip from bundle resources
- Restored NSIS target (no longer size-constrained)

CI:
- Each platform builds both CPU and CUDA sidecar variants (except macOS: CPU only)
- Sidecar zips uploaded as separate release assets
- Asset naming: sidecar-{os}-{arch}-{variant}.zip

Frontend:
- SidecarSetup.svelte: first-launch setup with CPU/CUDA radio choice,
  progress bar, error/retry handling
- Update banner on launch if newer sidecar version available
- Conditional rendering: setup screen → main app flow

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Claude
2026-03-22 07:09:08 -07:00
parent 1b706a855b
commit 7fa903ad01
11 changed files with 805 additions and 22 deletions

View File

@@ -2,5 +2,6 @@ pub mod ai;
pub mod export;
pub mod project;
pub mod settings;
pub mod sidecar;
pub mod system;
pub mod transcribe;

View File

@@ -0,0 +1,211 @@
use futures_util::StreamExt;
use serde::Serialize;
use std::io::Write;
use tauri::{AppHandle, Emitter};
use crate::sidecar::{SidecarManager, DATA_DIR};
const REPO_API: &str = "https://repo.anhonesthost.net/api/v1/repos/MacroPad/voice-to-notes";
#[derive(Serialize, Clone)]
struct DownloadProgress {
downloaded: u64,
total: u64,
percent: u8,
}
#[derive(Serialize)]
pub struct UpdateInfo {
pub current_version: String,
pub latest_version: String,
}
/// Check if the sidecar binary exists for the current version.
#[tauri::command]
pub fn check_sidecar() -> bool {
let data_dir = match DATA_DIR.get() {
Some(d) => d,
None => return false,
};
let binary_name = if cfg!(target_os = "windows") {
"voice-to-notes-sidecar.exe"
} else {
"voice-to-notes-sidecar"
};
let version = env!("CARGO_PKG_VERSION");
let extract_dir = data_dir.join(format!("sidecar-{}", version));
extract_dir.join(binary_name).exists()
}
/// Determine the current platform name for asset downloads.
fn platform_os() -> &'static str {
if cfg!(target_os = "windows") {
"windows"
} else if cfg!(target_os = "macos") {
"macos"
} else {
"linux"
}
}
/// Determine the current architecture name for asset downloads.
fn platform_arch() -> &'static str {
if cfg!(target_arch = "aarch64") {
"aarch64"
} else {
"x86_64"
}
}
/// Download the sidecar binary for the given variant (cpu or cuda).
#[tauri::command]
pub async fn download_sidecar(app: AppHandle, variant: String) -> Result<(), String> {
let data_dir = DATA_DIR.get().ok_or("App data directory not initialized")?;
let version = env!("CARGO_PKG_VERSION");
let os = platform_os();
let arch = platform_arch();
let asset_name = format!("sidecar-{}-{}-{}.zip", os, arch, variant);
// Fetch release info from Gitea API to get the download URL
let release_url = format!("{}/releases/tags/v{}", REPO_API, version);
let client = reqwest::Client::new();
let release_resp = client
.get(&release_url)
.header("Accept", "application/json")
.send()
.await
.map_err(|e| format!("Failed to fetch release info: {}", e))?;
if !release_resp.status().is_success() {
return Err(format!(
"Failed to fetch release info: HTTP {}",
release_resp.status()
));
}
let release_json: serde_json::Value = release_resp
.json()
.await
.map_err(|e| format!("Failed to parse release JSON: {}", e))?;
// Find the matching asset
let assets = release_json["assets"]
.as_array()
.ok_or("No assets found in release")?;
let download_url = assets
.iter()
.find(|a| a["name"].as_str() == Some(&asset_name))
.and_then(|a| a["browser_download_url"].as_str())
.ok_or_else(|| format!("Asset '{}' not found in release v{}", asset_name, version))?
.to_string();
// Stream download with progress events
let response = reqwest::get(&download_url)
.await
.map_err(|e| format!("Failed to start download: {}", e))?;
if !response.status().is_success() {
return Err(format!("Download failed: HTTP {}", response.status()));
}
let total = response.content_length().unwrap_or(0);
let mut downloaded: u64 = 0;
let mut stream = response.bytes_stream();
let zip_path = data_dir.join("sidecar.zip");
let mut file = std::fs::File::create(&zip_path)
.map_err(|e| format!("Failed to create zip file: {}", e))?;
while let Some(chunk) = stream.next().await {
let chunk = chunk.map_err(|e| format!("Download stream error: {}", e))?;
file.write_all(&chunk)
.map_err(|e| format!("Failed to write chunk: {}", e))?;
downloaded += chunk.len() as u64;
let percent = if total > 0 {
(downloaded * 100 / total) as u8
} else {
0
};
let _ = app.emit(
"sidecar-download-progress",
DownloadProgress {
downloaded,
total,
percent,
},
);
}
// Extract the downloaded zip
let extract_dir = data_dir.join(format!("sidecar-{}", version));
SidecarManager::extract_zip(&zip_path, &extract_dir)?;
// Make the binary executable on Unix
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let binary_path = extract_dir.join("voice-to-notes-sidecar");
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);
}
}
// Clean up the zip file and old sidecar versions
let _ = std::fs::remove_file(&zip_path);
SidecarManager::cleanup_old_sidecars(data_dir, version);
Ok(())
}
/// Check if a sidecar update is available.
#[tauri::command]
pub async fn check_sidecar_update() -> Result<Option<UpdateInfo>, String> {
// If sidecar doesn't exist yet, return None (first launch handled separately)
if !check_sidecar() {
return Ok(None);
}
let current_version = env!("CARGO_PKG_VERSION").to_string();
// Fetch latest release from Gitea API
let latest_url = format!("{}/releases/latest", REPO_API);
let client = reqwest::Client::new();
let resp = client
.get(&latest_url)
.header("Accept", "application/json")
.send()
.await
.map_err(|e| format!("Failed to fetch latest release: {}", e))?;
if !resp.status().is_success() {
return Err(format!(
"Failed to fetch latest release: HTTP {}",
resp.status()
));
}
let release_json: serde_json::Value = resp
.json()
.await
.map_err(|e| format!("Failed to parse release JSON: {}", e))?;
let latest_tag = release_json["tag_name"]
.as_str()
.ok_or("No tag_name in release")?;
// Strip leading 'v' if present
let latest_version = latest_tag.strip_prefix('v').unwrap_or(latest_tag);
if latest_version != current_version {
Ok(Some(UpdateInfo {
current_version,
latest_version: latest_version.to_string(),
}))
} else {
Ok(None)
}
}

View File

@@ -14,6 +14,7 @@ use commands::project::{
load_project_transcript, save_project_file, save_project_transcript, update_segment,
};
use commands::settings::{load_settings, save_settings};
use commands::sidecar::{check_sidecar, check_sidecar_update, download_sidecar};
use commands::system::{get_data_dir, llama_list_models, llama_start, llama_status, llama_stop};
use commands::transcribe::{download_diarize_model, run_pipeline, transcribe_file};
use state::AppState;
@@ -65,6 +66,9 @@ pub fn run() {
get_data_dir,
load_settings,
save_settings,
check_sidecar,
download_sidecar,
check_sidecar_update,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");

View File

@@ -14,7 +14,7 @@ use crate::sidecar::messages::IPCMessage;
/// Resource directory set by the Tauri app during setup.
static RESOURCE_DIR: OnceLock<PathBuf> = OnceLock::new();
/// App data directory for extracting the sidecar archive.
static DATA_DIR: OnceLock<PathBuf> = OnceLock::new();
pub(crate) static DATA_DIR: OnceLock<PathBuf> = OnceLock::new();
/// Initialize directories for sidecar resolution.
/// Must be called from the Tauri setup before any sidecar operations.
@@ -136,7 +136,7 @@ impl SidecarManager {
}
/// Extract a zip archive to the given directory.
fn extract_zip(zip_path: &Path, dest: &Path) -> Result<(), String> {
pub(crate) fn extract_zip(zip_path: &Path, dest: &Path) -> Result<(), String> {
eprintln!(
"[sidecar-rs] Extracting sidecar from {} to {}",
zip_path.display(),
@@ -185,7 +185,7 @@ impl SidecarManager {
/// Remove old sidecar-* directories that don't match the current version.
/// Called after the current version's sidecar is confirmed ready.
fn cleanup_old_sidecars(data_dir: &Path, current_version: &str) {
pub(crate) fn cleanup_old_sidecars(data_dir: &Path, current_version: &str) {
let current_dir_name = format!("sidecar-{}", current_version);
let entries = match std::fs::read_dir(data_dir) {