Compare commits

..

6 Commits

Author SHA1 Message Date
Gitea Actions
fff37992b1 chore: bump version to 1.4.4 [skip ci] 2026-04-07 00:05:15 +00:00
Developer
8afe3230d3 Add sidecar download, setup screen, and auto-launch
Some checks failed
Release / Bump version and tag (push) Successful in 3s
Release / Build App (macOS) (push) Successful in 1m9s
Release / Build App (Linux) (push) Successful in 5m36s
Release / Build App (Windows) (push) Has been cancelled
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>
2026-04-06 17:02:56 -07:00
Developer
04e7fb1a99 Fix macOS sidecar build and blank window on startup
Some checks failed
Release / Bump version and tag (push) Has been cancelled
Release / Build App (Linux) (push) Has been cancelled
Release / Build App (Windows) (push) Has been cancelled
Release / Build App (macOS) (push) Has been cancelled
macOS sidecar: `uv run` re-resolves dependencies using CUDA sources
even after `uv sync --no-sources`. Use UV_NO_SOURCES=1 env var instead
so it applies to all uv commands in the step.

Blank window: When the Tauri app starts without the Python backend
running, it showed a completely blank window. Now shows a "Connecting
to backend..." spinner, or an error state with instructions to start
the backend manually.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 16:55:03 -07:00
Gitea Actions
9a282215c9 chore: bump sidecar version to 1.0.2 [skip ci] 2026-04-06 23:49:18 +00:00
Gitea Actions
cc2d17a627 chore: bump version to 1.4.3 [skip ci] 2026-04-06 21:02:18 +00:00
Developer
61c5ffa4fa Remove Zone.Identifier files that break Windows checkout
All checks were successful
Release / Bump version and tag (push) Successful in 4s
Release / Build App (macOS) (push) Successful in 58s
Release / Build App (Windows) (push) Successful in 3m22s
Release / Build App (Linux) (push) Successful in 6m27s
Windows NTFS Zone.Identifier alternate data stream files were
accidentally committed. The colon in the filename is invalid on
Windows, causing git checkout to fail on Windows runners.

Also added *:Zone.Identifier to .gitignore to prevent this recurring.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 14:02:11 -07:00
14 changed files with 1679 additions and 40 deletions

View File

@@ -365,11 +365,14 @@ jobs:
run: brew install portaudio
- name: Build sidecar (CPU)
env:
UV_NO_SOURCES: "1"
run: |
# --no-sources bypasses pyproject.toml's [tool.uv.sources] which forces
# torch from the CUDA index (no macOS ARM wheels there)
# Default PyPI torch includes MPS (Apple Silicon GPU) support
uv sync --no-sources
# UV_NO_SOURCES bypasses pyproject.toml's [tool.uv.sources] which forces
# torch from the CUDA index (no macOS ARM wheels there).
# Applies to both uv sync AND uv run (which re-resolves).
# Default PyPI torch includes MPS (Apple Silicon GPU) support.
uv sync
uv run pyinstaller local-transcription-headless.spec
- name: Package sidecar (CPU)

3
.gitignore vendored
View File

@@ -63,3 +63,6 @@ dist/
# Tauri
src-tauri/target/
# Windows NTFS alternate data streams
*:Zone.Identifier

View File

@@ -1,7 +1,7 @@
{
"name": "local-transcription",
"private": true,
"version": "1.4.2",
"version": "1.4.4",
"type": "module",
"scripts": {
"dev": "vite dev",

View File

@@ -1,6 +1,6 @@
[project]
name = "local-transcription"
version = "1.0.1"
version = "1.0.2"
description = "A standalone desktop application for real-time speech-to-text transcription using Whisper models"
readme = "README.md"
requires-python = ">=3.9"

494
src-tauri/Cargo.lock generated
View File

@@ -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",
]

View File

@@ -1,6 +1,6 @@
[package]
name = "local-transcription"
version = "1.4.2"
version = "1.4.4"
description = "Real-time speech-to-text transcription for streamers"
authors = ["Local Transcription Contributors"]
edition = "2021"
@@ -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"] }

View File

@@ -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");
}

View File

