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
|
- name: Set up Python
|
||||||
run: uv python install ${{ env.PYTHON_VERSION }}
|
run: uv python install ${{ env.PYTHON_VERSION }}
|
||||||
|
|
||||||
- name: Build sidecar
|
- name: Build sidecar (CUDA)
|
||||||
working-directory: python
|
working-directory: python
|
||||||
run: uv run --python ${{ env.PYTHON_VERSION }} python build_sidecar.py --with-cuda
|
run: uv run --python ${{ env.PYTHON_VERSION }} python build_sidecar.py --with-cuda
|
||||||
|
|
||||||
- name: Package sidecar for Tauri
|
- name: Package sidecar (CUDA)
|
||||||
run: |
|
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 ──
|
# ── Tauri app ──
|
||||||
- name: Set up Node.js
|
- name: Set up Node.js
|
||||||
@@ -183,6 +193,26 @@ jobs:
|
|||||||
echo "Upload response: HTTP ${HTTP_CODE}"
|
echo "Upload response: HTTP ${HTTP_CODE}"
|
||||||
done
|
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:
|
build-windows:
|
||||||
name: Build (Windows)
|
name: Build (Windows)
|
||||||
needs: bump-version
|
needs: bump-version
|
||||||
@@ -214,19 +244,34 @@ jobs:
|
|||||||
shell: powershell
|
shell: powershell
|
||||||
run: uv python install ${{ env.PYTHON_VERSION }}
|
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
|
shell: powershell
|
||||||
working-directory: python
|
working-directory: python
|
||||||
run: uv run --python ${{ env.PYTHON_VERSION }} python build_sidecar.py --with-cuda
|
run: uv run --python ${{ env.PYTHON_VERSION }} python build_sidecar.py --with-cuda
|
||||||
|
|
||||||
- name: Package sidecar for Tauri
|
- name: Package sidecar (CUDA)
|
||||||
shell: powershell
|
shell: powershell
|
||||||
run: |
|
run: |
|
||||||
# Compress-Archive has a 2GB limit; use 7z for CUDA builds
|
7z a -tzip -mx=5 sidecar-windows-x86_64-cuda.zip .\python\dist\voice-to-notes-sidecar\*
|
||||||
if (-not (Get-Command 7z -ErrorAction SilentlyContinue)) {
|
|
||||||
choco install 7zip -y
|
- name: Build sidecar (CPU)
|
||||||
}
|
shell: powershell
|
||||||
7z a -tzip -mx=5 src-tauri\sidecar.zip .\python\dist\voice-to-notes-sidecar\*
|
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 ──
|
# ── Tauri app ──
|
||||||
- name: Set up Node.js
|
- 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:
|
build-macos:
|
||||||
name: Build (macOS)
|
name: Build (macOS)
|
||||||
needs: bump-version
|
needs: bump-version
|
||||||
@@ -327,13 +400,13 @@ jobs:
|
|||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
run: uv python install ${{ env.PYTHON_VERSION }}
|
run: uv python install ${{ env.PYTHON_VERSION }}
|
||||||
|
|
||||||
- name: Build sidecar
|
- name: Build sidecar (CPU)
|
||||||
working-directory: python
|
working-directory: python
|
||||||
run: uv run --python ${{ env.PYTHON_VERSION }} python build_sidecar.py --cpu-only
|
run: uv run --python ${{ env.PYTHON_VERSION }} python build_sidecar.py --cpu-only
|
||||||
|
|
||||||
- name: Package sidecar for Tauri
|
- name: Package sidecar (CPU)
|
||||||
run: |
|
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 ──
|
# ── Tauri app ──
|
||||||
- name: Set up Node.js
|
- name: Set up Node.js
|
||||||
@@ -397,3 +470,23 @@ jobs:
|
|||||||
"${REPO_API}/releases/${RELEASE_ID}/assets?name=${encoded_name}")
|
"${REPO_API}/releases/${RELEASE_ID}/assets?name=${encoded_name}")
|
||||||
echo "Upload response: HTTP ${HTTP_CODE}"
|
echo "Upload response: HTTP ${HTTP_CODE}"
|
||||||
done
|
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",
|
"name": "voice-to-notes",
|
||||||
"version": "0.1.0",
|
"version": "0.2.10",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "voice-to-notes",
|
"name": "voice-to-notes",
|
||||||
"version": "0.1.0",
|
"version": "0.2.10",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tauri-apps/api": "^2",
|
"@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"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
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]]
|
[[package]]
|
||||||
name = "async-broadcast"
|
name = "async-broadcast"
|
||||||
version = "0.7.2"
|
version = "0.7.2"
|
||||||
@@ -655,6 +664,17 @@ dependencies = [
|
|||||||
"serde_core",
|
"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]]
|
[[package]]
|
||||||
name = "derive_more"
|
name = "derive_more"
|
||||||
version = "0.99.20"
|
version = "0.99.20"
|
||||||
@@ -4362,7 +4382,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "voice-to-notes"
|
name = "voice-to-notes"
|
||||||
version = "0.1.0"
|
version = "0.2.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"rusqlite",
|
"rusqlite",
|
||||||
@@ -4374,6 +4394,7 @@ dependencies = [
|
|||||||
"tauri-plugin-opener",
|
"tauri-plugin-opener",
|
||||||
"thiserror 1.0.69",
|
"thiserror 1.0.69",
|
||||||
"uuid",
|
"uuid",
|
||||||
|
"zip",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -5412,12 +5433,41 @@ dependencies = [
|
|||||||
"syn 2.0.117",
|
"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]]
|
[[package]]
|
||||||
name = "zmij"
|
name = "zmij"
|
||||||
version = "1.0.21"
|
version = "1.0.21"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
|
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]]
|
[[package]]
|
||||||
name = "zvariant"
|
name = "zvariant"
|
||||||
version = "5.10.0"
|
version = "5.10.0"
|
||||||
|
|||||||
@@ -24,3 +24,5 @@ zip = { version = "2", default-features = false, features = ["deflate"] }
|
|||||||
thiserror = "1"
|
thiserror = "1"
|
||||||
chrono = { version = "0.4", features = ["serde"] }
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
tauri-plugin-dialog = "2.6.0"
|
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 export;
|
||||||
pub mod project;
|
pub mod project;
|
||||||
pub mod settings;
|
pub mod settings;
|
||||||
|
pub mod sidecar;
|
||||||
pub mod system;
|
pub mod system;
|
||||||
pub mod transcribe;
|
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,
|
load_project_transcript, save_project_file, save_project_transcript, update_segment,
|
||||||
};
|
};
|
||||||
use commands::settings::{load_settings, save_settings};
|
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::system::{get_data_dir, llama_list_models, llama_start, llama_status, llama_stop};
|
||||||
use commands::transcribe::{download_diarize_model, run_pipeline, transcribe_file};
|
use commands::transcribe::{download_diarize_model, run_pipeline, transcribe_file};
|
||||||
use state::AppState;
|
use state::AppState;
|
||||||
@@ -65,6 +66,9 @@ pub fn run() {
|
|||||||
get_data_dir,
|
get_data_dir,
|
||||||
load_settings,
|
load_settings,
|
||||||
save_settings,
|
save_settings,
|
||||||
|
check_sidecar,
|
||||||
|
download_sidecar,
|
||||||
|
check_sidecar_update,
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ use crate::sidecar::messages::IPCMessage;
|
|||||||
/// Resource directory set by the Tauri app during setup.
|
/// Resource directory set by the Tauri app during setup.
|
||||||
static RESOURCE_DIR: OnceLock<PathBuf> = OnceLock::new();
|
static RESOURCE_DIR: OnceLock<PathBuf> = OnceLock::new();
|
||||||
/// App data directory for extracting the sidecar archive.
|
/// 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.
|
/// Initialize directories for sidecar resolution.
|
||||||
/// Must be called from the Tauri setup before any sidecar operations.
|
/// 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.
|
/// 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!(
|
eprintln!(
|
||||||
"[sidecar-rs] Extracting sidecar from {} to {}",
|
"[sidecar-rs] Extracting sidecar from {} to {}",
|
||||||
zip_path.display(),
|
zip_path.display(),
|
||||||
@@ -185,7 +185,7 @@ impl SidecarManager {
|
|||||||
|
|
||||||
/// Remove old sidecar-* directories that don't match the current version.
|
/// Remove old sidecar-* directories that don't match the current version.
|
||||||
/// Called after the current version's sidecar is confirmed ready.
|
/// 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 current_dir_name = format!("sidecar-{}", current_version);
|
||||||
|
|
||||||
let entries = match std::fs::read_dir(data_dir) {
|
let entries = match std::fs::read_dir(data_dir) {
|
||||||
|
|||||||
@@ -31,7 +31,7 @@
|
|||||||
},
|
},
|
||||||
"bundle": {
|
"bundle": {
|
||||||
"active": true,
|
"active": true,
|
||||||
"targets": ["deb", "rpm", "msi", "dmg"],
|
"targets": ["deb", "rpm", "nsis", "dmg"],
|
||||||
"icon": [
|
"icon": [
|
||||||
"icons/32x32.png",
|
"icons/32x32.png",
|
||||||
"icons/128x128.png",
|
"icons/128x128.png",
|
||||||
@@ -42,7 +42,7 @@
|
|||||||
"category": "Utility",
|
"category": "Utility",
|
||||||
"shortDescription": "Transcribe audio/video with speaker identification",
|
"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.",
|
"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",
|
"copyright": "Voice to Notes Contributors",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"linux": {
|
"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 AIChatPanel from '$lib/components/AIChatPanel.svelte';
|
||||||
import ProgressOverlay from '$lib/components/ProgressOverlay.svelte';
|
import ProgressOverlay from '$lib/components/ProgressOverlay.svelte';
|
||||||
import SettingsModal from '$lib/components/SettingsModal.svelte';
|
import SettingsModal from '$lib/components/SettingsModal.svelte';
|
||||||
|
import SidecarSetup from '$lib/components/SidecarSetup.svelte';
|
||||||
import { segments, speakers } from '$lib/stores/transcript';
|
import { segments, speakers } from '$lib/stores/transcript';
|
||||||
import { settings, loadSettings } from '$lib/stores/settings';
|
import { settings, loadSettings } from '$lib/stores/settings';
|
||||||
import type { Segment, Speaker } from '$lib/types/transcript';
|
import type { Segment, Speaker } from '$lib/types/transcript';
|
||||||
@@ -18,13 +19,56 @@
|
|||||||
let audioUrl = $state('');
|
let audioUrl = $state('');
|
||||||
let showSettings = $state(false);
|
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
|
// Project management state
|
||||||
let currentProjectPath = $state<string | null>(null);
|
let currentProjectPath = $state<string | null>(null);
|
||||||
let currentProjectName = $state('');
|
let currentProjectName = $state('');
|
||||||
let audioFilePath = $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(() => {
|
onMount(() => {
|
||||||
loadSettings();
|
loadSettings();
|
||||||
|
checkSidecar().then(() => {
|
||||||
|
if (sidecarReady) {
|
||||||
|
checkSidecarUpdate();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Global keyboard shortcuts
|
// Global keyboard shortcuts
|
||||||
function handleKeyDown(e: KeyboardEvent) {
|
function handleKeyDown(e: KeyboardEvent) {
|
||||||
@@ -443,14 +487,31 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if !appReady}
|
{#if !appReady || !sidecarChecked}
|
||||||
<div class="splash-screen">
|
<div class="splash-screen">
|
||||||
<h1 class="splash-title">Voice to Notes</h1>
|
<h1 class="splash-title">Voice to Notes</h1>
|
||||||
<p class="splash-subtitle">Loading...</p>
|
<p class="splash-subtitle">Loading...</p>
|
||||||
<div class="splash-spinner"></div>
|
<div class="splash-spinner"></div>
|
||||||
</div>
|
</div>
|
||||||
|
{:else if sidecarChecked && !sidecarReady && !showUpdateDownload}
|
||||||
|
<SidecarSetup onComplete={handleSidecarSetupComplete} />
|
||||||
|
{:else if showUpdateDownload}
|
||||||
|
<SidecarSetup onComplete={handleUpdateComplete} />
|
||||||
{:else}
|
{:else}
|
||||||
<div class="app-shell">
|
<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="app-header">
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
<button class="settings-btn" onclick={openProject} disabled={isTranscribing}>
|
<button class="settings-btn" onclick={openProject} disabled={isTranscribing}>
|
||||||
@@ -674,4 +735,45 @@
|
|||||||
@keyframes spin {
|
@keyframes spin {
|
||||||
to { transform: rotate(360deg); }
|
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>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user