Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -230,3 +230,28 @@ jobs:
prerelease: false
updaterJsonPreferNsis: false
includeUpdaterJson: true

# The installer above ships WITHOUT the PyInstaller backend because a
# single bundled asset (Linux .deb, Windows .msi) overshoots GH
# Releases' 2 GB per-asset cap. Package the backend into its own tar.gz
# and attach it to the same draft release; the Tauri app downloads and
# extracts it from app_local_data_dir on first launch (see lib.rs →
# ensure_backend_ready).
- name: Package backend tarball
shell: bash
run: |
VER="${GITHUB_REF_NAME#v}"
OUT="omnivoice-backend_${VER}_${{ matrix.rust_target }}.tar.gz"
tar -czf "$OUT" -C dist omnivoice-backend
ls -lah "$OUT"
echo "BACKEND_TARBALL=$OUT" >> "$GITHUB_ENV"

- name: Upload backend tarball to draft release
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh release upload "${{ github.ref_name }}" \
"$BACKEND_TARBALL" \
--repo "$GITHUB_REPOSITORY" \
--clobber
Comment on lines +240 to +257
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Guard release-upload steps to tag builds only.

These steps currently run for workflow_dispatch too. If no release exists for github.ref_name, Line 254 (gh release upload) fails and breaks manual runs.

Suggested fix
       - name: Package backend tarball
+        if: startsWith(github.ref, 'refs/tags/v')
         shell: bash
         run: |
           VER="${GITHUB_REF_NAME#v}"
           OUT="omnivoice-backend_${VER}_${{ matrix.rust_target }}.tar.gz"
           tar -czf "$OUT" -C dist omnivoice-backend
           ls -lah "$OUT"
           echo "BACKEND_TARBALL=$OUT" >> "$GITHUB_ENV"

       - name: Upload backend tarball to draft release
+        if: startsWith(github.ref, 'refs/tags/v')
         shell: bash
         env:
           GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/release.yml around lines 240 - 257, Guard the release
upload steps so they run only for tag builds: update the steps named "Package
backend tarball" / "Upload backend tarball to draft release" to check that the
workflow is running for a tag (e.g., use a conditional like
startsWith(github.ref, 'refs/tags/')) before creating BACKEND_TARBALL and
invoking gh release upload; ensure the upload step that calls gh release upload
"$BACKEND_TARBALL" is skipped for workflow_dispatch or non-tag refs to avoid
failing when no release exists.

8 changes: 8 additions & 0 deletions frontend/src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -28,5 +28,13 @@ tauri-plugin-window-state = "2.0.0"
tauri-plugin-updater = "2"
tauri-plugin-process = "2"

# First-run backend bootstrap: the PyInstaller backend (~1.5 GB) ships as a
# separate release asset instead of being bundled into the installer so we
# stay under GH Releases' 2 GB per-asset cap on Linux/Windows. ureq handles
# the HTTP GET, flate2 + tar do the extraction of the downloaded tarball.
ureq = "2"
tar = "0.4"
flate2 = "1"

[target.'cfg(unix)'.dependencies]
libc = "0.2"
157 changes: 146 additions & 11 deletions frontend/src-tauri/src/lib.rs
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};
Expand All @@ -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>>,
}
Expand Down Expand Up @@ -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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Restrict ../../dist probing to dev builds only.

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
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src-tauri/src/lib.rs` around lines 126 - 133, The code
unconditionally pushes PathBuf::from("../../dist") into roots (near the roots
variable and exe_name usage), causing release builds to probe a dev-relative
binary; restrict that push to dev/debug builds by wrapping it in a build-config
check (e.g., if cfg!(debug_assertions) {
roots.push(PathBuf::from("../../dist")); }) or use an equivalent
#[cfg(debug_assertions)] guard so only non-release builds include the
"../../dist" candidate.

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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

🧩 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.rs

Repository: 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.yml

Repository: 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 toml

Repository: 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 2

Repository: 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/ -i

Repository: 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 50

Repository: 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 10

Repository: 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/ -i

Repository: 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
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src-tauri/src/lib.rs` around lines 187 - 203, The download path
currently reads resp into reader and directly creates GzDecoder/Archive and
calls archive.unpack(&dest_root); add an integrity check step before extracting:
read the response bytes (or stream to a temp file) instead of piping straight
into GzDecoder, compute and verify a checksum (e.g., SHA-256) or verify a
cryptographic signature with the project's public key against a bundled/remote
expected value, and only proceed to create flate2::read::GzDecoder::new(...) and
call archive.unpack(&dest_root) if the verification succeeds; ensure you update
the variables referenced here (url, resp, reader, gz, archive.unpack) to use the
verified temp file/byte buffer and return an io::Error when verification fails.

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> {
Expand Down Expand Up @@ -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");
Expand Down
4 changes: 1 addition & 3 deletions frontend/src-tauri/tauri.conf.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,7 @@
"icons/icon.icns",
"icons/icon.ico"
],
"resources": {
"../../dist/omnivoice-backend": "backend/omnivoice-backend"
},
"resources": {},
"macOS": {
"minimumSystemVersion": "12.0"
}
Expand Down
Loading