From 8afe3230d398eed96e993fc1c390b8c1e9737f4d Mon Sep 17 00:00:00 2001 From: Developer Date: Mon, 6 Apr 2026 17:02:56 -0700 Subject: [PATCH] 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) --- src-tauri/Cargo.lock | 494 ++++++++++++++++++++- src-tauri/Cargo.toml | 5 + src-tauri/src/lib.rs | 32 ++ src-tauri/src/sidecar/mod.rs | 580 +++++++++++++++++++++++++ src/App.svelte | 71 ++- src/lib/components/SidecarSetup.svelte | 384 ++++++++++++++++ 6 files changed, 1555 insertions(+), 11 deletions(-) create mode 100644 src-tauri/src/sidecar/mod.rs create mode 100644 src/lib/components/SidecarSetup.svelte diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 14d8269..2452a3b 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -47,6 +47,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 = "atk" version = "0.18.2" @@ -338,6 +347,16 @@ dependencies = [ "version_check", ] +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation" version = "0.10.1" @@ -361,9 +380,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" dependencies = [ "bitflags 2.11.0", - "core-foundation", + "core-foundation 0.10.1", "core-graphics-types", - "foreign-types", + "foreign-types 0.5.0", "libc", ] @@ -374,7 +393,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" dependencies = [ "bitflags 2.11.0", - "core-foundation", + "core-foundation 0.10.1", "libc", ] @@ -515,6 +534,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" @@ -792,6 +822,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared 0.1.1", +] + [[package]] name = "foreign-types" version = "0.5.0" @@ -799,7 +838,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" dependencies = [ "foreign-types-macros", - "foreign-types-shared", + "foreign-types-shared 0.3.1", ] [[package]] @@ -813,6 +852,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + [[package]] name = "foreign-types-shared" version = "0.3.1" @@ -1222,6 +1267,25 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "h2" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap 2.13.1", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "hashbrown" version = "0.12.3" @@ -1332,6 +1396,7 @@ dependencies = [ "bytes", "futures-channel", "futures-core", + "h2", "http", "http-body", "httparse", @@ -1342,6 +1407,38 @@ dependencies = [ "want", ] +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + [[package]] name = "hyper-util" version = "0.1.20" @@ -1360,9 +1457,11 @@ dependencies = [ "percent-encoding", "pin-project-lite", "socket2", + "system-configuration", "tokio", "tower-service", "tracing", + "windows-registry", ] [[package]] @@ -1766,6 +1865,12 @@ dependencies = [ "libc", ] +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "litemap" version = "0.8.2" @@ -1774,8 +1879,11 @@ checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" [[package]] name = "local-transcription" -version = "1.4.0" +version = "1.4.3" dependencies = [ + "bytes", + "futures-util", + "reqwest 0.12.28", "serde", "serde_json", "tauri", @@ -1783,6 +1891,8 @@ dependencies = [ "tauri-plugin-dialog", "tauri-plugin-process", "tauri-plugin-shell", + "tokio", + "zip", ] [[package]] @@ -1911,6 +2021,23 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + [[package]] name = "ndk" version = "0.9.0" @@ -2132,6 +2259,50 @@ dependencies = [ "pathdiff", ] +[[package]] +name = "openssl" +version = "0.10.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "951c002c75e16ea2c65b8c7e4d3d51d5530d8dfa7d060b4776828c88cfb18ecf" +dependencies = [ + "bitflags 2.11.0", + "cfg-if", + "foreign-types 0.3.2", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "openssl-sys" +version = "0.9.112" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57d55af3b3e226502be1526dfdba67ab0e9c96fc293004e79576b2b9edb0dbdb" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "option-ext" version = "0.2.0" @@ -2727,6 +2898,49 @@ version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "encoding_rs", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-tls", + "hyper-util", + "js-sys", + "log", + "mime", + "native-tls", + "percent-encoding", + "pin-project-lite", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-native-tls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams 0.4.2", + "web-sys", +] + [[package]] name = "reqwest" version = "0.13.2" @@ -2757,7 +2971,7 @@ dependencies = [ "url", "wasm-bindgen", "wasm-bindgen-futures", - "wasm-streams", + "wasm-streams 0.5.0", "web-sys", ] @@ -2785,6 +2999,20 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "rustc-hash" version = "2.1.2" @@ -2800,12 +3028,64 @@ dependencies = [ "semver", ] +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.11.0", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" +dependencies = [ + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + [[package]] name = "same-file" version = "1.0.6" @@ -2815,6 +3095,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "schemars" version = "0.8.22" @@ -2872,6 +3161,29 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.11.0", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "selectors" version = "0.24.0" @@ -3014,6 +3326,18 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + [[package]] name = "serde_with" version = "3.18.0" @@ -3294,6 +3618,12 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "swift-rs" version = "1.0.7" @@ -3347,6 +3677,27 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags 2.11.0", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "system-deps" version = "6.2.2" @@ -3368,7 +3719,7 @@ checksum = "9103edf55f2da3c82aea4c7fab7c4241032bfeea0e71fa557d98e00e7ce7cc20" dependencies = [ "bitflags 2.11.0", "block2", - "core-foundation", + "core-foundation 0.10.1", "core-graphics", "crossbeam-channel", "dispatch2", @@ -3445,7 +3796,7 @@ dependencies = [ "percent-encoding", "plist", "raw-window-handle", - "reqwest", + "reqwest 0.13.2", "serde", "serde_json", "serde_repr", @@ -3719,6 +4070,19 @@ dependencies = [ "toml 0.9.12+spec-1.1.0", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "tendril" version = "0.4.3" @@ -3830,11 +4194,45 @@ dependencies = [ "bytes", "libc", "mio", + "parking_lot", "pin-project-lite", + "signal-hook-registry", "socket2", + "tokio-macros", "windows-sys 0.61.2", ] +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + [[package]] name = "tokio-util" version = "0.7.18" @@ -4116,6 +4514,12 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "url" version = "2.5.8" @@ -4165,6 +4569,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version-compare" version = "0.2.1" @@ -4323,6 +4733,19 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "wasm-streams" version = "0.5.0" @@ -4599,6 +5022,17 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + [[package]] name = "windows-result" version = "0.3.4" @@ -4644,6 +5078,15 @@ dependencies = [ "windows-targets 0.42.2", ] +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.59.0" @@ -5132,6 +5575,12 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + [[package]] name = "zerotrie" version = "0.2.4" @@ -5165,8 +5614,37 @@ 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.1", + "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", +] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index cdd4011..9475c97 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -19,3 +19,8 @@ tauri-plugin-dialog = "2" tauri-plugin-process = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" +reqwest = { version = "0.12", features = ["json", "stream"] } +futures-util = "0.3" +zip = { version = "2", default-features = false, features = ["deflate"] } +bytes = "1" +tokio = { version = "1", features = ["full"] } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 2410685..a5e677e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,9 +1,41 @@ +mod sidecar; + +use std::sync::Mutex; +use tauri::Manager; + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() .plugin(tauri_plugin_shell::init()) .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_process::init()) + .manage(sidecar::ManagedSidecar(Mutex::new( + sidecar::SidecarManager::new(), + ))) + .setup(|app| { + let resource_dir = app + .path() + .resource_dir() + .expect("failed to resolve resource dir"); + let data_dir = app + .path() + .app_data_dir() + .expect("failed to resolve app data dir"); + + // Ensure the data directory exists + std::fs::create_dir_all(&data_dir).expect("failed to create app data dir"); + + sidecar::init_dirs(resource_dir, data_dir); + Ok(()) + }) + .invoke_handler(tauri::generate_handler![ + sidecar::check_sidecar, + sidecar::download_sidecar, + sidecar::check_sidecar_update, + sidecar::get_sidecar_port, + sidecar::start_sidecar, + sidecar::stop_sidecar, + ]) .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 new file mode 100644 index 0000000..dddbd2c --- /dev/null +++ b/src-tauri/src/sidecar/mod.rs @@ -0,0 +1,580 @@ +use std::io::BufRead; +use std::path::PathBuf; +use std::sync::Mutex; + +use serde::{Deserialize, Serialize}; +use tauri::{AppHandle, Emitter}; + +const REPO_API: &str = + "https://repo.anhonesthost.net/api/v1/repos/streamer-tools/local-transcription"; + +const BINARY_NAME: &str = if cfg!(windows) { + "local-transcription-backend.exe" +} else { + "local-transcription-backend" +}; + +// --------------------------------------------------------------------------- +// Directory state (initialised once during Tauri setup) +// --------------------------------------------------------------------------- + +static DIRS: std::sync::OnceLock = std::sync::OnceLock::new(); + +struct SidecarDirs { + #[allow(dead_code)] + resource_dir: PathBuf, + data_dir: PathBuf, +} + +/// Called from Tauri `setup` to persist the resource / data directories. +pub fn init_dirs(resource_dir: PathBuf, data_dir: PathBuf) { + let _ = DIRS.set(SidecarDirs { + resource_dir, + data_dir, + }); +} + +fn data_dir() -> &'static PathBuf { + &DIRS.get().expect("sidecar::init_dirs not called").data_dir +} + +// --------------------------------------------------------------------------- +// Version helpers +// --------------------------------------------------------------------------- + +fn version_file() -> PathBuf { + data_dir().join("sidecar-version.txt") +} + +fn read_installed_version() -> Option { + std::fs::read_to_string(version_file()) + .ok() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) +} + +fn sidecar_dir_for_version(version: &str) -> PathBuf { + data_dir().join(format!("sidecar-{version}")) +} + +fn binary_path_for_version(version: &str) -> PathBuf { + sidecar_dir_for_version(version).join(BINARY_NAME) +} + +// --------------------------------------------------------------------------- +// Gitea API types +// --------------------------------------------------------------------------- + +#[derive(Debug, Deserialize)] +struct GiteaRelease { + tag_name: String, + assets: Vec, +} + +#[derive(Debug, Deserialize)] +struct GiteaAsset { + name: String, + browser_download_url: String, + size: u64, +} + +// --------------------------------------------------------------------------- +// Platform / arch detection +// --------------------------------------------------------------------------- + +fn platform_token() -> &'static str { + if cfg!(target_os = "windows") { + "windows" + } else if cfg!(target_os = "macos") { + "macos" + } else { + "linux" + } +} + +fn arch_token() -> &'static str { + if cfg!(target_arch = "aarch64") { + "aarch64" + } else { + "x86_64" + } +} + +/// Build the expected asset prefix, e.g. `sidecar-linux-x86_64-cuda`. +fn asset_prefix(variant: &str) -> String { + format!("sidecar-{}-{}-{}", platform_token(), arch_token(), variant) +} + +// --------------------------------------------------------------------------- +// Tauri commands +// --------------------------------------------------------------------------- + +/// Returns `true` when a sidecar binary is installed and the file exists. +#[tauri::command] +pub fn check_sidecar() -> bool { + if let Some(version) = read_installed_version() { + binary_path_for_version(&version).exists() + } else { + false + } +} + +/// Download progress payload emitted via `sidecar-download-progress`. +#[derive(Clone, Serialize)] +struct DownloadProgress { + downloaded: u64, + total: u64, + phase: String, // "downloading" | "extracting" | "done" | "error" + message: String, +} + +/// Download & install the latest sidecar release. +/// +/// `variant` is typically `"cuda"` or `"cpu"`. +#[tauri::command] +pub async fn download_sidecar(app: AppHandle, variant: String) -> Result { + use futures_util::StreamExt; + + let emit = |progress: DownloadProgress| { + let _ = app.emit("sidecar-download-progress", progress); + }; + + // 1. Fetch releases from Gitea (filter to sidecar-v* tags) --------------- + emit(DownloadProgress { + downloaded: 0, + total: 0, + phase: "downloading".into(), + message: "Fetching release info...".into(), + }); + + let releases_url = format!("{REPO_API}/releases?limit=20"); + let client = reqwest::Client::new(); + let releases: Vec = client + .get(&releases_url) + .send() + .await + .map_err(|e| format!("Failed to fetch releases: {e}"))? + .json() + .await + .map_err(|e| format!("Failed to parse releases: {e}"))?; + + // Find the latest release whose tag starts with `sidecar-v` + let release = releases + .into_iter() + .find(|r| r.tag_name.starts_with("sidecar-v")) + .ok_or_else(|| "No sidecar release found".to_string())?; + + let version = release.tag_name.clone(); // e.g. "sidecar-v1.0.2" + + // 2. Find matching asset ---------------------------------------------------- + let prefix = asset_prefix(&variant); + let asset = release + .assets + .iter() + .find(|a| a.name.starts_with(&prefix) && a.name.ends_with(".zip")) + .ok_or_else(|| { + format!( + "No asset matching '{}' in release {}. Available: {}", + prefix, + version, + release + .assets + .iter() + .map(|a| a.name.as_str()) + .collect::>() + .join(", ") + ) + })?; + + let total_size = asset.size; + let download_url = asset.browser_download_url.clone(); + + // 3. Stream download --------------------------------------------------------- + emit(DownloadProgress { + downloaded: 0, + total: total_size, + phase: "downloading".into(), + message: format!("Downloading {}...", asset.name), + }); + + let response = client + .get(&download_url) + .send() + .await + .map_err(|e| format!("Download request failed: {e}"))?; + + if !response.status().is_success() { + return Err(format!("Download failed with status {}", response.status())); + } + + let tmp_zip = data_dir().join("_sidecar_download.zip"); + let mut file = tokio::fs::File::create(&tmp_zip) + .await + .map_err(|e| format!("Cannot create temp file: {e}"))?; + + let mut stream = response.bytes_stream(); + let mut downloaded: u64 = 0; + + use tokio::io::AsyncWriteExt; + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(|e| format!("Download stream error: {e}"))?; + file.write_all(&chunk) + .await + .map_err(|e| format!("Write error: {e}"))?; + downloaded += chunk.len() as u64; + + emit(DownloadProgress { + downloaded, + total: total_size, + phase: "downloading".into(), + message: format!( + "Downloading... {:.1} / {:.1} MB", + downloaded as f64 / 1_048_576.0, + total_size as f64 / 1_048_576.0 + ), + }); + } + + file.flush() + .await + .map_err(|e| format!("Flush error: {e}"))?; + drop(file); + + // 4. Extract zip ------------------------------------------------------------- + emit(DownloadProgress { + downloaded, + total: total_size, + phase: "extracting".into(), + message: "Extracting sidecar...".into(), + }); + + let dest_dir = sidecar_dir_for_version(&version); + if dest_dir.exists() { + std::fs::remove_dir_all(&dest_dir) + .map_err(|e| format!("Cannot clean old dir: {e}"))?; + } + std::fs::create_dir_all(&dest_dir) + .map_err(|e| format!("Cannot create sidecar dir: {e}"))?; + + // Extraction is blocking I/O -- offload to a spawn_blocking thread. + let zip_path = tmp_zip.clone(); + let dest = dest_dir.clone(); + tokio::task::spawn_blocking(move || extract_zip(&zip_path, &dest)) + .await + .map_err(|e| format!("Join error: {e}"))? + .map_err(|e| format!("Extraction error: {e}"))?; + + // Remove the temp zip + let _ = std::fs::remove_file(&tmp_zip); + + // 5. Set executable permissions on Unix ------------------------------------- + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let bin = dest_dir.join(BINARY_NAME); + if bin.exists() { + let mut perms = std::fs::metadata(&bin) + .map_err(|e| format!("metadata error: {e}"))? + .permissions(); + perms.set_mode(0o755); + std::fs::set_permissions(&bin, perms) + .map_err(|e| format!("chmod error: {e}"))?; + } + } + + // 6. Write version file & clean up old versions ---------------------------- + std::fs::write(version_file(), &version) + .map_err(|e| format!("Failed to write version file: {e}"))?; + + cleanup_old_versions(&version); + + emit(DownloadProgress { + downloaded, + total: total_size, + phase: "done".into(), + message: "Sidecar installed successfully".into(), + }); + + Ok(version) +} + +/// Check if there is a newer sidecar release than the installed one. +/// Returns `Some(tag_name)` when an update is available, or `None`. +#[tauri::command] +pub async fn check_sidecar_update() -> Result, String> { + let installed = match read_installed_version() { + Some(v) => v, + None => return Ok(None), + }; + + let releases_url = format!("{REPO_API}/releases?limit=20"); + let releases: Vec = reqwest::Client::new() + .get(&releases_url) + .send() + .await + .map_err(|e| format!("Failed to fetch releases: {e}"))? + .json() + .await + .map_err(|e| format!("Failed to parse releases: {e}"))?; + + let latest = releases + .iter() + .find(|r| r.tag_name.starts_with("sidecar-v")); + + match latest { + Some(rel) if rel.tag_name != installed => Ok(Some(rel.tag_name.clone())), + _ => Ok(None), + } +} + +// --------------------------------------------------------------------------- +// Zip extraction helper +// --------------------------------------------------------------------------- + +fn extract_zip(zip_path: &std::path::Path, dest: &std::path::Path) -> Result<(), String> { + let file = + std::fs::File::open(zip_path).map_err(|e| format!("Cannot open zip: {e}"))?; + let mut archive = + zip::ZipArchive::new(file).map_err(|e| format!("Invalid zip: {e}"))?; + + for i in 0..archive.len() { + let mut entry = archive + .by_index(i) + .map_err(|e| format!("Zip entry error: {e}"))?; + let entry_path = match entry.enclosed_name() { + Some(p) => p.to_owned(), + None => continue, + }; + + let out_path = dest.join(&entry_path); + + if entry.is_dir() { + std::fs::create_dir_all(&out_path) + .map_err(|e| format!("mkdir error: {e}"))?; + } else { + if let Some(parent) = out_path.parent() { + std::fs::create_dir_all(parent) + .map_err(|e| format!("mkdir error: {e}"))?; + } + let mut outfile = std::fs::File::create(&out_path) + .map_err(|e| format!("create file error: {e}"))?; + std::io::copy(&mut entry, &mut outfile) + .map_err(|e| format!("copy error: {e}"))?; + } + } + Ok(()) +} + +// --------------------------------------------------------------------------- +// Cleanup old versions +// --------------------------------------------------------------------------- + +fn cleanup_old_versions(current_version: &str) { + let data = data_dir(); + let current_dir_name = format!("sidecar-{current_version}"); + if let Ok(entries) = std::fs::read_dir(data) { + for entry in entries.flatten() { + let name = entry.file_name().to_string_lossy().to_string(); + if name.starts_with("sidecar-v") // e.g. sidecar-v1.0.1 + && name != current_dir_name + && entry.path().is_dir() + { + let _ = std::fs::remove_dir_all(entry.path()); + } + } + } +} + +// --------------------------------------------------------------------------- +// SidecarManager — launch / stop / query the backend process +// --------------------------------------------------------------------------- + +#[derive(Debug, Serialize, Deserialize)] +struct ReadyEvent { + event: String, + port: u16, +} + +pub struct SidecarManager { + child: Option, + port: Option, +} + +impl SidecarManager { + pub fn new() -> Self { + Self { + child: None, + port: None, + } + } + + /// Returns `true` when the child process is still alive. + pub fn is_running(&mut self) -> bool { + match &mut self.child { + Some(child) => match child.try_wait() { + Ok(Some(_)) => { + // Process has exited + self.child = None; + self.port = None; + false + } + Ok(None) => true, + Err(_) => false, + }, + None => false, + } + } + + /// Start the sidecar if it is not already running. Returns the port. + pub fn ensure_running(&mut self) -> Result { + if self.is_running() { + return self + .port + .ok_or_else(|| "Sidecar running but port unknown".into()); + } + + let is_dev = cfg!(debug_assertions) + || std::env::var("LOCAL_TRANSCRIPTION_DEV") + .map(|v| v == "1") + .unwrap_or(false); + + let mut cmd = if is_dev { + self.build_dev_command()? + } else { + self.build_prod_command()? + }; + + // Hide the console window on Windows in release mode. + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + const CREATE_NO_WINDOW: u32 = 0x08000000; + cmd.creation_flags(CREATE_NO_WINDOW); + } + + cmd.stdout(std::process::Stdio::piped()); + cmd.stderr(std::process::Stdio::piped()); + + let mut child = cmd.spawn().map_err(|e| format!("Failed to spawn sidecar: {e}"))?; + + // Wait for the `{"event":"ready","port":...}` line on stdout. + let stdout = child + .stdout + .take() + .ok_or("Failed to capture sidecar stdout")?; + + let port = Self::wait_for_ready(stdout)?; + + self.child = Some(child); + self.port = Some(port); + Ok(port) + } + + /// Stop the sidecar process if running. + pub fn stop(&mut self) { + if let Some(mut child) = self.child.take() { + let _ = child.kill(); + let _ = child.wait(); + } + self.port = None; + } + + /// Return the port the sidecar is listening on, if known. + pub fn port(&self) -> Option { + self.port + } + + // -- private helpers ------------------------------------------------------- + + fn build_dev_command(&self) -> Result { + let mut cmd = std::process::Command::new("python"); + cmd.args(["-m", "backend.main_headless"]); + + // Try to find the project root (parent of src-tauri) + if let Some(dirs) = DIRS.get() { + let project_root = dirs + .resource_dir + .parent() // src-tauri + .and_then(|p| p.parent()); // project root + if let Some(root) = project_root { + cmd.current_dir(root); + } + } + + Ok(cmd) + } + + fn build_prod_command(&self) -> Result { + let version = read_installed_version() + .ok_or("No sidecar version installed")?; + let bin = binary_path_for_version(&version); + if !bin.exists() { + return Err(format!("Sidecar binary not found at {}", bin.display())); + } + let mut cmd = std::process::Command::new(&bin); + cmd.current_dir( + bin.parent() + .ok_or("Cannot determine sidecar parent dir")?, + ); + Ok(cmd) + } + + fn wait_for_ready(stdout: std::process::ChildStdout) -> Result { + let reader = std::io::BufReader::new(stdout); + let timeout = std::time::Duration::from_secs(120); + let start = std::time::Instant::now(); + + for line in reader.lines() { + if start.elapsed() > timeout { + return Err("Timed out waiting for sidecar ready event".into()); + } + let line = line.map_err(|e| format!("IO error reading stdout: {e}"))?; + if let Ok(evt) = serde_json::from_str::(&line) { + if evt.event == "ready" { + return Ok(evt.port); + } + } + // Ignore other lines (e.g. log output) + } + Err("Sidecar process exited before sending ready event".into()) + } +} + +// --------------------------------------------------------------------------- +// Tauri-managed SidecarManager state & commands +// --------------------------------------------------------------------------- + +/// Wrapper so we can store `SidecarManager` in Tauri's managed state. +pub struct ManagedSidecar(pub Mutex); + +#[tauri::command] +pub fn get_sidecar_port(state: tauri::State<'_, ManagedSidecar>) -> Result, String> { + let mut mgr = state + .0 + .lock() + .map_err(|e| format!("Lock error: {e}"))?; + // Refresh running status before returning port + if !mgr.is_running() { + return Ok(None); + } + Ok(mgr.port()) +} + +#[tauri::command] +pub fn start_sidecar(state: tauri::State<'_, ManagedSidecar>) -> Result { + let mut mgr = state + .0 + .lock() + .map_err(|e| format!("Lock error: {e}"))?; + mgr.ensure_running() +} + +#[tauri::command] +pub fn stop_sidecar(state: tauri::State<'_, ManagedSidecar>) -> Result<(), String> { + let mut mgr = state + .0 + .lock() + .map_err(|e| format!("Lock error: {e}"))?; + mgr.stop(); + Ok(()) +} diff --git a/src/App.svelte b/src/App.svelte index eef989b..1d6b642 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -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("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("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("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 @@ }); -{#if !isConnected} +{#if sidecarState === "checking"} +
+
+
+
+
+

Local Transcription

+

Checking setup...

+
+
+ +{:else if sidecarState === "needs_setup"} + + +{:else if !isConnected}
@@ -57,6 +121,7 @@ {/if}
+ {:else}
diff --git a/src/lib/components/SidecarSetup.svelte b/src/lib/components/SidecarSetup.svelte new file mode 100644 index 0000000..32efae8 --- /dev/null +++ b/src/lib/components/SidecarSetup.svelte @@ -0,0 +1,384 @@ + + +
+
+
+

Local Transcription

+

First-Time Setup

+
+ + {#if setupState === "choose"} +

+ The app needs to download its transcription engine before you can start. + Choose the version that best fits your hardware. +

+ +
+ + + +
+ + + + {:else if setupState === "downloading"} +
+

{progressMessage}

+
+
+
+

{Math.round(progress)}%

+
+ + {:else if setupState === "error"} +
+
+ + + + + +
+

Download Failed

+

{errorMessage}

+ +
+ + {:else if setupState === "success"} +
+
+ + + + +
+

Setup Complete

+

The transcription engine is ready to go.

+
+ {/if} +
+
+ +