Add sidecar download, setup screen, and auto-launch
On first launch, the app now prompts users to download the Python sidecar (CPU or CUDA variant) from Gitea releases, matching the voice-to-notes pattern. On subsequent launches, it auto-launches the sidecar and connects. New Rust module (src-tauri/src/sidecar/): - download_sidecar: streams download with progress events, extracts zip - check_sidecar: verifies installed sidecar binary exists - check_sidecar_update: compares local vs latest release version - SidecarManager: launches binary, waits for ready JSON, manages lifecycle - Dev mode: runs `python -m backend.main_headless` directly - start_sidecar/stop_sidecar/get_sidecar_port: Tauri commands New Svelte component (SidecarSetup.svelte): - First-time setup overlay with CPU/CUDA variant selection - Download progress bar with byte counter - Error state with retry, success state with auto-continue Updated App.svelte state machine: - checking -> needs_setup -> starting -> connected - Falls back to direct connection in browser dev mode Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
580
src-tauri/src/sidecar/mod.rs
Normal file
580
src-tauri/src/sidecar/mod.rs
Normal file
@@ -0,0 +1,580 @@
|
||||
use std::io::BufRead;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::{AppHandle, Emitter};
|
||||
|
||||
const REPO_API: &str =
|
||||
"https://repo.anhonesthost.net/api/v1/repos/streamer-tools/local-transcription";
|
||||
|
||||
const BINARY_NAME: &str = if cfg!(windows) {
|
||||
"local-transcription-backend.exe"
|
||||
} else {
|
||||
"local-transcription-backend"
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Directory state (initialised once during Tauri setup)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
static DIRS: std::sync::OnceLock<SidecarDirs> = std::sync::OnceLock::new();
|
||||
|
||||
struct SidecarDirs {
|
||||
#[allow(dead_code)]
|
||||
resource_dir: PathBuf,
|
||||
data_dir: PathBuf,
|
||||
}
|
||||
|
||||
/// Called from Tauri `setup` to persist the resource / data directories.
|
||||
pub fn init_dirs(resource_dir: PathBuf, data_dir: PathBuf) {
|
||||
let _ = DIRS.set(SidecarDirs {
|
||||
resource_dir,
|
||||
data_dir,
|
||||
});
|
||||
}
|
||||
|
||||
fn data_dir() -> &'static PathBuf {
|
||||
&DIRS.get().expect("sidecar::init_dirs not called").data_dir
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Version helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn version_file() -> PathBuf {
|
||||
data_dir().join("sidecar-version.txt")
|
||||
}
|
||||
|
||||
fn read_installed_version() -> Option<String> {
|
||||
std::fs::read_to_string(version_file())
|
||||
.ok()
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
}
|
||||
|
||||
fn sidecar_dir_for_version(version: &str) -> PathBuf {
|
||||
data_dir().join(format!("sidecar-{version}"))
|
||||
}
|
||||
|
||||
fn binary_path_for_version(version: &str) -> PathBuf {
|
||||
sidecar_dir_for_version(version).join(BINARY_NAME)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Gitea API types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct GiteaRelease {
|
||||
tag_name: String,
|
||||
assets: Vec<GiteaAsset>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct GiteaAsset {
|
||||
name: String,
|
||||
browser_download_url: String,
|
||||
size: u64,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Platform / arch detection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn platform_token() -> &'static str {
|
||||
if cfg!(target_os = "windows") {
|
||||
"windows"
|
||||
} else if cfg!(target_os = "macos") {
|
||||
"macos"
|
||||
} else {
|
||||
"linux"
|
||||
}
|
||||
}
|
||||
|
||||
fn arch_token() -> &'static str {
|
||||
if cfg!(target_arch = "aarch64") {
|
||||
"aarch64"
|
||||
} else {
|
||||
"x86_64"
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the expected asset prefix, e.g. `sidecar-linux-x86_64-cuda`.
|
||||
fn asset_prefix(variant: &str) -> String {
|
||||
format!("sidecar-{}-{}-{}", platform_token(), arch_token(), variant)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tauri commands
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Returns `true` when a sidecar binary is installed and the file exists.
|
||||
#[tauri::command]
|
||||
pub fn check_sidecar() -> bool {
|
||||
if let Some(version) = read_installed_version() {
|
||||
binary_path_for_version(&version).exists()
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Download progress payload emitted via `sidecar-download-progress`.
|
||||
#[derive(Clone, Serialize)]
|
||||
struct DownloadProgress {
|
||||
downloaded: u64,
|
||||
total: u64,
|
||||
phase: String, // "downloading" | "extracting" | "done" | "error"
|
||||
message: String,
|
||||
}
|
||||
|
||||
/// Download & install the latest sidecar release.
|
||||
///
|
||||
/// `variant` is typically `"cuda"` or `"cpu"`.
|
||||
#[tauri::command]
|
||||
pub async fn download_sidecar(app: AppHandle, variant: String) -> Result<String, String> {
|
||||
use futures_util::StreamExt;
|
||||
|
||||
let emit = |progress: DownloadProgress| {
|
||||
let _ = app.emit("sidecar-download-progress", progress);
|
||||
};
|
||||
|
||||
// 1. Fetch releases from Gitea (filter to sidecar-v* tags) ---------------
|
||||
emit(DownloadProgress {
|
||||
downloaded: 0,
|
||||
total: 0,
|
||||
phase: "downloading".into(),
|
||||
message: "Fetching release info...".into(),
|
||||
});
|
||||
|
||||
let releases_url = format!("{REPO_API}/releases?limit=20");
|
||||
let client = reqwest::Client::new();
|
||||
let releases: Vec<GiteaRelease> = client
|
||||
.get(&releases_url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to fetch releases: {e}"))?
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse releases: {e}"))?;
|
||||
|
||||
// Find the latest release whose tag starts with `sidecar-v`
|
||||
let release = releases
|
||||
.into_iter()
|
||||
.find(|r| r.tag_name.starts_with("sidecar-v"))
|
||||
.ok_or_else(|| "No sidecar release found".to_string())?;
|
||||
|
||||
let version = release.tag_name.clone(); // e.g. "sidecar-v1.0.2"
|
||||
|
||||
// 2. Find matching asset ----------------------------------------------------
|
||||
let prefix = asset_prefix(&variant);
|
||||
let asset = release
|
||||
.assets
|
||||
.iter()
|
||||
.find(|a| a.name.starts_with(&prefix) && a.name.ends_with(".zip"))
|
||||
.ok_or_else(|| {
|
||||
format!(
|
||||
"No asset matching '{}' in release {}. Available: {}",
|
||||
prefix,
|
||||
version,
|
||||
release
|
||||
.assets
|
||||
.iter()
|
||||
.map(|a| a.name.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
)
|
||||
})?;
|
||||
|
||||
let total_size = asset.size;
|
||||
let download_url = asset.browser_download_url.clone();
|
||||
|
||||
// 3. Stream download ---------------------------------------------------------
|
||||
emit(DownloadProgress {
|
||||
downloaded: 0,
|
||||
total: total_size,
|
||||
phase: "downloading".into(),
|
||||
message: format!("Downloading {}...", asset.name),
|
||||
});
|
||||
|
||||
let response = client
|
||||
.get(&download_url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Download request failed: {e}"))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(format!("Download failed with status {}", response.status()));
|
||||
}
|
||||
|
||||
let tmp_zip = data_dir().join("_sidecar_download.zip");
|
||||
let mut file = tokio::fs::File::create(&tmp_zip)
|
||||
.await
|
||||
.map_err(|e| format!("Cannot create temp file: {e}"))?;
|
||||
|
||||
let mut stream = response.bytes_stream();
|
||||
let mut downloaded: u64 = 0;
|
||||
|
||||
use tokio::io::AsyncWriteExt;
|
||||
while let Some(chunk) = stream.next().await {
|
||||
let chunk = chunk.map_err(|e| format!("Download stream error: {e}"))?;
|
||||
file.write_all(&chunk)
|
||||
.await
|
||||
.map_err(|e| format!("Write error: {e}"))?;
|
||||
downloaded += chunk.len() as u64;
|
||||
|
||||
emit(DownloadProgress {
|
||||
downloaded,
|
||||
total: total_size,
|
||||
phase: "downloading".into(),
|
||||
message: format!(
|
||||
"Downloading... {:.1} / {:.1} MB",
|
||||
downloaded as f64 / 1_048_576.0,
|
||||
total_size as f64 / 1_048_576.0
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
file.flush()
|
||||
.await
|
||||
.map_err(|e| format!("Flush error: {e}"))?;
|
||||
drop(file);
|
||||
|
||||
// 4. Extract zip -------------------------------------------------------------
|
||||
emit(DownloadProgress {
|
||||
downloaded,
|
||||
total: total_size,
|
||||
phase: "extracting".into(),
|
||||
message: "Extracting sidecar...".into(),
|
||||
});
|
||||
|
||||
let dest_dir = sidecar_dir_for_version(&version);
|
||||
if dest_dir.exists() {
|
||||
std::fs::remove_dir_all(&dest_dir)
|
||||
.map_err(|e| format!("Cannot clean old dir: {e}"))?;
|
||||
}
|
||||
std::fs::create_dir_all(&dest_dir)
|
||||
.map_err(|e| format!("Cannot create sidecar dir: {e}"))?;
|
||||
|
||||
// Extraction is blocking I/O -- offload to a spawn_blocking thread.
|
||||
let zip_path = tmp_zip.clone();
|
||||
let dest = dest_dir.clone();
|
||||
tokio::task::spawn_blocking(move || extract_zip(&zip_path, &dest))
|
||||
.await
|
||||
.map_err(|e| format!("Join error: {e}"))?
|
||||
.map_err(|e| format!("Extraction error: {e}"))?;
|
||||
|
||||
// Remove the temp zip
|
||||
let _ = std::fs::remove_file(&tmp_zip);
|
||||
|
||||
// 5. Set executable permissions on Unix -------------------------------------
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let bin = dest_dir.join(BINARY_NAME);
|
||||
if bin.exists() {
|
||||
let mut perms = std::fs::metadata(&bin)
|
||||
.map_err(|e| format!("metadata error: {e}"))?
|
||||
.permissions();
|
||||
perms.set_mode(0o755);
|
||||
std::fs::set_permissions(&bin, perms)
|
||||
.map_err(|e| format!("chmod error: {e}"))?;
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Write version file & clean up old versions ----------------------------
|
||||
std::fs::write(version_file(), &version)
|
||||
.map_err(|e| format!("Failed to write version file: {e}"))?;
|
||||
|
||||
cleanup_old_versions(&version);
|
||||
|
||||
emit(DownloadProgress {
|
||||
downloaded,
|
||||
total: total_size,
|
||||
phase: "done".into(),
|
||||
message: "Sidecar installed successfully".into(),
|
||||
});
|
||||
|
||||
Ok(version)
|
||||
}
|
||||
|
||||
/// Check if there is a newer sidecar release than the installed one.
|
||||
/// Returns `Some(tag_name)` when an update is available, or `None`.
|
||||
#[tauri::command]
|
||||
pub async fn check_sidecar_update() -> Result<Option<String>, String> {
|
||||
let installed = match read_installed_version() {
|
||||
Some(v) => v,
|
||||
None => return Ok(None),
|
||||
};
|
||||
|
||||
let releases_url = format!("{REPO_API}/releases?limit=20");
|
||||
let releases: Vec<GiteaRelease> = reqwest::Client::new()
|
||||
.get(&releases_url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to fetch releases: {e}"))?
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse releases: {e}"))?;
|
||||
|
||||
let latest = releases
|
||||
.iter()
|
||||
.find(|r| r.tag_name.starts_with("sidecar-v"));
|
||||
|
||||
match latest {
|
||||
Some(rel) if rel.tag_name != installed => Ok(Some(rel.tag_name.clone())),
|
||||
_ => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Zip extraction helper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn extract_zip(zip_path: &std::path::Path, dest: &std::path::Path) -> Result<(), String> {
|
||||
let file =
|
||||
std::fs::File::open(zip_path).map_err(|e| format!("Cannot open zip: {e}"))?;
|
||||
let mut archive =
|
||||
zip::ZipArchive::new(file).map_err(|e| format!("Invalid zip: {e}"))?;
|
||||
|
||||
for i in 0..archive.len() {
|
||||
let mut entry = archive
|
||||
.by_index(i)
|
||||
.map_err(|e| format!("Zip entry error: {e}"))?;
|
||||
let entry_path = match entry.enclosed_name() {
|
||||
Some(p) => p.to_owned(),
|
||||
None => continue,
|
||||
};
|
||||
|
||||
let out_path = dest.join(&entry_path);
|
||||
|
||||
if entry.is_dir() {
|
||||
std::fs::create_dir_all(&out_path)
|
||||
.map_err(|e| format!("mkdir error: {e}"))?;
|
||||
} else {
|
||||
if let Some(parent) = out_path.parent() {
|
||||
std::fs::create_dir_all(parent)
|
||||
.map_err(|e| format!("mkdir error: {e}"))?;
|
||||
}
|
||||
let mut outfile = std::fs::File::create(&out_path)
|
||||
.map_err(|e| format!("create file error: {e}"))?;
|
||||
std::io::copy(&mut entry, &mut outfile)
|
||||
.map_err(|e| format!("copy error: {e}"))?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cleanup old versions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn cleanup_old_versions(current_version: &str) {
|
||||
let data = data_dir();
|
||||
let current_dir_name = format!("sidecar-{current_version}");
|
||||
if let Ok(entries) = std::fs::read_dir(data) {
|
||||
for entry in entries.flatten() {
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
if name.starts_with("sidecar-v") // e.g. sidecar-v1.0.1
|
||||
&& name != current_dir_name
|
||||
&& entry.path().is_dir()
|
||||
{
|
||||
let _ = std::fs::remove_dir_all(entry.path());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SidecarManager — launch / stop / query the backend process
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct ReadyEvent {
|
||||
event: String,
|
||||
port: u16,
|
||||
}
|
||||
|
||||
pub struct SidecarManager {
|
||||
child: Option<std::process::Child>,
|
||||
port: Option<u16>,
|
||||
}
|
||||
|
||||
impl SidecarManager {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
child: None,
|
||||
port: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns `true` when the child process is still alive.
|
||||
pub fn is_running(&mut self) -> bool {
|
||||
match &mut self.child {
|
||||
Some(child) => match child.try_wait() {
|
||||
Ok(Some(_)) => {
|
||||
// Process has exited
|
||||
self.child = None;
|
||||
self.port = None;
|
||||
false
|
||||
}
|
||||
Ok(None) => true,
|
||||
Err(_) => false,
|
||||
},
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Start the sidecar if it is not already running. Returns the port.
|
||||
pub fn ensure_running(&mut self) -> Result<u16, String> {
|
||||
if self.is_running() {
|
||||
return self
|
||||
.port
|
||||
.ok_or_else(|| "Sidecar running but port unknown".into());
|
||||
}
|
||||
|
||||
let is_dev = cfg!(debug_assertions)
|
||||
|| std::env::var("LOCAL_TRANSCRIPTION_DEV")
|
||||
.map(|v| v == "1")
|
||||
.unwrap_or(false);
|
||||
|
||||
let mut cmd = if is_dev {
|
||||
self.build_dev_command()?
|
||||
} else {
|
||||
self.build_prod_command()?
|
||||
};
|
||||
|
||||
// Hide the console window on Windows in release mode.
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||||
cmd.creation_flags(CREATE_NO_WINDOW);
|
||||
}
|
||||
|
||||
cmd.stdout(std::process::Stdio::piped());
|
||||
cmd.stderr(std::process::Stdio::piped());
|
||||
|
||||
let mut child = cmd.spawn().map_err(|e| format!("Failed to spawn sidecar: {e}"))?;
|
||||
|
||||
// Wait for the `{"event":"ready","port":...}` line on stdout.
|
||||
let stdout = child
|
||||
.stdout
|
||||
.take()
|
||||
.ok_or("Failed to capture sidecar stdout")?;
|
||||
|
||||
let port = Self::wait_for_ready(stdout)?;
|
||||
|
||||
self.child = Some(child);
|
||||
self.port = Some(port);
|
||||
Ok(port)
|
||||
}
|
||||
|
||||
/// Stop the sidecar process if running.
|
||||
pub fn stop(&mut self) {
|
||||
if let Some(mut child) = self.child.take() {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
}
|
||||
self.port = None;
|
||||
}
|
||||
|
||||
/// Return the port the sidecar is listening on, if known.
|
||||
pub fn port(&self) -> Option<u16> {
|
||||
self.port
|
||||
}
|
||||
|
||||
// -- private helpers -------------------------------------------------------
|
||||
|
||||
fn build_dev_command(&self) -> Result<std::process::Command, String> {
|
||||
let mut cmd = std::process::Command::new("python");
|
||||
cmd.args(["-m", "backend.main_headless"]);
|
||||
|
||||
// Try to find the project root (parent of src-tauri)
|
||||
if let Some(dirs) = DIRS.get() {
|
||||
let project_root = dirs
|
||||
.resource_dir
|
||||
.parent() // src-tauri
|
||||
.and_then(|p| p.parent()); // project root
|
||||
if let Some(root) = project_root {
|
||||
cmd.current_dir(root);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(cmd)
|
||||
}
|
||||
|
||||
fn build_prod_command(&self) -> Result<std::process::Command, String> {
|
||||
let version = read_installed_version()
|
||||
.ok_or("No sidecar version installed")?;
|
||||
let bin = binary_path_for_version(&version);
|
||||
if !bin.exists() {
|
||||
return Err(format!("Sidecar binary not found at {}", bin.display()));
|
||||
}
|
||||
let mut cmd = std::process::Command::new(&bin);
|
||||
cmd.current_dir(
|
||||
bin.parent()
|
||||
.ok_or("Cannot determine sidecar parent dir")?,
|
||||
);
|
||||
Ok(cmd)
|
||||
}
|
||||
|
||||
fn wait_for_ready(stdout: std::process::ChildStdout) -> Result<u16, String> {
|
||||
let reader = std::io::BufReader::new(stdout);
|
||||
let timeout = std::time::Duration::from_secs(120);
|
||||
let start = std::time::Instant::now();
|
||||
|
||||
for line in reader.lines() {
|
||||
if start.elapsed() > timeout {
|
||||
return Err("Timed out waiting for sidecar ready event".into());
|
||||
}
|
||||
let line = line.map_err(|e| format!("IO error reading stdout: {e}"))?;
|
||||
if let Ok(evt) = serde_json::from_str::<ReadyEvent>(&line) {
|
||||
if evt.event == "ready" {
|
||||
return Ok(evt.port);
|
||||
}
|
||||
}
|
||||
// Ignore other lines (e.g. log output)
|
||||
}
|
||||
Err("Sidecar process exited before sending ready event".into())
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tauri-managed SidecarManager state & commands
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Wrapper so we can store `SidecarManager` in Tauri's managed state.
|
||||
pub struct ManagedSidecar(pub Mutex<SidecarManager>);
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_sidecar_port(state: tauri::State<'_, ManagedSidecar>) -> Result<Option<u16>, String> {
|
||||
let mut mgr = state
|
||||
.0
|
||||
.lock()
|
||||
.map_err(|e| format!("Lock error: {e}"))?;
|
||||
// Refresh running status before returning port
|
||||
if !mgr.is_running() {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(mgr.port())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn start_sidecar(state: tauri::State<'_, ManagedSidecar>) -> Result<u16, String> {
|
||||
let mut mgr = state
|
||||
.0
|
||||
.lock()
|
||||
.map_err(|e| format!("Lock error: {e}"))?;
|
||||
mgr.ensure_running()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn stop_sidecar(state: tauri::State<'_, ManagedSidecar>) -> Result<(), String> {
|
||||
let mut mgr = state
|
||||
.0
|
||||
.lock()
|
||||
.map_err(|e| format!("Lock error: {e}"))?;
|
||||
mgr.stop();
|
||||
Ok(())
|
||||
}
|
||||
Reference in New Issue
Block a user