New features: - Settings > Transcription Engine > "Change Transcription Engine" button stops the sidecar, deletes downloaded files, and reloads the app to show the engine selection screen - Improved SidecarSetup descriptions with detailed explanations of each variant and "Recommended" tag on Cloud (Deepgram) - Cloud option listed first as the recommended choice - New reset_sidecar Tauri command that cleans up sidecar files Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1083 lines
36 KiB
Rust
1083 lines
36 KiB
Rust
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 {
|
|
// version is the full tag name, e.g. "sidecar-v1.0.3" -- use it directly
|
|
data_dir().join(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();
|
|
// current_version is already the full tag, e.g. "sidecar-v1.0.3"
|
|
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-")
|
|
&& name != current_version
|
|
&& 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());
|
|
}
|
|
|
|
// Clear stale PID lock from a previous crash so the sidecar can start.
|
|
// The Python InstanceLock writes to ~/.local-transcription/app.lock
|
|
if let Ok(home) = std::env::var("USERPROFILE")
|
|
.or_else(|_| std::env::var("HOME"))
|
|
{
|
|
let lock_file = PathBuf::from(home)
|
|
.join(".local-transcription")
|
|
.join("app.lock");
|
|
if lock_file.exists() {
|
|
eprintln!("[sidecar] Removing stale lock file: {}", lock_file.display());
|
|
let _ = std::fs::remove_file(&lock_file);
|
|
}
|
|
}
|
|
|
|
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")?;
|
|
|
|
// Capture stderr in a background thread so we can log it
|
|
let stderr = child
|
|
.stderr
|
|
.take()
|
|
.ok_or("Failed to capture sidecar stderr")?;
|
|
|
|
let log_dir = DIRS.get().map(|d| d.data_dir.clone());
|
|
std::thread::spawn(move || {
|
|
use std::io::BufRead;
|
|
let reader = std::io::BufReader::new(stderr);
|
|
let mut log_file = log_dir.and_then(|d| {
|
|
std::fs::OpenOptions::new()
|
|
.create(true)
|
|
.append(true)
|
|
.open(d.join("sidecar.log"))
|
|
.ok()
|
|
});
|
|
for line in reader.lines() {
|
|
if let Ok(line) = line {
|
|
eprintln!("[sidecar-stderr] {}", line);
|
|
if let Some(ref mut f) = log_file {
|
|
use std::io::Write;
|
|
let _ = writeln!(f, "{}", line);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
match Self::wait_for_ready(stdout) {
|
|
Ok(port) => {
|
|
self.child = Some(child);
|
|
self.port = Some(port);
|
|
Ok(port)
|
|
}
|
|
Err(e) => {
|
|
// Kill the child if ready failed
|
|
let _ = child.kill();
|
|
let _ = child.wait();
|
|
|
|
// Read the sidecar.log for context
|
|
let log_hint = DIRS
|
|
.get()
|
|
.and_then(|d| std::fs::read_to_string(d.data_dir.join("sidecar.log")).ok())
|
|
.and_then(|s| {
|
|
let lines: Vec<&str> = s.lines().collect();
|
|
let tail: Vec<&str> = lines.iter().rev().take(10).rev().cloned().collect();
|
|
if tail.is_empty() { None } else { Some(tail.join("\n")) }
|
|
})
|
|
.unwrap_or_default();
|
|
|
|
if log_hint.is_empty() {
|
|
Err(e)
|
|
} else {
|
|
Err(format!("{e}\n\nSidecar stderr (last 10 lines):\n{log_hint}"))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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> {
|
|
// Use `uv run` to ensure we use the project's venv, not system Python
|
|
let mut cmd = std::process::Command::new("uv");
|
|
cmd.args(["run", "python", "-u", "-m", "backend.main_headless"]);
|
|
|
|
// Find the project root: try CARGO_MANIFEST_DIR first (set at compile time),
|
|
// then fall back to resource_dir parent chain
|
|
let manifest_dir = option_env!("CARGO_MANIFEST_DIR").map(std::path::PathBuf::from);
|
|
let project_root = manifest_dir
|
|
.as_ref()
|
|
.and_then(|d| d.parent()) // src-tauri -> project root
|
|
.or_else(|| {
|
|
DIRS.get()
|
|
.and_then(|d| d.resource_dir.parent())
|
|
.and_then(|p| p.parent())
|
|
});
|
|
|
|
if let Some(root) = project_root {
|
|
eprintln!("[sidecar] Dev mode: working dir = {}", root.display());
|
|
cmd.current_dir(root);
|
|
} else {
|
|
eprintln!("[sidecar] Dev mode: WARNING - could not determine project root");
|
|
}
|
|
|
|
cmd.env("PYTHONUNBUFFERED", "1");
|
|
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")?,
|
|
);
|
|
// Force unbuffered stdout so the ready event is sent immediately.
|
|
// PyInstaller frozen executables buffer stdout when piped.
|
|
cmd.env("PYTHONUNBUFFERED", "1");
|
|
Ok(cmd)
|
|
}
|
|
|
|
fn wait_for_ready(stdout: std::process::ChildStdout) -> Result<u16, String> {
|
|
use std::sync::mpsc;
|
|
|
|
let timeout = std::time::Duration::from_secs(120);
|
|
|
|
// Read stdout in a background thread so we can enforce a real timeout.
|
|
// BufReader::lines() blocks indefinitely if no data arrives.
|
|
let (tx, rx) = mpsc::channel();
|
|
|
|
std::thread::spawn(move || {
|
|
let reader = std::io::BufReader::new(stdout);
|
|
for line in reader.lines() {
|
|
match line {
|
|
Ok(line) => {
|
|
eprintln!("[sidecar-stdout] {}", line);
|
|
if let Ok(evt) = serde_json::from_str::<ReadyEvent>(&line) {
|
|
if evt.event == "ready" {
|
|
let _ = tx.send(Ok(evt.port));
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
Err(e) => {
|
|
let _ = tx.send(Err(format!("IO error reading stdout: {e}")));
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
let _ = tx.send(Err(
|
|
"Sidecar process exited before sending ready event".into(),
|
|
));
|
|
});
|
|
|
|
rx.recv_timeout(timeout).unwrap_or_else(|_| {
|
|
Err(format!(
|
|
"Timed out after {}s waiting for sidecar ready event",
|
|
timeout.as_secs()
|
|
))
|
|
})
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tauri-managed SidecarManager state & commands
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Wrapper so we can store `SidecarManager` in Tauri's managed state.
|
|
/// Uses Arc so it can be cloned into background threads for async commands.
|
|
pub struct ManagedSidecar(pub std::sync::Arc<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 async fn start_sidecar(state: tauri::State<'_, ManagedSidecar>) -> Result<u16, String> {
|
|
let mgr = state.0.clone();
|
|
// Run blocking sidecar launch in a background thread so it doesn't
|
|
// freeze the Tauri UI while waiting for the ready event (up to 120s).
|
|
tokio::task::spawn_blocking(move || {
|
|
let mut mgr = mgr.lock().map_err(|e| format!("Lock error: {e}"))?;
|
|
mgr.ensure_running()
|
|
})
|
|
.await
|
|
.map_err(|e| format!("Task join error: {e}"))?
|
|
}
|
|
|
|
#[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(())
|
|
}
|
|
|
|
/// Stop the running sidecar, delete its files and version marker.
|
|
/// The next app launch will show the sidecar download prompt.
|
|
#[tauri::command]
|
|
pub fn reset_sidecar(state: tauri::State<'_, ManagedSidecar>) -> Result<(), String> {
|
|
// Stop the running sidecar first
|
|
{
|
|
let mut mgr = state
|
|
.0
|
|
.lock()
|
|
.map_err(|e| format!("Lock error: {e}"))?;
|
|
mgr.stop();
|
|
}
|
|
|
|
let data = data_dir();
|
|
|
|
// Delete the version file so check_sidecar returns false
|
|
let vf = version_file();
|
|
if vf.exists() {
|
|
std::fs::remove_file(&vf)
|
|
.map_err(|e| format!("Failed to delete version file: {e}"))?;
|
|
}
|
|
|
|
// Delete all sidecar directories
|
|
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-") && entry.path().is_dir() {
|
|
eprintln!("[sidecar] Removing {}", entry.path().display());
|
|
let _ = std::fs::remove_dir_all(entry.path());
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use std::io::Write;
|
|
|
|
// -----------------------------------------------------------------------
|
|
// 1. Platform / arch detection
|
|
// -----------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn platform_token_returns_valid_value() {
|
|
let token = platform_token();
|
|
assert!(
|
|
["windows", "macos", "linux"].contains(&token),
|
|
"unexpected platform token: {token}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn arch_token_returns_valid_value() {
|
|
let token = arch_token();
|
|
assert!(
|
|
["x86_64", "aarch64"].contains(&token),
|
|
"unexpected arch token: {token}"
|
|
);
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// 2. Asset name construction
|
|
// -----------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn asset_prefix_cpu() {
|
|
let prefix = asset_prefix("cpu");
|
|
let expected = format!("sidecar-{}-{}-cpu", platform_token(), arch_token());
|
|
assert_eq!(prefix, expected);
|
|
}
|
|
|
|
#[test]
|
|
fn asset_prefix_cuda() {
|
|
let prefix = asset_prefix("cuda");
|
|
let expected = format!("sidecar-{}-{}-cuda", platform_token(), arch_token());
|
|
assert_eq!(prefix, expected);
|
|
}
|
|
|
|
#[test]
|
|
fn asset_prefix_format_matches_zip_convention() {
|
|
// The download function looks for assets matching
|
|
// `{prefix}*.zip`, so verify the prefix starts with "sidecar-"
|
|
// and contains exactly three hyphens (sidecar-OS-ARCH-VARIANT).
|
|
let prefix = asset_prefix("cpu");
|
|
assert!(prefix.starts_with("sidecar-"));
|
|
assert_eq!(prefix.matches('-').count(), 3, "expected 3 hyphens in '{prefix}'");
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// 3. Version parsing — tag_name format "sidecar-vX.Y.Z"
|
|
// -----------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn sidecar_tag_starts_with_expected_prefix() {
|
|
// The code filters releases by `tag_name.starts_with("sidecar-v")`.
|
|
// Verify the convention: a version string like "sidecar-v1.0.2" passes
|
|
// the filter, while "v1.0.2" does not.
|
|
let tag = "sidecar-v1.0.2";
|
|
assert!(tag.starts_with("sidecar-v"));
|
|
|
|
let bad_tag = "v1.0.2";
|
|
assert!(!bad_tag.starts_with("sidecar-v"));
|
|
}
|
|
|
|
#[test]
|
|
fn strip_sidecar_v_prefix() {
|
|
// The codebase stores the full tag as the version (e.g. "sidecar-v1.0.2").
|
|
// Verify we can strip the prefix to get just "1.0.2" when needed.
|
|
let tag = "sidecar-v1.0.2";
|
|
let semver = tag.strip_prefix("sidecar-v").unwrap();
|
|
assert_eq!(semver, "1.0.2");
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// 4. ReadyEvent deserialization
|
|
// -----------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn ready_event_deserializes_basic() {
|
|
let json = r#"{"event": "ready", "port": 8081}"#;
|
|
let evt: ReadyEvent = serde_json::from_str(json).unwrap();
|
|
assert_eq!(evt.event, "ready");
|
|
assert_eq!(evt.port, 8081);
|
|
}
|
|
|
|
#[test]
|
|
fn ready_event_deserializes_with_extra_fields() {
|
|
// The backend may emit additional fields like `obs_port`.
|
|
// serde should ignore unknown fields by default (deny_unknown_fields
|
|
// is NOT set on ReadyEvent).
|
|
let json = r#"{"event": "ready", "port": 8081, "obs_port": 8080}"#;
|
|
let evt: ReadyEvent = serde_json::from_str(json).unwrap();
|
|
assert_eq!(evt.event, "ready");
|
|
assert_eq!(evt.port, 8081);
|
|
}
|
|
|
|
#[test]
|
|
fn ready_event_rejects_missing_port() {
|
|
let json = r#"{"event": "ready"}"#;
|
|
assert!(serde_json::from_str::<ReadyEvent>(json).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn ready_event_rejects_invalid_port_type() {
|
|
let json = r#"{"event": "ready", "port": "not_a_number"}"#;
|
|
assert!(serde_json::from_str::<ReadyEvent>(json).is_err());
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Helper: initialise DIRS with a temp directory so path-related functions
|
|
// work. Because OnceLock can only be set once per process, all tests that
|
|
// need DIRS must coordinate. We use std::sync::Once + a global temp path.
|
|
// -----------------------------------------------------------------------
|
|
|
|
static TEST_DATA_DIR: std::sync::OnceLock<PathBuf> = std::sync::OnceLock::new();
|
|
|
|
/// Ensure `DIRS` is initialised (idempotent within a test run).
|
|
/// Returns the data_dir path.
|
|
fn ensure_dirs_initialised() -> PathBuf {
|
|
TEST_DATA_DIR
|
|
.get_or_init(|| {
|
|
let tmp = tempfile::tempdir().expect("create tempdir");
|
|
let data = tmp.path().to_path_buf();
|
|
// We intentionally leak `tmp` so the directory lives for the
|
|
// entire test-run.
|
|
std::mem::forget(tmp);
|
|
let resource = data.join("resource"); // dummy
|
|
std::fs::create_dir_all(&resource).ok();
|
|
init_dirs(resource, data.clone());
|
|
data
|
|
})
|
|
.clone()
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// 5. Path construction (requires init_dirs)
|
|
// -----------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn version_file_path_is_in_data_dir() {
|
|
let data = ensure_dirs_initialised();
|
|
let vf = version_file();
|
|
assert_eq!(vf, data.join("sidecar-version.txt"));
|
|
}
|
|
|
|
#[test]
|
|
fn sidecar_dir_for_version_contains_version() {
|
|
let data = ensure_dirs_initialised();
|
|
let dir = sidecar_dir_for_version("sidecar-v1.2.3");
|
|
assert_eq!(dir, data.join("sidecar-v1.2.3"));
|
|
}
|
|
|
|
#[test]
|
|
fn binary_path_for_version_has_correct_filename() {
|
|
let _data = ensure_dirs_initialised();
|
|
let bin = binary_path_for_version("sidecar-v1.2.3");
|
|
assert_eq!(bin.file_name().unwrap(), BINARY_NAME);
|
|
}
|
|
|
|
#[test]
|
|
fn read_installed_version_none_when_missing() {
|
|
let _data = ensure_dirs_initialised();
|
|
// The version file should not exist yet (clean temp dir).
|
|
// If another test wrote it, this still validates the function
|
|
// doesn't panic.
|
|
let _ = read_installed_version(); // should not panic
|
|
}
|
|
|
|
#[test]
|
|
fn write_then_read_installed_version() {
|
|
let _data = ensure_dirs_initialised();
|
|
let vf = version_file();
|
|
std::fs::write(&vf, "sidecar-v2.0.0\n").unwrap();
|
|
let v = read_installed_version().expect("should read version");
|
|
assert_eq!(v, "sidecar-v2.0.0");
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// 6. Cleanup old versions
|
|
// -----------------------------------------------------------------------
|
|
|
|
/// Cleanup tests are combined into one function because
|
|
/// `cleanup_old_versions` operates on the shared `data_dir()` and
|
|
/// tests run in parallel, so separate tests would race.
|
|
#[test]
|
|
fn cleanup_old_versions_behaviour() {
|
|
let data = ensure_dirs_initialised();
|
|
|
|
// -- Part A: removes stale version dirs, keeps current ----------------
|
|
let dirs_to_create = ["sidecar-v1.0.0", "sidecar-v1.0.1", "sidecar-v1.0.2"];
|
|
for d in &dirs_to_create {
|
|
std::fs::create_dir_all(data.join(d)).unwrap();
|
|
}
|
|
|
|
// current_version is the full tag, e.g. "sidecar-v1.0.2"
|
|
cleanup_old_versions("sidecar-v1.0.2");
|
|
|
|
assert!(
|
|
!data.join("sidecar-v1.0.0").exists(),
|
|
"sidecar-v1.0.0 should be removed"
|
|
);
|
|
assert!(
|
|
!data.join("sidecar-v1.0.1").exists(),
|
|
"sidecar-v1.0.1 should be removed"
|
|
);
|
|
assert!(
|
|
data.join("sidecar-v1.0.2").exists(),
|
|
"sidecar-v1.0.2 should be kept"
|
|
);
|
|
|
|
// -- Part B: ignores non-sidecar directories --------------------------
|
|
let other = data.join("some-other-dir");
|
|
std::fs::create_dir_all(&other).unwrap();
|
|
|
|
cleanup_old_versions("v1.0.2"); // run again — should leave other alone
|
|
|
|
assert!(other.exists(), "non-sidecar dir should not be removed");
|
|
|
|
// Clean up so we don't affect other tests that share data_dir.
|
|
let _ = std::fs::remove_dir_all(data.join("sidecar-v1.0.2"));
|
|
let _ = std::fs::remove_dir_all(&other);
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// 7. Zip extraction
|
|
// -----------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn extract_zip_creates_files() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let zip_path = tmp.path().join("test.zip");
|
|
let dest_dir = tmp.path().join("output");
|
|
std::fs::create_dir_all(&dest_dir).unwrap();
|
|
|
|
// Build a simple zip in memory.
|
|
{
|
|
let file = std::fs::File::create(&zip_path).unwrap();
|
|
let mut writer = zip::ZipWriter::new(file);
|
|
let options = zip::write::SimpleFileOptions::default()
|
|
.compression_method(zip::CompressionMethod::Deflated);
|
|
|
|
writer.start_file("hello.txt", options).unwrap();
|
|
writer.write_all(b"Hello, world!").unwrap();
|
|
|
|
writer.start_file("subdir/nested.txt", options).unwrap();
|
|
writer.write_all(b"Nested content").unwrap();
|
|
|
|
writer.finish().unwrap();
|
|
}
|
|
|
|
extract_zip(&zip_path, &dest_dir).expect("extraction should succeed");
|
|
|
|
let hello = dest_dir.join("hello.txt");
|
|
assert!(hello.exists(), "hello.txt should exist");
|
|
assert_eq!(std::fs::read_to_string(&hello).unwrap(), "Hello, world!");
|
|
|
|
let nested = dest_dir.join("subdir/nested.txt");
|
|
assert!(nested.exists(), "subdir/nested.txt should exist");
|
|
assert_eq!(std::fs::read_to_string(&nested).unwrap(), "Nested content");
|
|
}
|
|
|
|
#[test]
|
|
fn extract_zip_error_on_invalid_file() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let bad_zip = tmp.path().join("bad.zip");
|
|
std::fs::write(&bad_zip, b"not a zip file").unwrap();
|
|
let dest = tmp.path().join("dest");
|
|
std::fs::create_dir_all(&dest).unwrap();
|
|
|
|
let result = extract_zip(&bad_zip, &dest);
|
|
assert!(result.is_err(), "should fail on invalid zip");
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// SidecarManager unit tests (no process spawning)
|
|
// -----------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn sidecar_manager_new_is_not_running() {
|
|
let mut mgr = SidecarManager::new();
|
|
assert!(!mgr.is_running());
|
|
assert!(mgr.port().is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn sidecar_manager_stop_when_not_running() {
|
|
let mut mgr = SidecarManager::new();
|
|
mgr.stop(); // should not panic
|
|
assert!(!mgr.is_running());
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// GiteaRelease / GiteaAsset deserialization
|
|
// -----------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn gitea_release_deserializes() {
|
|
let json = r#"{
|
|
"tag_name": "sidecar-v1.0.0",
|
|
"assets": [
|
|
{
|
|
"name": "sidecar-linux-x86_64-cuda.zip",
|
|
"browser_download_url": "https://example.com/file.zip",
|
|
"size": 12345
|
|
}
|
|
]
|
|
}"#;
|
|
let release: GiteaRelease = serde_json::from_str(json).unwrap();
|
|
assert_eq!(release.tag_name, "sidecar-v1.0.0");
|
|
assert_eq!(release.assets.len(), 1);
|
|
assert_eq!(release.assets[0].name, "sidecar-linux-x86_64-cuda.zip");
|
|
assert_eq!(release.assets[0].size, 12345);
|
|
}
|
|
|
|
#[test]
|
|
fn gitea_release_with_extra_fields() {
|
|
// Gitea responses include many more fields; serde should ignore them.
|
|
let json = r#"{
|
|
"id": 42,
|
|
"tag_name": "sidecar-v2.0.0",
|
|
"name": "Release 2.0.0",
|
|
"body": "changelog here",
|
|
"draft": false,
|
|
"prerelease": false,
|
|
"assets": []
|
|
}"#;
|
|
let release: GiteaRelease = serde_json::from_str(json).unwrap();
|
|
assert_eq!(release.tag_name, "sidecar-v2.0.0");
|
|
assert!(release.assets.is_empty());
|
|
}
|
|
|
|
// -----------------------------------------------------------------------
|
|
// DownloadProgress serialization round-trip
|
|
// -----------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn download_progress_serializes() {
|
|
let progress = DownloadProgress {
|
|
downloaded: 1024,
|
|
total: 4096,
|
|
phase: "downloading".into(),
|
|
message: "50%".into(),
|
|
};
|
|
let json = serde_json::to_string(&progress).unwrap();
|
|
assert!(json.contains("\"downloaded\":1024"));
|
|
assert!(json.contains("\"phase\":\"downloading\""));
|
|
}
|
|
}
|