@@ -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<SidecarDirs> = 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<String> {
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<GiteaAsset>,
}
#[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<String, String> {
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<GiteaRelease> = 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::<Vec<_>>()
.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<Option<String>, 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<GiteaRelease> = 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<std::process::Child>,
port: Option<u16>,
}
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<u16, String> {
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<u16> {
self.port
}
// -- private helpers -------------------------------------------------------
fn build_dev_command(&self) -> Result<std::process::Command, String> {
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<std::process::Command, String> {
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<u16, String> {
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::<ReadyEvent>(&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<SidecarManager>);
#[tauri::command]
pub fn get_sidecar_port(state: tauri::State<'_, ManagedSidecar>) -> Result<Option<u16>, 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<u16, String> {
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(())
}

View File

@@ -1,6 +1,6 @@
{
"productName": "Local Transcription",
"version": "1.4.2",
"version": "1.4.4",
"identifier": "net.anhonesthost.local-transcription",
"build": {
"frontendDist": "../dist",

View File

@@ -5,13 +5,19 @@
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);
let isConnected = $derived(backendStore.connectionState === "connected");
let connectionState = $derived(backendStore.connectionState);
function openSettings() {
showSettings = true;
@@ -21,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();
@@ -31,33 +83,135 @@
});
</script>
<div class="app-shell">
<Header onSettingsClick={openSettings} />
<StatusBar />
<div class="display-links">
<span class="link-label">OBS:</span>
<a href={obsDisplayUrl} target="_blank" rel="noopener">{obsDisplayUrl}</a>
{#if syncDisplayUrl}
<span class="link-separator">|</span>
<span class="link-label">Sync:</span>
<a href={syncDisplayUrl} target="_blank" rel="noopener"
>{syncDisplayUrl}</a
>
{/if}
{#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>
<TranscriptionDisplay />
<Controls />
{:else if sidecarState === "needs_setup"}
<SidecarSetup onComplete={onSidecarReady} />
<div class="version-label">v{backendStore.version}</div>
</div>
{:else if !isConnected}
<div class="connecting-overlay">
<div class="connecting-content">
<div class="connecting-icon">
{#if connectionState === "error"}
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="#e74c3c" 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>
{:else}
<div class="spinner"></div>
{/if}
</div>
<h2>Local Transcription</h2>
{#if connectionState === "error"}
<p>Cannot connect to backend</p>
<p class="hint">Make sure the Python backend is running:<br>
<code>uv run python -m backend.main_headless</code></p>
{:else}
<p>Connecting to backend...</p>
{/if}
</div>
</div>
{#if showSettings}
<Settings onClose={closeSettings} />
{:else}
<div class="app-shell">
<Header onSettingsClick={openSettings} />
<StatusBar />
<div class="display-links">
<span class="link-label">OBS:</span>
<a href={obsDisplayUrl} target="_blank" rel="noopener">{obsDisplayUrl}</a>
{#if syncDisplayUrl}
<span class="link-separator">|</span>
<span class="link-label">Sync:</span>
<a href={syncDisplayUrl} target="_blank" rel="noopener"
>{syncDisplayUrl}</a
>
{/if}
</div>
<TranscriptionDisplay />
<Controls />
<div class="version-label">v{backendStore.version}</div>
</div>
{#if showSettings}
<Settings onClose={closeSettings} />
{/if}
{/if}
<style>
.connecting-overlay {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
width: 100%;
background-color: var(--bg-primary);
}
.connecting-content {
text-align: center;
color: var(--text-primary);
}
.connecting-content h2 {
margin: 16px 0 8px;
font-size: 20px;
font-weight: 600;
}
.connecting-content p {
margin: 4px 0;
color: var(--text-secondary);
font-size: 14px;
}
.connecting-content .hint {
margin-top: 16px;
font-size: 12px;
color: var(--text-muted);
}
.connecting-content code {
display: inline-block;
margin-top: 4px;
padding: 4px 8px;
background: var(--bg-tertiary);
border-radius: 4px;
font-size: 12px;
color: var(--text-primary);
}
.connecting-icon {
display: flex;
justify-content: center;
margin-bottom: 8px;
}
.spinner {
width: 40px;
height: 40px;
border: 3px solid var(--border-color);
border-top-color: var(--accent-color, #4CAF50);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.app-shell {
display: flex;
flex-direction: column;

View 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>

View File

@@ -1,7 +1,7 @@
"""Version information for Local Transcription."""
__version__ = "1.4.2"
__version_info__ = (1, 4, 2)
__version__ = "1.4.4"
__version_info__ = (1, 4, 4)
# Version history:
# 1.4.0 - Auto-update feature: