-
Notifications
You must be signed in to change notification settings - Fork 466
feat(release): split backend out of installer (download on first run, ~1.8GB → ~50MB) #73
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,4 +1,5 @@ | ||
| use std::fs; | ||
| use std::io::{self, Read}; | ||
| use std::net::TcpStream; | ||
| use std::path::PathBuf; | ||
| use std::process::{Child, Command, Stdio}; | ||
|
|
@@ -8,6 +9,10 @@ use tauri::Manager; | |
|
|
||
| const BACKEND_PORT: u16 = 8000; | ||
|
|
||
| // GH Releases uploader repo. Used to construct the tarball download URL for | ||
| // the first-run bootstrap when the installer ships without a bundled backend. | ||
| const BACKEND_RELEASE_REPO: &str = "debpalash/OmniVoice-Studio"; | ||
|
|
||
| pub struct BackendState { | ||
| pub process: Mutex<Option<Child>>, | ||
| } | ||
|
|
@@ -105,23 +110,145 @@ fn kill_orphan_on_port(_port: u16) {} | |
|
|
||
| // ── Backend path resolution ─────────────────────────────────────────────── | ||
|
|
||
| /// Look for a frozen PyInstaller bundle shipped as a Tauri resource. | ||
| /// In a packaged .app: `Contents/Resources/backend/omnivoice-backend/omnivoice-backend`. | ||
| /// Look for a frozen PyInstaller bundle in three places, in priority order: | ||
| /// 1. Tauri resource dir (legacy path: backend bundled into the installer). | ||
| /// 2. Per-user app data dir (new path: backend downloaded on first run). | ||
| /// 3. Dev fallback (`../../dist/omnivoice-backend/`) — PyInstaller local run. | ||
| fn find_bundled_backend<R: tauri::Runtime>(app: &tauri::App<R>) -> Option<PathBuf> { | ||
| let resource_dir = app.path().resource_dir().ok()?; | ||
| let candidates = [ | ||
| resource_dir.join("backend/omnivoice-backend/omnivoice-backend"), | ||
| resource_dir.join("backend/omnivoice-backend"), | ||
| resource_dir.join("omnivoice-backend"), | ||
| ]; | ||
| for c in &candidates { | ||
| if c.is_file() { | ||
| return Some(c.clone()); | ||
| let exe_name = backend_exe_name(); | ||
| let mut roots: Vec<PathBuf> = Vec::new(); | ||
| if let Ok(d) = app.path().resource_dir() { | ||
| roots.push(d); | ||
| } | ||
| if let Ok(d) = app.path().app_local_data_dir() { | ||
| roots.push(d); | ||
| } | ||
| roots.push(PathBuf::from("../../dist")); | ||
| for root in roots { | ||
| let candidates = [ | ||
| root.join(format!("backend/omnivoice-backend/{}", exe_name)), | ||
| root.join("backend/omnivoice-backend").join(&exe_name), | ||
| root.join("omnivoice-backend").join(&exe_name), | ||
| root.join(format!("omnivoice-backend/{}", exe_name)), | ||
| ]; | ||
|
Comment on lines
+126
to
+133
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Restrict Including this path unconditionally lets release builds pick up a relative dev binary if present, which is risky and can bypass intended bootstrap behavior. Suggested fix- roots.push(PathBuf::from("../../dist"));
+ if cfg!(debug_assertions) {
+ roots.push(PathBuf::from("../../dist"));
+ }🤖 Prompt for AI Agents |
||
| for c in &candidates { | ||
| if c.is_file() { | ||
| return Some(c.clone()); | ||
| } | ||
| } | ||
| } | ||
| None | ||
| } | ||
|
|
||
| fn backend_exe_name() -> String { | ||
| if cfg!(windows) { | ||
| "omnivoice-backend.exe".into() | ||
| } else { | ||
| "omnivoice-backend".into() | ||
| } | ||
| } | ||
|
|
||
| // ── First-run backend bootstrap (download + extract) ────────────────────── | ||
|
|
||
| /// Triple that matches the release-asset naming used by release.yml — see | ||
| /// the `package-backend-tarball` step. Kept in lock-step with that | ||
| /// matrix.rust_target value. | ||
| fn release_asset_triple() -> Option<&'static str> { | ||
| match (std::env::consts::OS, std::env::consts::ARCH) { | ||
| ("macos", "aarch64") => Some("aarch64-apple-darwin"), | ||
| ("macos", "x86_64") => Some("x86_64-apple-darwin"), | ||
| ("linux", "x86_64") => Some("x86_64-unknown-linux-gnu"), | ||
| ("windows", "x86_64") => Some("x86_64-pc-windows-msvc"), | ||
| _ => None, | ||
| } | ||
| } | ||
|
|
||
| fn backend_download_url() -> Option<String> { | ||
| let triple = release_asset_triple()?; | ||
| let version = env!("CARGO_PKG_VERSION"); | ||
| Some(format!( | ||
| "https://github.com/{}/releases/download/v{}/omnivoice-backend_{}_{}.tar.gz", | ||
| BACKEND_RELEASE_REPO, version, version, triple | ||
| )) | ||
| } | ||
|
|
||
| /// Download the backend tarball and extract it under the per-user app-local | ||
| /// data dir. Blocks the caller. Returns the path where the backend was | ||
| /// extracted (parent of the omnivoice-backend directory) on success. | ||
| fn download_and_extract_backend<R: tauri::Runtime>(app: &tauri::App<R>) -> io::Result<PathBuf> { | ||
| let url = backend_download_url() | ||
| .ok_or_else(|| io::Error::new(io::ErrorKind::Unsupported, "no release asset for this platform"))?; | ||
| let dest_root = app | ||
| .path() | ||
| .app_local_data_dir() | ||
| .map_err(|e| io::Error::new(io::ErrorKind::Other, e.to_string()))?; | ||
| fs::create_dir_all(&dest_root)?; | ||
|
|
||
| log::info!("Fetching backend tarball: {}", url); | ||
| let resp = ureq::get(&url) | ||
| .timeout(Duration::from_secs(60 * 30)) | ||
| .call() | ||
| .map_err(|e| io::Error::new(io::ErrorKind::Other, format!("download: {}", e)))?; | ||
| if resp.status() != 200 { | ||
| return Err(io::Error::new( | ||
| io::ErrorKind::Other, | ||
| format!("download HTTP {} from {}", resp.status(), url), | ||
| )); | ||
| } | ||
|
|
||
| let mut reader = resp.into_reader(); | ||
| let gz = flate2::read::GzDecoder::new(LogReader::new(&mut reader)); | ||
| let mut archive = tar::Archive::new(gz); | ||
| archive.unpack(&dest_root)?; | ||
| log::info!("Backend extracted under {}", dest_root.display()); | ||
|
Comment on lines
+187
to
+203
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -euo pipefail
echo "== Release workflow: checksum/signature generation =="
rg -n "sha256|checksum|minisign|cosign|\\.sig|signature|digest" .github/workflows/release.yml
echo
echo "== Bootstrap code: checksum/signature verification =="
rg -n "sha256|checksum|minisign|cosign|verify|signature|digest" frontend/src-tauri/src/lib.rsRepository: debpalash/OmniVoice-Studio Length of output: 249 🏁 Script executed: # Get context around the signature mention in release.yml
sed -n '200,220p' .github/workflows/release.ymlRepository: debpalash/OmniVoice-Studio Length of output: 1032 🏁 Script executed: # Search for latest.json usage in the codebase
rg -n "latest\.json" --type yaml --type ts --type rs --type tomlRepository: debpalash/OmniVoice-Studio Length of output: 97 🏁 Script executed: # Look for where the downloaded backend is executed
rg -n "execute|run|spawn|Command" frontend/src-tauri/src/lib.rs -A 2 -B 2Repository: debpalash/OmniVoice-Studio Length of output: 6369 🏁 Script executed: # Check if there's any verification function in the codebase
rg -n "fn.*verif|fn.*check.*sig|fn.*validate" frontend/src-tauri/src/ -iRepository: debpalash/OmniVoice-Studio Length of output: 52 🏁 Script executed: # Find and show the full download_and_extract_backend function
rg -n "fn download_and_extract_backend" frontend/src-tauri/src/lib.rs -A 50Repository: debpalash/OmniVoice-Studio Length of output: 2138 🏁 Script executed: # Search for latest.json usage in bootstrap/download code
rg -n "latest\.json" frontend/src-tauri/src/Repository: debpalash/OmniVoice-Studio Length of output: 52 🏁 Script executed: # Check the full context of the download function - look for any hash/signature variables
rg -n "download_and_extract_backend|tarball|backend.*url" frontend/src-tauri/src/lib.rs -B 2 -A 10Repository: debpalash/OmniVoice-Studio Length of output: 3102 🏁 Script executed: # Search entire codebase for any verification of downloaded files
rg -n "verify|signature|digest|checksum|sha" frontend/src-tauri/src/ -iRepository: debpalash/OmniVoice-Studio Length of output: 52 Add integrity verification before extracting/executing downloaded backend. The app downloads and executes an untrusted backend tarball on first run, but provides only HTTP 200 status verification. There is no checksum or signature verification in this path, weakening supply-chain guarantees for the bootstrap flow. The backend URL is hardcoded and downloaded directly from GitHub releases (lines 179–197), then immediately extracted (line 202) and executed (line 347) without any integrity checks. The Tauri updater has signature support, but that is not wired for the backend tarball. 🤖 Prompt for AI Agents |
||
| Ok(dest_root) | ||
| } | ||
|
|
||
| /// Wraps a Read impl and logs progress every ~64 MB. Gives the user some | ||
| /// feedback in the tauri log while the download runs. | ||
| struct LogReader<'a, R: Read> { | ||
| inner: &'a mut R, | ||
| so_far: u64, | ||
| next_log: u64, | ||
| } | ||
|
|
||
| impl<'a, R: Read> LogReader<'a, R> { | ||
| fn new(inner: &'a mut R) -> Self { | ||
| Self { inner, so_far: 0, next_log: 64 * 1024 * 1024 } | ||
| } | ||
| } | ||
|
|
||
| impl<'a, R: Read> Read for LogReader<'a, R> { | ||
| fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> { | ||
| let n = self.inner.read(buf)?; | ||
| self.so_far += n as u64; | ||
| if self.so_far >= self.next_log { | ||
| log::info!("Backend download: {} MB received", self.so_far / (1024 * 1024)); | ||
| self.next_log += 64 * 1024 * 1024; | ||
| } | ||
| Ok(n) | ||
| } | ||
| } | ||
|
|
||
| fn ensure_backend_ready<R: tauri::Runtime>(app: &tauri::App<R>) -> bool { | ||
| if find_bundled_backend(app).is_some() { | ||
| return true; | ||
| } | ||
| if release_asset_triple().is_none() { | ||
| log::warn!("No release asset known for this platform; skipping auto-download."); | ||
| return false; | ||
| } | ||
| log::info!("Backend not found locally — starting first-run download."); | ||
| match download_and_extract_backend(app) { | ||
| Ok(_) => find_bundled_backend(app).is_some(), | ||
| Err(e) => { | ||
| log::error!("Backend auto-download failed: {}", e); | ||
| false | ||
| } | ||
| } | ||
| } | ||
|
|
||
|
|
||
| /// Dev-mode fallback: running from the source tree (`bun run dev`). | ||
| /// Locate `backend/main.py` so we can launch via `uv run uvicorn`. | ||
| fn find_dev_project_root() -> Option<PathBuf> { | ||
|
|
@@ -310,6 +437,14 @@ pub fn run() { | |
| // `bun run tauri dev`. | ||
| // 4. Otherwise → spawn (kill orphan first if port held by corpse). | ||
| let skip_spawn = std::env::var("TAURI_SKIP_BACKEND").is_ok(); | ||
| if !skip_spawn { | ||
| // First-run bootstrap: the installer ships without the | ||
| // PyInstaller backend so it fits under GH Releases' 2 GB | ||
| // per-asset cap. Download + extract it into the per-user | ||
| // app data dir if we don't have it yet. Blocking — the | ||
| // webview stays on the initial loader until this resolves. | ||
| let _ = ensure_backend_ready(app); | ||
| } | ||
| let has_bundled = find_bundled_backend(app).is_some(); | ||
| let child = if skip_spawn { | ||
| log::info!("TAURI_SKIP_BACKEND set — not spawning"); | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Guard release-upload steps to tag builds only.
These steps currently run for
workflow_dispatchtoo. If no release exists forgithub.ref_name, Line 254 (gh release upload) fails and breaks manual runs.Suggested fix
🤖 Prompt for AI Agents