Add sidecar download, setup screen, and auto-launch
On first launch, the app now prompts users to download the Python sidecar (CPU or CUDA variant) from Gitea releases, matching the voice-to-notes pattern. On subsequent launches, it auto-launches the sidecar and connects. New Rust module (src-tauri/src/sidecar/): - download_sidecar: streams download with progress events, extracts zip - check_sidecar: verifies installed sidecar binary exists - check_sidecar_update: compares local vs latest release version - SidecarManager: launches binary, waits for ready JSON, manages lifecycle - Dev mode: runs `python -m backend.main_headless` directly - start_sidecar/stop_sidecar/get_sidecar_port: Tauri commands New Svelte component (SidecarSetup.svelte): - First-time setup overlay with CPU/CUDA variant selection - Download progress bar with byte counter - Error state with retry, success state with auto-continue Updated App.svelte state machine: - checking -> needs_setup -> starting -> connected - Falls back to direct connection in browser dev mode Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -5,10 +5,14 @@
|
||||
import Controls from "$lib/components/Controls.svelte";
|
||||
import TranscriptionDisplay from "$lib/components/TranscriptionDisplay.svelte";
|
||||
import Settings from "$lib/components/Settings.svelte";
|
||||
import SidecarSetup from "$lib/components/SidecarSetup.svelte";
|
||||
import { backendStore } from "$lib/stores/backend";
|
||||
import { configStore } from "$lib/stores/config";
|
||||
|
||||
type SidecarState = "checking" | "needs_setup" | "starting" | "connected";
|
||||
|
||||
let showSettings = $state(false);
|
||||
let sidecarState = $state<SidecarState>("checking");
|
||||
|
||||
let obsDisplayUrl = $derived(backendStore.obsUrl);
|
||||
let syncDisplayUrl = $derived(backendStore.syncUrl);
|
||||
@@ -23,9 +27,55 @@
|
||||
showSettings = false;
|
||||
}
|
||||
|
||||
async function checkAndLaunchSidecar() {
|
||||
try {
|
||||
const { invoke } = await import("@tauri-apps/api/core");
|
||||
|
||||
// Check if sidecar is installed
|
||||
sidecarState = "checking";
|
||||
const installed = await invoke<boolean>("check_sidecar");
|
||||
|
||||
if (!installed) {
|
||||
sidecarState = "needs_setup";
|
||||
return;
|
||||
}
|
||||
|
||||
await launchSidecar();
|
||||
} catch {
|
||||
// Not running in Tauri (browser dev mode) - skip sidecar check
|
||||
// and connect directly to localhost:8081
|
||||
sidecarState = "starting";
|
||||
backendStore.setPort(8081);
|
||||
backendStore.connect();
|
||||
configStore.loadConfig();
|
||||
}
|
||||
}
|
||||
|
||||
async function launchSidecar() {
|
||||
try {
|
||||
const { invoke } = await import("@tauri-apps/api/core");
|
||||
|
||||
sidecarState = "starting";
|
||||
await invoke("start_sidecar");
|
||||
|
||||
const port = await invoke<number>("get_sidecar_port");
|
||||
backendStore.setPort(port);
|
||||
backendStore.connect();
|
||||
configStore.loadConfig();
|
||||
} catch {
|
||||
// If sidecar launch fails, still try connecting to default port
|
||||
sidecarState = "starting";
|
||||
backendStore.connect();
|
||||
configStore.loadConfig();
|
||||
}
|
||||
}
|
||||
|
||||
async function onSidecarReady() {
|
||||
await launchSidecar();
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
backendStore.connect();
|
||||
configStore.loadConfig();
|
||||
checkAndLaunchSidecar();
|
||||
|
||||
return () => {
|
||||
backendStore.disconnect();
|
||||
@@ -33,7 +83,21 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if !isConnected}
|
||||
{#if sidecarState === "checking"}
|
||||
<div class="connecting-overlay">
|
||||
<div class="connecting-content">
|
||||
<div class="connecting-icon">
|
||||
<div class="spinner"></div>
|
||||
</div>
|
||||
<h2>Local Transcription</h2>
|
||||
<p>Checking setup...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{:else if sidecarState === "needs_setup"}
|
||||
<SidecarSetup onComplete={onSidecarReady} />
|
||||
|
||||
{:else if !isConnected}
|
||||
<div class="connecting-overlay">
|
||||
<div class="connecting-content">
|
||||
<div class="connecting-icon">
|
||||
@@ -57,6 +121,7 @@
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{:else}
|
||||
<div class="app-shell">
|
||||
<Header onSettingsClick={openSettings} />
|
||||
|
||||
384
src/lib/components/SidecarSetup.svelte
Normal file
384
src/lib/components/SidecarSetup.svelte
Normal file
@@ -0,0 +1,384 @@
|
||||
<script lang="ts">
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { onMount } from "svelte";
|
||||
|
||||
interface Props {
|
||||
onComplete: () => void;
|
||||
}
|
||||
|
||||
let { onComplete }: Props = $props();
|
||||
|
||||
type SetupState = "choose" | "downloading" | "error" | "success";
|
||||
|
||||
let setupState = $state<SetupState>("choose");
|
||||
let variant = $state<"cpu" | "cuda">("cpu");
|
||||
let progress = $state(0);
|
||||
let progressMessage = $state("");
|
||||
let errorMessage = $state("");
|
||||
|
||||
let unlisten: (() => void) | null = null;
|
||||
|
||||
onMount(() => {
|
||||
return () => {
|
||||
if (unlisten) {
|
||||
unlisten();
|
||||
unlisten = null;
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
async function startDownload() {
|
||||
setupState = "downloading";
|
||||
progress = 0;
|
||||
progressMessage = "Starting download...";
|
||||
errorMessage = "";
|
||||
|
||||
try {
|
||||
// Listen for progress events from the Tauri backend
|
||||
unlisten = await listen<{ progress: number; message: string }>(
|
||||
"sidecar-download-progress",
|
||||
(event) => {
|
||||
progress = event.payload.progress;
|
||||
progressMessage = event.payload.message;
|
||||
}
|
||||
);
|
||||
|
||||
await invoke("download_sidecar", { variant });
|
||||
|
||||
// Download complete
|
||||
setupState = "success";
|
||||
if (unlisten) {
|
||||
unlisten();
|
||||
unlisten = null;
|
||||
}
|
||||
|
||||
// Brief pause to show success, then proceed
|
||||
setTimeout(() => {
|
||||
onComplete();
|
||||
}, 1500);
|
||||
} catch (err) {
|
||||
setupState = "error";
|
||||
errorMessage = err instanceof Error ? err.message : String(err);
|
||||
if (unlisten) {
|
||||
unlisten();
|
||||
unlisten = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function retry() {
|
||||
setupState = "choose";
|
||||
progress = 0;
|
||||
progressMessage = "";
|
||||
errorMessage = "";
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="setup-overlay">
|
||||
<div class="setup-card">
|
||||
<div class="setup-header">
|
||||
<h1 class="app-title">Local Transcription</h1>
|
||||
<h2 class="setup-heading">First-Time Setup</h2>
|
||||
</div>
|
||||
|
||||
{#if setupState === "choose"}
|
||||
<p class="setup-description">
|
||||
The app needs to download its transcription engine before you can start.
|
||||
Choose the version that best fits your hardware.
|
||||
</p>
|
||||
|
||||
<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-name">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-name">GPU Accelerated (CUDA)</span>
|
||||
<span class="variant-desc">Faster transcription with NVIDIA GPU (~2 GB download)</span>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button class="download-btn" onclick={startDownload}>
|
||||
Download & Install
|
||||
</button>
|
||||
|
||||
{:else if setupState === "downloading"}
|
||||
<div class="progress-section">
|
||||
<p class="progress-message">{progressMessage}</p>
|
||||
<div class="progress-bar-track">
|
||||
<div
|
||||
class="progress-bar-fill"
|
||||
style="width: {progress}%"
|
||||
></div>
|
||||
</div>
|
||||
<p class="progress-percent">{Math.round(progress)}%</p>
|
||||
</div>
|
||||
|
||||
{:else if setupState === "error"}
|
||||
<div class="error-section">
|
||||
<div class="error-icon">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="#f44336" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<line x1="15" y1="9" x2="9" y2="15"/>
|
||||
<line x1="9" y1="9" x2="15" y2="15"/>
|
||||
</svg>
|
||||
</div>
|
||||
<p class="error-title">Download Failed</p>
|
||||
<p class="error-message">{errorMessage}</p>
|
||||
<button class="retry-btn" onclick={retry}>
|
||||
Try Again
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{:else if setupState === "success"}
|
||||
<div class="success-section">
|
||||
<div class="success-icon">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="#4CAF50" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<polyline points="16 9 10.5 15 8 12.5"/>
|
||||
</svg>
|
||||
</div>
|
||||
<p class="success-title">Setup Complete</p>
|
||||
<p class="success-message">The transcription engine is ready to go.</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.setup-overlay {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
background-color: #1e1e1e;
|
||||
}
|
||||
|
||||
.setup-card {
|
||||
background-color: #2a2a2a;
|
||||
border-radius: 12px;
|
||||
padding: 40px;
|
||||
max-width: 480px;
|
||||
width: 100%;
|
||||
margin: 20px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.setup-header {
|
||||
text-align: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.app-title {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: #e0e0e0;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.setup-heading {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: #a0a0a0;
|
||||
}
|
||||
|
||||
.setup-description {
|
||||
font-size: 14px;
|
||||
color: #a0a0a0;
|
||||
line-height: 1.6;
|
||||
text-align: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.variant-options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.variant-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
border: 2px solid #444;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s ease, background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.variant-option:hover {
|
||||
background-color: #333;
|
||||
border-color: #555;
|
||||
}
|
||||
|
||||
.variant-option.selected {
|
||||
border-color: #4CAF50;
|
||||
background-color: rgba(76, 175, 80, 0.08);
|
||||
}
|
||||
|
||||
.variant-option input[type="radio"] {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.variant-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.variant-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.variant-desc {
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.download-btn {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 12px 24px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
background-color: #4CAF50;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.download-btn:hover {
|
||||
background-color: #45a049;
|
||||
}
|
||||
|
||||
.download-btn:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
/* Progress state */
|
||||
.progress-section {
|
||||
text-align: center;
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
.progress-message {
|
||||
font-size: 14px;
|
||||
color: #a0a0a0;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.progress-bar-track {
|
||||
width: 100%;
|
||||
height: 8px;
|
||||
background-color: #3a3a3a;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.progress-bar-fill {
|
||||
height: 100%;
|
||||
background-color: #4CAF50;
|
||||
border-radius: 4px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.progress-percent {
|
||||
font-size: 13px;
|
||||
color: #707070;
|
||||
}
|
||||
|
||||
/* Error state */
|
||||
.error-section {
|
||||
text-align: center;
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
.error-icon {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.error-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #f44336;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
font-size: 13px;
|
||||
color: #a0a0a0;
|
||||
margin-bottom: 20px;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.retry-btn {
|
||||
display: inline-block;
|
||||
padding: 10px 28px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
background-color: #4CAF50;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.retry-btn:hover {
|
||||
background-color: #45a049;
|
||||
}
|
||||
|
||||
/* Success state */
|
||||
.success-section {
|
||||
text-align: center;
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
.success-icon {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.success-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #4CAF50;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.success-message {
|
||||
font-size: 14px;
|
||||
color: #a0a0a0;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user