Download sidecar on first launch instead of bundling
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:
@@ -112,13 +112,23 @@ jobs:
|
||||
- name: Set up Python
|
||||
run: uv python install ${{ env.PYTHON_VERSION }}
|
||||
|
||||
- name: Build sidecar
|
||||
- name: Build sidecar (CUDA)
|
||||
working-directory: python
|
||||
run: uv run --python ${{ env.PYTHON_VERSION }} python build_sidecar.py --with-cuda
|
||||
|
||||
- name: Package sidecar for Tauri
|
||||
- name: Package sidecar (CUDA)
|
||||
run: |
|
||||
cd python/dist/voice-to-notes-sidecar && zip -r ../../../src-tauri/sidecar.zip .
|
||||
cd python/dist/voice-to-notes-sidecar && zip -r ../../../sidecar-linux-x86_64-cuda.zip .
|
||||
|
||||
- name: Build sidecar (CPU)
|
||||
working-directory: python
|
||||
run: |
|
||||
rm -rf dist/voice-to-notes-sidecar
|
||||
uv run --python ${{ env.PYTHON_VERSION }} python build_sidecar.py --cpu-only
|
||||
|
||||
- name: Package sidecar (CPU)
|
||||
run: |
|
||||
cd python/dist/voice-to-notes-sidecar && zip -r ../../../sidecar-linux-x86_64-cpu.zip .
|
||||
|
||||
# ── Tauri app ──
|
||||
- name: Set up Node.js
|
||||
@@ -183,6 +193,26 @@ jobs:
|
||||
echo "Upload response: HTTP ${HTTP_CODE}"
|
||||
done
|
||||
|
||||
for file in sidecar-*.zip; do
|
||||
filename=$(basename "$file")
|
||||
encoded_name=$(echo "$filename" | sed 's/ /%20/g')
|
||||
echo "Uploading ${filename} ($(du -h "$file" | cut -f1))..."
|
||||
|
||||
ASSET_ID=$(curl -s -H "Authorization: token ${BUILD_TOKEN}" \
|
||||
"${REPO_API}/releases/${RELEASE_ID}/assets" | jq -r ".[] | select(.name == \"${filename}\") | .id // empty")
|
||||
if [ -n "${ASSET_ID}" ]; then
|
||||
curl -s -X DELETE -H "Authorization: token ${BUILD_TOKEN}" \
|
||||
"${REPO_API}/releases/${RELEASE_ID}/assets/${ASSET_ID}"
|
||||
fi
|
||||
|
||||
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" -X POST \
|
||||
-H "Authorization: token ${BUILD_TOKEN}" \
|
||||
-H "Content-Type: application/octet-stream" \
|
||||
-T "$file" \
|
||||
"${REPO_API}/releases/${RELEASE_ID}/assets?name=${encoded_name}")
|
||||
echo "Upload response: HTTP ${HTTP_CODE}"
|
||||
done
|
||||
|
||||
build-windows:
|
||||
name: Build (Windows)
|
||||
needs: bump-version
|
||||
@@ -214,19 +244,34 @@ jobs:
|
||||
shell: powershell
|
||||
run: uv python install ${{ env.PYTHON_VERSION }}
|
||||
|
||||
- name: Build sidecar
|
||||
- name: Install 7-Zip
|
||||
shell: powershell
|
||||
run: |
|
||||
if (-not (Get-Command 7z -ErrorAction SilentlyContinue)) {
|
||||
choco install 7zip -y
|
||||
}
|
||||
|
||||
- name: Build sidecar (CUDA)
|
||||
shell: powershell
|
||||
working-directory: python
|
||||
run: uv run --python ${{ env.PYTHON_VERSION }} python build_sidecar.py --with-cuda
|
||||
|
||||
- name: Package sidecar for Tauri
|
||||
- name: Package sidecar (CUDA)
|
||||
shell: powershell
|
||||
run: |
|
||||
# Compress-Archive has a 2GB limit; use 7z for CUDA builds
|
||||
if (-not (Get-Command 7z -ErrorAction SilentlyContinue)) {
|
||||
choco install 7zip -y
|
||||
}
|
||||
7z a -tzip -mx=5 src-tauri\sidecar.zip .\python\dist\voice-to-notes-sidecar\*
|
||||
7z a -tzip -mx=5 sidecar-windows-x86_64-cuda.zip .\python\dist\voice-to-notes-sidecar\*
|
||||
|
||||
- name: Build sidecar (CPU)
|
||||
shell: powershell
|
||||
working-directory: python
|
||||
run: |
|
||||
Remove-Item -Recurse -Force dist\voice-to-notes-sidecar
|
||||
uv run --python ${{ env.PYTHON_VERSION }} python build_sidecar.py --cpu-only
|
||||
|
||||
- name: Package sidecar (CPU)
|
||||
shell: powershell
|
||||
run: |
|
||||
7z a -tzip -mx=5 sidecar-windows-x86_64-cpu.zip .\python\dist\voice-to-notes-sidecar\*
|
||||
|
||||
# ── Tauri app ──
|
||||
- name: Set up Node.js
|
||||
@@ -299,6 +344,34 @@ jobs:
|
||||
}
|
||||
}
|
||||
|
||||
Get-ChildItem -Path . -Filter "sidecar-*.zip" | ForEach-Object {
|
||||
$filename = $_.Name
|
||||
$encodedName = [System.Uri]::EscapeDataString($filename)
|
||||
$size = [math]::Round($_.Length / 1MB, 1)
|
||||
Write-Host "Uploading ${filename} (${size} MB)..."
|
||||
|
||||
try {
|
||||
$assets = Invoke-RestMethod -Uri "${REPO_API}/releases/${RELEASE_ID}/assets" -Headers $Headers
|
||||
$existing = $assets | Where-Object { $_.name -eq $filename }
|
||||
if ($existing) {
|
||||
Invoke-RestMethod -Uri "${REPO_API}/releases/${RELEASE_ID}/assets/$($existing.id)" -Method Delete -Headers $Headers
|
||||
}
|
||||
} catch {}
|
||||
|
||||
$uploadUrl = "${REPO_API}/releases/${RELEASE_ID}/assets?name=${encodedName}"
|
||||
$result = curl.exe --fail --silent --show-error `
|
||||
-X POST `
|
||||
-H "Authorization: token $env:BUILD_TOKEN" `
|
||||
-H "Content-Type: application/octet-stream" `
|
||||
-T "$($_.FullName)" `
|
||||
"$uploadUrl" 2>&1
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
Write-Host "Upload successful: ${filename}"
|
||||
} else {
|
||||
Write-Host "WARNING: Upload failed for ${filename}: ${result}"
|
||||
}
|
||||
}
|
||||
|
||||
build-macos:
|
||||
name: Build (macOS)
|
||||
needs: bump-version
|
||||
@@ -327,13 +400,13 @@ jobs:
|
||||
- name: Set up Python
|
||||
run: uv python install ${{ env.PYTHON_VERSION }}
|
||||
|
||||
- name: Build sidecar
|
||||
- name: Build sidecar (CPU)
|
||||
working-directory: python
|
||||
run: uv run --python ${{ env.PYTHON_VERSION }} python build_sidecar.py --cpu-only
|
||||
|
||||
- name: Package sidecar for Tauri
|
||||
- name: Package sidecar (CPU)
|
||||
run: |
|
||||
cd python/dist/voice-to-notes-sidecar && zip -r ../../../src-tauri/sidecar.zip .
|
||||
cd python/dist/voice-to-notes-sidecar && zip -r ../../../sidecar-macos-aarch64-cpu.zip .
|
||||
|
||||
# ── Tauri app ──
|
||||
- name: Set up Node.js
|
||||
@@ -397,3 +470,23 @@ jobs:
|
||||
"${REPO_API}/releases/${RELEASE_ID}/assets?name=${encoded_name}")
|
||||
echo "Upload response: HTTP ${HTTP_CODE}"
|
||||
done
|
||||
|
||||
for file in sidecar-*.zip; do
|
||||
filename=$(basename "$file")
|
||||
encoded_name=$(echo "$filename" | sed 's/ /%20/g')
|
||||
echo "Uploading ${filename} ($(du -h "$file" | cut -f1))..."
|
||||
|
||||
ASSET_ID=$(curl -s -H "Authorization: token ${BUILD_TOKEN}" \
|
||||
"${REPO_API}/releases/${RELEASE_ID}/assets" | jq -r ".[] | select(.name == \"${filename}\") | .id // empty")
|
||||
if [ -n "${ASSET_ID}" ]; then
|
||||
curl -s -X DELETE -H "Authorization: token ${BUILD_TOKEN}" \
|
||||
"${REPO_API}/releases/${RELEASE_ID}/assets/${ASSET_ID}"
|
||||
fi
|
||||
|
||||
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" -X POST \
|
||||
-H "Authorization: token ${BUILD_TOKEN}" \
|
||||
-H "Content-Type: application/octet-stream" \
|
||||
-T "$file" \
|
||||
"${REPO_API}/releases/${RELEASE_ID}/assets?name=${encoded_name}")
|
||||
echo "Upload response: HTTP ${HTTP_CODE}"
|
||||
done
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "voice-to-notes",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.10",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "voice-to-notes",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.10",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2",
|
||||
|
||||
52
src-tauri/Cargo.lock
generated
52
src-tauri/Cargo.lock
generated
@@ -59,6 +59,15 @@ version = "1.0.102"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
||||
|
||||
[[package]]
|
||||
name = "arbitrary"
|
||||
version = "1.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
|
||||
dependencies = [
|
||||
"derive_arbitrary",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-broadcast"
|
||||
version = "0.7.2"
|
||||
@@ -655,6 +664,17 @@ dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_arbitrary"
|
||||
version = "1.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_more"
|
||||
version = "0.99.20"
|
||||
@@ -4362,7 +4382,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||
|
||||
[[package]]
|
||||
name = "voice-to-notes"
|
||||
version = "0.1.0"
|
||||
version = "0.2.2"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"rusqlite",
|
||||
@@ -4374,6 +4394,7 @@ dependencies = [
|
||||
"tauri-plugin-opener",
|
||||
"thiserror 1.0.69",
|
||||
"uuid",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5412,12 +5433,41 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zip"
|
||||
version = "2.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50"
|
||||
dependencies = [
|
||||
"arbitrary",
|
||||
"crc32fast",
|
||||
"crossbeam-utils",
|
||||
"displaydoc",
|
||||
"flate2",
|
||||
"indexmap 2.13.0",
|
||||
"memchr",
|
||||
"thiserror 2.0.18",
|
||||
"zopfli",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
|
||||
|
||||
[[package]]
|
||||
name = "zopfli"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"crc32fast",
|
||||
"log",
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zvariant"
|
||||
version = "5.10.0"
|
||||
|
||||
@@ -24,3 +24,5 @@ zip = { version = "2", default-features = false, features = ["deflate"] }
|
||||
thiserror = "1"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
tauri-plugin-dialog = "2.6.0"
|
||||
reqwest = { version = "0.12", features = ["stream"] }
|
||||
futures-util = "0.3"
|
||||
|
||||
@@ -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;
|
||||
|
||||
211
src-tauri/src/commands/sidecar.rs
Normal file
211
src-tauri/src/commands/sidecar.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": ["deb", "rpm", "msi", "dmg"],
|
||||
"targets": ["deb", "rpm", "nsis", "dmg"],
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
@@ -42,7 +42,7 @@
|
||||
"category": "Utility",
|
||||
"shortDescription": "Transcribe audio/video with speaker identification",
|
||||
"longDescription": "Voice to Notes is a desktop application that transcribes audio and video recordings with speaker identification, synchronized playback, and AI-powered analysis. Export to SRT, WebVTT, ASS captions, or plain text.",
|
||||
"resources": ["sidecar.zip"],
|
||||
"resources": [],
|
||||
"copyright": "Voice to Notes Contributors",
|
||||
"license": "MIT",
|
||||
"linux": {
|
||||
|
||||
320
src/lib/components/SidecarSetup.svelte
Normal file
320
src/lib/components/SidecarSetup.svelte
Normal file
@@ -0,0 +1,320 @@
|
||||
<script lang="ts">
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
import type { UnlistenFn } from '@tauri-apps/api/event';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
onComplete: () => void;
|
||||
}
|
||||
|
||||
let { onComplete }: Props = $props();
|
||||
|
||||
let variant = $state<'cpu' | 'cuda'>('cpu');
|
||||
let downloading = $state(false);
|
||||
let downloadProgress = $state({ downloaded: 0, total: 0, percent: 0 });
|
||||
let error = $state('');
|
||||
let success = $state(false);
|
||||
|
||||
let unlisten: UnlistenFn | null = null;
|
||||
|
||||
onMount(() => {
|
||||
return () => {
|
||||
unlisten?.();
|
||||
};
|
||||
});
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
|
||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(0)} MB`;
|
||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
|
||||
}
|
||||
|
||||
async function startDownload() {
|
||||
downloading = true;
|
||||
error = '';
|
||||
success = false;
|
||||
|
||||
unlisten = await listen<{ downloaded: number; total: number; percent: number }>(
|
||||
'sidecar-download-progress',
|
||||
(event) => {
|
||||
downloadProgress = event.payload;
|
||||
}
|
||||
);
|
||||
|
||||
try {
|
||||
await invoke('download_sidecar', { variant });
|
||||
success = true;
|
||||
// Brief pause so the user sees "Complete" before the screen goes away
|
||||
setTimeout(() => {
|
||||
onComplete();
|
||||
}, 800);
|
||||
} catch (err) {
|
||||
error = String(err);
|
||||
} finally {
|
||||
downloading = false;
|
||||
unlisten?.();
|
||||
unlisten = null;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="setup-overlay">
|
||||
<div class="setup-card">
|
||||
<h1 class="app-title">Voice to Notes</h1>
|
||||
<h2 class="setup-heading">First-Time Setup</h2>
|
||||
<p class="setup-description">
|
||||
Voice to Notes needs to download its AI engine to transcribe audio.
|
||||
</p>
|
||||
|
||||
{#if !downloading && !success}
|
||||
<div class="variant-options">
|
||||
<label class="variant-option" class:selected={variant === 'cpu'}>
|
||||
<input type="radio" name="variant" value="cpu" bind:group={variant} />
|
||||
<div class="variant-info">
|
||||
<span class="variant-label">Standard (CPU)</span>
|
||||
<span class="variant-desc">Works on all computers (~500 MB download)</span>
|
||||
</div>
|
||||
</label>
|
||||
<label class="variant-option" class:selected={variant === 'cuda'}>
|
||||
<input type="radio" name="variant" value="cuda" bind:group={variant} />
|
||||
<div class="variant-info">
|
||||
<span class="variant-label">GPU Accelerated (CUDA)</span>
|
||||
<span class="variant-desc">Faster transcription with NVIDIA GPU (~2 GB download)</span>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<div class="error-box">
|
||||
<p class="error-text">{error}</p>
|
||||
<button class="btn-retry" onclick={startDownload}>Retry</button>
|
||||
</div>
|
||||
{:else}
|
||||
<button class="btn-download" onclick={startDownload}>
|
||||
Download & Install
|
||||
</button>
|
||||
{/if}
|
||||
{:else if downloading}
|
||||
<div class="progress-section">
|
||||
<div class="progress-bar-track">
|
||||
<div class="progress-bar-fill" style="width: {downloadProgress.percent}%"></div>
|
||||
</div>
|
||||
<p class="progress-text">
|
||||
{downloadProgress.percent}% — {formatBytes(downloadProgress.downloaded)} / {formatBytes(downloadProgress.total)}
|
||||
</p>
|
||||
<p class="progress-hint">Downloading {variant === 'cuda' ? 'GPU' : 'CPU'} engine...</p>
|
||||
</div>
|
||||
{:else if success}
|
||||
<div class="success-section">
|
||||
<div class="success-icon">✓</div>
|
||||
<p class="success-text">Setup complete!</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.setup-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: #0a0a23;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10000;
|
||||
}
|
||||
|
||||
.setup-card {
|
||||
background: #16213e;
|
||||
border: 1px solid #2a3a5e;
|
||||
border-radius: 12px;
|
||||
padding: 2.5rem 3rem;
|
||||
max-width: 480px;
|
||||
width: 90vw;
|
||||
color: #e0e0e0;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.app-title {
|
||||
font-size: 1.8rem;
|
||||
margin: 0 0 0.25rem;
|
||||
color: #e94560;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.setup-heading {
|
||||
font-size: 1.1rem;
|
||||
margin: 0 0 0.75rem;
|
||||
color: #e0e0e0;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.setup-description {
|
||||
font-size: 0.9rem;
|
||||
color: #b0b0b0;
|
||||
margin: 0 0 1.5rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.variant-options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1.5rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.variant-option {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
padding: 0.85rem 1rem;
|
||||
border: 1px solid #2a3a5e;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
}
|
||||
|
||||
.variant-option:hover {
|
||||
border-color: #4a5568;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
|
||||
.variant-option.selected {
|
||||
border-color: #e94560;
|
||||
background: rgba(233, 69, 96, 0.08);
|
||||
}
|
||||
|
||||
.variant-option input[type='radio'] {
|
||||
margin-top: 0.2rem;
|
||||
accent-color: #e94560;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.variant-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.variant-label {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.variant-desc {
|
||||
font-size: 0.78rem;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.btn-download {
|
||||
background: #e94560;
|
||||
border: none;
|
||||
color: white;
|
||||
padding: 0.7rem 1.5rem;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
width: 100%;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.btn-download:hover {
|
||||
background: #d63851;
|
||||
}
|
||||
|
||||
.progress-section {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.progress-bar-track {
|
||||
width: 100%;
|
||||
height: 8px;
|
||||
background: #1a1a2e;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
border: 1px solid #2a3a5e;
|
||||
}
|
||||
|
||||
.progress-bar-fill {
|
||||
height: 100%;
|
||||
background: #e94560;
|
||||
border-radius: 4px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.progress-text {
|
||||
margin: 0.75rem 0 0;
|
||||
font-size: 0.85rem;
|
||||
color: #e0e0e0;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.progress-hint {
|
||||
margin: 0.35rem 0 0;
|
||||
font-size: 0.78rem;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.error-box {
|
||||
background: rgba(233, 69, 96, 0.1);
|
||||
border: 1px solid rgba(233, 69, 96, 0.3);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.error-text {
|
||||
color: #e94560;
|
||||
font-size: 0.85rem;
|
||||
margin: 0 0 0.75rem;
|
||||
word-break: break-word;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.btn-retry {
|
||||
background: #e94560;
|
||||
border: none;
|
||||
color: white;
|
||||
padding: 0.5rem 1.25rem;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.btn-retry:hover {
|
||||
background: #d63851;
|
||||
}
|
||||
|
||||
.success-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 1rem 0;
|
||||
}
|
||||
|
||||
.success-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
background: rgba(78, 205, 196, 0.15);
|
||||
color: #4ecdc4;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.success-text {
|
||||
color: #4ecdc4;
|
||||
font-size: 1rem;
|
||||
margin: 0;
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
@@ -8,6 +8,7 @@
|
||||
import AIChatPanel from '$lib/components/AIChatPanel.svelte';
|
||||
import ProgressOverlay from '$lib/components/ProgressOverlay.svelte';
|
||||
import SettingsModal from '$lib/components/SettingsModal.svelte';
|
||||
import SidecarSetup from '$lib/components/SidecarSetup.svelte';
|
||||
import { segments, speakers } from '$lib/stores/transcript';
|
||||
import { settings, loadSettings } from '$lib/stores/settings';
|
||||
import type { Segment, Speaker } from '$lib/types/transcript';
|
||||
@@ -18,13 +19,56 @@
|
||||
let audioUrl = $state('');
|
||||
let showSettings = $state(false);
|
||||
|
||||
// Sidecar state
|
||||
let sidecarReady = $state(false);
|
||||
let sidecarChecked = $state(false);
|
||||
|
||||
// Sidecar update state
|
||||
let sidecarUpdate = $state<{ current_version: string; latest_version: string } | null>(null);
|
||||
let showUpdateDownload = $state(false);
|
||||
let updateDismissed = $state(false);
|
||||
|
||||
// Project management state
|
||||
let currentProjectPath = $state<string | null>(null);
|
||||
let currentProjectName = $state('');
|
||||
let audioFilePath = $state('');
|
||||
|
||||
async function checkSidecar() {
|
||||
try {
|
||||
const ready = await invoke<boolean>('check_sidecar');
|
||||
sidecarReady = ready;
|
||||
} catch {
|
||||
sidecarReady = false;
|
||||
}
|
||||
sidecarChecked = true;
|
||||
}
|
||||
|
||||
async function checkSidecarUpdate() {
|
||||
try {
|
||||
const update = await invoke<{ current_version: string; latest_version: string } | null>('check_sidecar_update');
|
||||
sidecarUpdate = update;
|
||||
} catch {
|
||||
// Silently ignore update check failures
|
||||
}
|
||||
}
|
||||
|
||||
function handleSidecarSetupComplete() {
|
||||
sidecarReady = true;
|
||||
checkSidecarUpdate();
|
||||
}
|
||||
|
||||
function handleUpdateComplete() {
|
||||
showUpdateDownload = false;
|
||||
sidecarUpdate = null;
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
loadSettings();
|
||||
checkSidecar().then(() => {
|
||||
if (sidecarReady) {
|
||||
checkSidecarUpdate();
|
||||
}
|
||||
});
|
||||
|
||||
// Global keyboard shortcuts
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
@@ -443,14 +487,31 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if !appReady}
|
||||
{#if !appReady || !sidecarChecked}
|
||||
<div class="splash-screen">
|
||||
<h1 class="splash-title">Voice to Notes</h1>
|
||||
<p class="splash-subtitle">Loading...</p>
|
||||
<div class="splash-spinner"></div>
|
||||
</div>
|
||||
{:else if sidecarChecked && !sidecarReady && !showUpdateDownload}
|
||||
<SidecarSetup onComplete={handleSidecarSetupComplete} />
|
||||
{:else if showUpdateDownload}
|
||||
<SidecarSetup onComplete={handleUpdateComplete} />
|
||||
{:else}
|
||||
<div class="app-shell">
|
||||
{#if sidecarUpdate && !updateDismissed}
|
||||
<div class="update-banner">
|
||||
<span class="update-text">
|
||||
Sidecar update available (v{sidecarUpdate.current_version} → v{sidecarUpdate.latest_version})
|
||||
</span>
|
||||
<button class="update-btn" onclick={() => showUpdateDownload = true}>
|
||||
Update
|
||||
</button>
|
||||
<button class="update-dismiss" onclick={() => updateDismissed = true} title="Dismiss">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="app-header">
|
||||
<div class="header-actions">
|
||||
<button class="settings-btn" onclick={openProject} disabled={isTranscribing}>
|
||||
@@ -674,4 +735,45 @@
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Sidecar update banner */
|
||||
.update-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.5rem 1rem;
|
||||
background: rgba(78, 205, 196, 0.1);
|
||||
border-bottom: 1px solid rgba(78, 205, 196, 0.25);
|
||||
color: #e0e0e0;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.update-text {
|
||||
flex: 1;
|
||||
color: #b0b0b0;
|
||||
}
|
||||
.update-btn {
|
||||
background: #4ecdc4;
|
||||
border: none;
|
||||
color: #0a0a23;
|
||||
padding: 0.3rem 0.85rem;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.update-btn:hover {
|
||||
background: #3dbdb5;
|
||||
}
|
||||
.update-dismiss {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #888;
|
||||
font-size: 1.1rem;
|
||||
cursor: pointer;
|
||||
padding: 0.1rem 0.3rem;
|
||||
line-height: 1;
|
||||
}
|
||||
.update-dismiss:hover {
|
||||
color: #e0e0e0;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user