From 7fa903ad0114433c560aa436427dbe34b929ab5f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 22 Mar 2026 07:09:08 -0700 Subject: [PATCH] Download sidecar on first launch instead of bundling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .gitea/workflows/release.yml | 119 ++++++++- package-lock.json | 4 +- src-tauri/Cargo.lock | 52 +++- src-tauri/Cargo.toml | 2 + src-tauri/src/commands/mod.rs | 1 + src-tauri/src/commands/sidecar.rs | 211 ++++++++++++++++ src-tauri/src/lib.rs | 4 + src-tauri/src/sidecar/mod.rs | 6 +- src-tauri/tauri.conf.json | 4 +- src/lib/components/SidecarSetup.svelte | 320 +++++++++++++++++++++++++ src/routes/+page.svelte | 104 +++++++- 11 files changed, 805 insertions(+), 22 deletions(-) create mode 100644 src-tauri/src/commands/sidecar.rs create mode 100644 src/lib/components/SidecarSetup.svelte diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 111acd0..be7a751 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -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 diff --git a/package-lock.json b/package-lock.json index 35f3abb..345f7ca 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 36705ca..fe565cf 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -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" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index d7dafc4..fe77032 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -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" diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 951f6cb..3b987a3 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -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; diff --git a/src-tauri/src/commands/sidecar.rs b/src-tauri/src/commands/sidecar.rs new file mode 100644 index 0000000..eb5aedf --- /dev/null +++ b/src-tauri/src/commands/sidecar.rs @@ -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, 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) + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 1b8de1e..380e57c 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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"); diff --git a/src-tauri/src/sidecar/mod.rs b/src-tauri/src/sidecar/mod.rs index b3d4d19..e8b137a 100644 --- a/src-tauri/src/sidecar/mod.rs +++ b/src-tauri/src/sidecar/mod.rs @@ -14,7 +14,7 @@ use crate::sidecar::messages::IPCMessage; /// Resource directory set by the Tauri app during setup. static RESOURCE_DIR: OnceLock = OnceLock::new(); /// App data directory for extracting the sidecar archive. -static DATA_DIR: OnceLock = OnceLock::new(); +pub(crate) static DATA_DIR: OnceLock = 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) { diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 6af7b57..d137bd1 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -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": { diff --git a/src/lib/components/SidecarSetup.svelte b/src/lib/components/SidecarSetup.svelte new file mode 100644 index 0000000..ec0760c --- /dev/null +++ b/src/lib/components/SidecarSetup.svelte @@ -0,0 +1,320 @@ + + +
+
+

Voice to Notes

+

First-Time Setup

+

+ Voice to Notes needs to download its AI engine to transcribe audio. +

+ + {#if !downloading && !success} +
+ + +
+ + {#if error} +
+

{error}

+ +
+ {:else} + + {/if} + {:else if downloading} +
+
+
+
+

+ {downloadProgress.percent}% — {formatBytes(downloadProgress.downloaded)} / {formatBytes(downloadProgress.total)} +

+

Downloading {variant === 'cuda' ? 'GPU' : 'CPU'} engine...

+
+ {:else if success} +
+
+

Setup complete!

+
+ {/if} +
+
+ + diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index fa416e4..0a41cf5 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -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(null); let currentProjectName = $state(''); let audioFilePath = $state(''); + async function checkSidecar() { + try { + const ready = await invoke('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 @@ } -{#if !appReady} +{#if !appReady || !sidecarChecked}

Voice to Notes

Loading...

+{:else if sidecarChecked && !sidecarReady && !showUpdateDownload} + +{:else if showUpdateDownload} + {:else}
+ {#if sidecarUpdate && !updateDismissed} +
+ + Sidecar update available (v{sidecarUpdate.current_version} → v{sidecarUpdate.latest_version}) + + + +
+ {/if}