Compare commits

...

15 Commits

Author SHA1 Message Date
Gitea Actions
e42a922507 chore: bump sidecar version to 1.0.4 [skip ci] 2026-04-07 20:01:20 +00:00
Developer
8fc2d11c5f Fix builds failing to checkout: stop deleting tags, fix tag passing
All checks were successful
Tests / Python Backend Tests (push) Successful in 5s
Tests / Frontend Tests (push) Successful in 8s
Tests / Rust Sidecar Tests (push) Successful in 2m3s
Two issues causing all builds to fail:

1. Cleanup steps deleted git tags along with releases. Since builds
   are dispatched asynchronously, they tried to checkout tags that
   had already been deleted. Now cleanup only deletes releases (which
   frees storage by removing assets) but preserves git tags.

2. Linux/macOS build workflows used $GITHUB_OUTPUT step outputs for
   the tag, which is unreliable on Gitea runners. Switched to the
   same job-level env var pattern (RELEASE_TAG) that works on Windows.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 12:27:13 -07:00
Gitea Actions
11832e911b chore: bump version to 2.0.3 [skip ci] 2026-04-07 19:21:16 +00:00
Developer
18e6b974c0 Fix sidecar stdout buffering: set PYTHONUNBUFFERED=1
All checks were successful
Release / Run Tests (push) Successful in 24s
Tests / Python Backend Tests (push) Successful in 5s
Tests / Frontend Tests (push) Successful in 7s
Tests / Rust Sidecar Tests (push) Successful in 3m17s
Release / Bump version and tag (push) Successful in 4s
PyInstaller frozen executables buffer stdout when piped to a
subprocess (no TTY). Even with flush=True in Python, the OS-level
pipe buffer can delay output. This prevented the ready event from
reaching the Tauri app, causing the "Starting sidecar..." hang.

Fix: set PYTHONUNBUFFERED=1 env var on both prod and dev sidecar
commands, plus -u flag for dev mode Python.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 12:17:18 -07:00
Gitea Actions
08e464daaf chore: bump version to 2.0.2 [skip ci] 2026-04-07 19:15:05 +00:00
Developer
5d22adcaa4 Fix app hanging on sidecar startup
All checks were successful
Release / Run Tests (push) Successful in 11s
Tests / Python Backend Tests (push) Successful in 4s
Tests / Frontend Tests (push) Successful in 7s
Tests / Rust Sidecar Tests (push) Successful in 3m13s
Release / Bump version and tag (push) Successful in 14s
Two issues caused the app to freeze on "Starting sidecar...":

1. wait_for_ready() used a blocking BufReader::lines() iterator
   with a timeout check between lines. If the sidecar produced no
   stdout output (crashed, missing binary, or slow model loading),
   the read blocked forever. Now uses a background thread with
   mpsc::recv_timeout() for a real 120s deadline.

2. start_sidecar was a synchronous Tauri command that blocked the
   main thread during the entire sidecar startup (up to 120s).
   Now async via tokio::spawn_blocking, keeping the UI responsive.

Also logs all sidecar stdout lines to stderr with [sidecar-stdout]
prefix for debugging.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 12:11:13 -07:00
Gitea Actions
36b4f7dad5 chore: bump version to 2.0.1 [skip ci] 2026-04-07 15:58:01 +00:00
Developer
1ecb23b83f Bump to v2.0.0 — cross-platform Tauri rewrite
All checks were successful
Release / Run Tests (push) Successful in 9s
Tests / Python Backend Tests (push) Successful in 5s
Tests / Frontend Tests (push) Successful in 6s
Tests / Rust Sidecar Tests (push) Successful in 2m7s
Release / Bump version and tag (push) Successful in 4s
Major version bump reflecting the architecture change from PySide6/Qt
to Tauri v2 + Svelte 5 with cross-platform support for Windows,
macOS, and Linux.

Key changes since v1.4.0:
- Tauri v2 native desktop shell replacing PySide6/Qt
- Svelte 5 reactive frontend
- Headless Python backend as a downloadable sidecar
- Deepgram cloud transcription (managed + BYOK)
- Gitea CI/CD with per-OS builds and automated releases
- Sidecar auto-update checking on startup
- 63-test suite (Python + Svelte + Rust)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 08:55:25 -07:00
Developer
4b88871a9b Add sidecar update check on startup
Some checks failed
Release / Run Tests (push) Successful in 11s
Tests / Python Backend Tests (push) Successful in 5s
Tests / Frontend Tests (push) Successful in 7s
Release / Bump version and tag (push) Has been cancelled
Tests / Rust Sidecar Tests (push) Has been cancelled
On launch, after confirming the sidecar is installed, the app now
checks for a newer sidecar version via the Gitea API. If an update
is available, shows a prompt with "Update Now" or "Skip":

- Update Now: shows the SidecarSetup download screen
- Skip: launches the existing sidecar version

The update check is non-blocking -- if it fails (no internet, API
error), the app silently proceeds with the current version.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 08:54:18 -07:00
Gitea Actions
0ae48a67d5 chore: bump version to 1.4.21 [skip ci] 2026-04-07 15:51:32 +00:00
Developer
924cae6c75 Fix double-prefix sidecar dir and stale PID lock on startup
All checks were successful
Release / Run Tests (push) Successful in 29s
Tests / Python Backend Tests (push) Successful in 8s
Tests / Frontend Tests (push) Successful in 10s
Tests / Rust Sidecar Tests (push) Successful in 4m7s
Release / Bump version and tag (push) Successful in 4s
Two bugs preventing sidecar from starting:

1. Directory was "sidecar-sidecar-v1.0.3" (double prefix) because
   sidecar_dir_for_version() prepended "sidecar-" to a version that
   already contained it. Now uses the tag directly as the dir name.

2. After a crash, the Python InstanceLock PID file at
   ~/.local-transcription/app.lock remained, blocking the next launch
   with "Another instance is already running". Now clears the stale
   lock file before spawning the sidecar.

Also fixed cleanup_old_versions() and tests to match the corrected
directory naming.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 08:46:31 -07:00
Gitea Actions
5139936e18 chore: bump version to 1.4.20 [skip ci] 2026-04-07 15:44:54 +00:00
Developer
47724f1ac0 Capture sidecar stderr to sidecar.log for crash debugging
All checks were successful
Release / Run Tests (push) Successful in 15s
Tests / Python Backend Tests (push) Successful in 7s
Tests / Frontend Tests (push) Successful in 10s
Tests / Rust Sidecar Tests (push) Successful in 2m30s
Release / Bump version and tag (push) Successful in 12s
When the sidecar process exits before sending the ready event, the
error message now includes the last 10 lines of stderr. Stderr is
captured in a background thread and written to sidecar.log in the
app data directory.

This helps diagnose why the PyInstaller sidecar fails to start
(missing DLLs, import errors, permission issues, etc.).

Log location: %APPDATA%\net.anhonesthost.local-transcription\sidecar.log

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 08:41:40 -07:00
Developer
3b204be37e Add automatic cleanup of old releases to save storage
All checks were successful
Tests / Python Backend Tests (push) Successful in 13s
Tests / Frontend Tests (push) Successful in 23s
Tests / Rust Sidecar Tests (push) Successful in 2m8s
- App releases: keeps latest 3 + v1.4.0 (last pre-Tauri version),
  deletes older releases and their tags
- Sidecar releases: keeps latest 2, deletes older releases and tags
  (sidecars are large, ~500MB-2GB each)

Cleanup runs after creating new releases, before triggering builds.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 08:10:55 -07:00
Developer
4c02a48135 Fix CI: use uv for test venv, gate builds on tests, reduce build triggers
All checks were successful
Tests / Python Backend Tests (push) Successful in 6s
Tests / Frontend Tests (push) Successful in 7s
Tests / Rust Sidecar Tests (push) Successful in 2m7s
- test.yml: use uv venv instead of pip --break-system-packages
- release.yml: inline test job that must pass before version bump;
  only triggers on source file changes (src/, src-tauri/, package.json)
- sidecar-release.yml: inline Python test job that must pass before
  sidecar version bump
- Both coordinators use `needs: test` so builds never start if tests fail

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 08:05:49 -07:00
15 changed files with 335 additions and 104 deletions

View File

@@ -13,23 +13,14 @@ jobs:
runs-on: ubuntu-latest
env:
NODE_VERSION: "20"
RELEASE_TAG: ${{ inputs.tag }}
steps:
- name: Determine tag
id: tag
run: |
TAG="${{ inputs.tag }}"
if [ -z "$TAG" ]; then
TAG="${{ github.event.inputs.tag }}"
fi
if [ -z "$TAG" ]; then
TAG=$(git ls-remote --tags --sort=-v:refname origin 'refs/tags/v*' | head -1 | sed 's|.*refs/tags/||')
fi
echo "Building for tag: ${TAG}"
echo "tag=${TAG}" >> $GITHUB_OUTPUT
- name: Show tag
run: echo "Building for tag: ${RELEASE_TAG}"
- uses: actions/checkout@v4
with:
ref: ${{ steps.tag.outputs.tag }}
ref: ${{ inputs.tag }}
- name: Set up Node.js
uses: actions/setup-node@v4
@@ -58,7 +49,7 @@ jobs:
run: |
sudo apt-get install -y jq
REPO_API="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}"
TAG="${{ steps.tag.outputs.tag }}"
TAG="${RELEASE_TAG}"
echo "Release tag: ${TAG}"
echo "Waiting for release ${TAG} to be available..."

View File

@@ -13,23 +13,14 @@ jobs:
runs-on: macos-latest
env:
NODE_VERSION: "20"
RELEASE_TAG: ${{ inputs.tag }}
steps:
- name: Determine tag
id: tag
run: |
TAG="${{ inputs.tag }}"
if [ -z "$TAG" ]; then
TAG="${{ github.event.inputs.tag }}"
fi
if [ -z "$TAG" ]; then
TAG=$(git ls-remote --tags --sort=-v:refname origin 'refs/tags/v*' | head -1 | sed 's|.*refs/tags/||')
fi
echo "Building for tag: ${TAG}"
echo "tag=${TAG}" >> $GITHUB_OUTPUT
- name: Show tag
run: echo "Building for tag: ${RELEASE_TAG}"
- uses: actions/checkout@v4
with:
ref: ${{ steps.tag.outputs.tag }}
ref: ${{ inputs.tag }}
- name: Set up Node.js
uses: actions/setup-node@v4
@@ -56,7 +47,7 @@ jobs:
run: |
which jq || brew install jq
REPO_API="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}"
TAG="${{ steps.tag.outputs.tag }}"
TAG="${RELEASE_TAG}"
echo "Release tag: ${TAG}"
echo "Waiting for release ${TAG} to be available..."

View File

@@ -13,23 +13,14 @@ jobs:
runs-on: ubuntu-latest
env:
PYTHON_VERSION: "3.11"
RELEASE_TAG: ${{ inputs.tag }}
steps:
- name: Determine tag
id: tag
run: |
TAG="${{ inputs.tag }}"
if [ -z "$TAG" ]; then
TAG="${{ github.event.inputs.tag }}"
fi
if [ -z "$TAG" ]; then
TAG=$(git ls-remote --tags --sort=-v:refname origin 'refs/tags/sidecar-v*' | head -1 | sed 's|.*refs/tags/||')
fi
echo "Building for tag: ${TAG}"
echo "tag=${TAG}" >> $GITHUB_OUTPUT
- name: Show tag
run: echo "Building for tag: ${RELEASE_TAG}"
- uses: actions/checkout@v4
with:
ref: ${{ steps.tag.outputs.tag }}
ref: ${{ inputs.tag }}
- name: Install uv
run: |
@@ -75,7 +66,7 @@ jobs:
run: |
sudo apt-get install -y jq
REPO_API="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}"
TAG="${{ steps.tag.outputs.tag }}"
TAG="${RELEASE_TAG}"
echo "Waiting for sidecar release ${TAG} to be available..."
for i in $(seq 1 30); do

View File

@@ -13,23 +13,14 @@ jobs:
runs-on: macos-latest
env:
PYTHON_VERSION: "3.11"
RELEASE_TAG: ${{ inputs.tag }}
steps:
- name: Determine tag
id: tag
run: |
TAG="${{ inputs.tag }}"
if [ -z "$TAG" ]; then
TAG="${{ github.event.inputs.tag }}"
fi
if [ -z "$TAG" ]; then
TAG=$(git ls-remote --tags --sort=-v:refname origin 'refs/tags/sidecar-v*' | head -1 | sed 's|.*refs/tags/||')
fi
echo "Building for tag: ${TAG}"
echo "tag=${TAG}" >> $GITHUB_OUTPUT
- name: Show tag
run: echo "Building for tag: ${RELEASE_TAG}"
- uses: actions/checkout@v4
with:
ref: ${{ steps.tag.outputs.tag }}
ref: ${{ inputs.tag }}
- name: Install uv
run: |
@@ -66,7 +57,7 @@ jobs:
run: |
which jq || brew install jq
REPO_API="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}"
TAG="${{ steps.tag.outputs.tag }}"
TAG="${RELEASE_TAG}"
echo "Waiting for sidecar release ${TAG} to be available..."
for i in $(seq 1 30); do

View File

@@ -3,11 +3,45 @@ name: Release
on:
push:
branches: [main]
paths:
- 'src/**'
- 'src-tauri/**'
- 'package.json'
- 'vite.config.ts'
- 'index.html'
jobs:
test:
name: Run Tests
if: "!contains(github.event.head_commit.message, '[skip ci]')"
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- name: Install npm deps
run: npm ci
- name: Frontend tests
run: npx vitest run
- name: Install uv
run: |
curl -LsSf https://astral.sh/uv/install.sh | sh
echo "$HOME/.local/bin" >> $GITHUB_PATH
- name: Python tests
run: |
uv venv .testvenv
VIRTUAL_ENV=.testvenv uv pip install pytest httpx pytest-asyncio anyio fastapi pydantic pyyaml uvicorn requests
.testvenv/bin/python -m pytest backend/tests/ client/tests/ -v --tb=short
bump-version:
name: Bump version and tag
if: "!contains(github.event.head_commit.message, '[skip ci]')"
needs: test
runs-on: ubuntu-latest
outputs:
new_version: ${{ steps.bump.outputs.new_version }}
@@ -89,3 +123,43 @@ jobs:
"${REPO_API}/actions/workflows/${workflow}/dispatches")
echo " -> HTTP ${HTTP_CODE}"
done
- name: Clean up old app releases
env:
BUILD_TOKEN: ${{ secrets.BUILD_TOKEN }}
run: |
REPO_API="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}"
KEEP=3
PROTECT_TAG="v1.4.0"
echo "Cleaning up old app releases (keeping latest ${KEEP} + ${PROTECT_TAG})..."
# Get all app releases (v* tags, not sidecar-v*)
RELEASES=$(curl -s -H "Authorization: token ${BUILD_TOKEN}" \
"${REPO_API}/releases?limit=50" | jq -c '[.[] | select(.tag_name | startswith("v")) | select(.tag_name | startswith("sidecar") | not)]')
TOTAL=$(echo "$RELEASES" | jq 'length')
echo "Found ${TOTAL} app releases"
if [ "$TOTAL" -le "$KEEP" ]; then
echo "Nothing to clean up"
exit 0
fi
# Skip the newest KEEP releases, delete the rest (except protected)
echo "$RELEASES" | jq -c ".[$KEEP:][]" | while read -r release; do
ID=$(echo "$release" | jq -r '.id')
TAG=$(echo "$release" | jq -r '.tag_name')
if [ "$TAG" = "$PROTECT_TAG" ]; then
echo " Protecting ${TAG}"
continue
fi
echo " Deleting release ${TAG} (ID: ${ID})..."
curl -s -X DELETE -H "Authorization: token ${BUILD_TOKEN}" \
"${REPO_API}/releases/${ID}"
# Keep the git tag -- only delete the release (assets).
# Deleting tags breaks builds that haven't checked out yet.
done
echo "Cleanup complete"

View File

@@ -12,8 +12,27 @@ on:
workflow_dispatch:
jobs:
test:
name: Run Tests
if: "!contains(github.event.head_commit.message, '[skip ci]')"
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install uv
run: |
curl -LsSf https://astral.sh/uv/install.sh | sh
echo "$HOME/.local/bin" >> $GITHUB_PATH
- name: Python tests
run: |
uv venv .testvenv
VIRTUAL_ENV=.testvenv uv pip install pytest httpx pytest-asyncio anyio fastapi pydantic pyyaml uvicorn requests
.testvenv/bin/python -m pytest backend/tests/ client/tests/ -v --tb=short
bump-sidecar-version:
name: Bump sidecar version and tag
needs: test
if: "!contains(github.event.head_commit.message, '[skip ci]')"
runs-on: ubuntu-latest
outputs:
@@ -61,7 +80,6 @@ jobs:
NEW_VERSION="${MAJOR}.${MINOR}.${NEW_PATCH}"
echo "New sidecar version: ${NEW_VERSION}"
# Only update pyproject.toml -- version.py is owned by the app release workflow
sed -i "s/^version = \"${CURRENT}\"/version = \"${NEW_VERSION}\"/" pyproject.toml
echo "version=${NEW_VERSION}" >> $GITHUB_OUTPUT
@@ -117,3 +135,38 @@ jobs:
"${REPO_API}/actions/workflows/${workflow}/dispatches")
echo " -> HTTP ${HTTP_CODE}"
done
- name: Clean up old sidecar releases
if: steps.check_changes.outputs.has_changes == 'true'
env:
BUILD_TOKEN: ${{ secrets.BUILD_TOKEN }}
run: |
REPO_API="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}"
KEEP=2
echo "Cleaning up old sidecar releases (keeping latest ${KEEP})..."
# Get all sidecar releases (sidecar-v* tags)
RELEASES=$(curl -s -H "Authorization: token ${BUILD_TOKEN}" \
"${REPO_API}/releases?limit=50" | jq -c '[.[] | select(.tag_name | startswith("sidecar-v"))]')
TOTAL=$(echo "$RELEASES" | jq 'length')
echo "Found ${TOTAL} sidecar releases"
if [ "$TOTAL" -le "$KEEP" ]; then
echo "Nothing to clean up"
exit 0
fi
# Skip the newest KEEP releases, delete the rest
echo "$RELEASES" | jq -c ".[$KEEP:][]" | while read -r release; do
ID=$(echo "$release" | jq -r '.id')
TAG=$(echo "$release" | jq -r '.tag_name')
echo " Deleting sidecar release ${TAG} (ID: ${ID})..."
curl -s -X DELETE -H "Authorization: token ${BUILD_TOKEN}" \
"${REPO_API}/releases/${ID}"
# Keep the git tag -- only delete the release (assets).
# Deleting tags breaks builds that haven't checked out yet.
done
echo "Cleanup complete"

View File

@@ -5,6 +5,7 @@ on:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:
jobs:
python-tests:
@@ -13,12 +14,20 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Install test dependencies
- name: Install uv
run: |
pip install --break-system-packages pytest httpx pytest-asyncio anyio fastapi pydantic pyyaml uvicorn requests
if command -v uv &> /dev/null; then
echo "uv already installed: $(uv --version)"
else
curl -LsSf https://astral.sh/uv/install.sh | sh
echo "$HOME/.local/bin" >> $GITHUB_PATH
fi
- name: Run pytest
run: python3 -m pytest backend/tests/ client/tests/ -v --tb=short
run: |
uv venv .testvenv
VIRTUAL_ENV=.testvenv uv pip install pytest httpx pytest-asyncio anyio fastapi pydantic pyyaml uvicorn requests
.testvenv/bin/python -m pytest backend/tests/ client/tests/ -v --tb=short
frontend-tests:
name: Frontend Tests

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
[package]
name = "local-transcription"
version = "1.4.19"
version = "2.0.3"
description = "Real-time speech-to-text transcription for streamers"
authors = ["Local Transcription Contributors"]
edition = "2021"

View File

@@ -29,9 +29,9 @@ pub fn run() {
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_process::init())
.manage(sidecar::ManagedSidecar(Mutex::new(
.manage(sidecar::ManagedSidecar(std::sync::Arc::new(Mutex::new(
sidecar::SidecarManager::new(),
)))
))))
.setup(|app| {
let resource_dir = app
.path()

View File

@@ -54,7 +54,8 @@ fn read_installed_version() -> Option<String> {
}
fn sidecar_dir_for_version(version: &str) -> PathBuf {
data_dir().join(format!("sidecar-{version}"))
// version is the full tag name, e.g. "sidecar-v1.0.3" -- use it directly
data_dir().join(version)
}
fn binary_path_for_version(version: &str) -> PathBuf {
@@ -371,12 +372,12 @@ fn extract_zip(zip_path: &std::path::Path, dest: &std::path::Path) -> Result<(),
fn cleanup_old_versions(current_version: &str) {
let data = data_dir();
let current_dir_name = format!("sidecar-{current_version}");
// current_version is already the full tag, e.g. "sidecar-v1.0.3"
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
if name.starts_with("sidecar-")
&& name != current_version
&& entry.path().is_dir()
{
let _ = std::fs::remove_dir_all(entry.path());
@@ -433,6 +434,20 @@ impl SidecarManager {
.ok_or_else(|| "Sidecar running but port unknown".into());
}
// Clear stale PID lock from a previous crash so the sidecar can start.
// The Python InstanceLock writes to ~/.local-transcription/app.lock
if let Ok(home) = std::env::var("USERPROFILE")
.or_else(|_| std::env::var("HOME"))
{
let lock_file = PathBuf::from(home)
.join(".local-transcription")
.join("app.lock");
if lock_file.exists() {
eprintln!("[sidecar] Removing stale lock file: {}", lock_file.display());
let _ = std::fs::remove_file(&lock_file);
}
}
let is_dev = cfg!(debug_assertions)
|| std::env::var("LOCAL_TRANSCRIPTION_DEV")
.map(|v| v == "1")
@@ -463,11 +478,63 @@ impl SidecarManager {
.take()
.ok_or("Failed to capture sidecar stdout")?;
let port = Self::wait_for_ready(stdout)?;
// Capture stderr in a background thread so we can log it
let stderr = child
.stderr
.take()
.ok_or("Failed to capture sidecar stderr")?;
self.child = Some(child);
self.port = Some(port);
Ok(port)
let log_dir = DIRS.get().map(|d| d.data_dir.clone());
std::thread::spawn(move || {
use std::io::BufRead;
let reader = std::io::BufReader::new(stderr);
let mut log_file = log_dir.and_then(|d| {
std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(d.join("sidecar.log"))
.ok()
});
for line in reader.lines() {
if let Ok(line) = line {
eprintln!("[sidecar-stderr] {}", line);
if let Some(ref mut f) = log_file {
use std::io::Write;
let _ = writeln!(f, "{}", line);
}
}
}
});
match Self::wait_for_ready(stdout) {
Ok(port) => {
self.child = Some(child);
self.port = Some(port);
Ok(port)
}
Err(e) => {
// Kill the child if ready failed
let _ = child.kill();
let _ = child.wait();
// Read the sidecar.log for context
let log_hint = DIRS
.get()
.and_then(|d| std::fs::read_to_string(d.data_dir.join("sidecar.log")).ok())
.and_then(|s| {
let lines: Vec<&str> = s.lines().collect();
let tail: Vec<&str> = lines.iter().rev().take(10).rev().cloned().collect();
if tail.is_empty() { None } else { Some(tail.join("\n")) }
})
.unwrap_or_default();
if log_hint.is_empty() {
Err(e)
} else {
Err(format!("{e}\n\nSidecar stderr (last 10 lines):\n{log_hint}"))
}
}
}
}
/// Stop the sidecar process if running.
@@ -488,7 +555,7 @@ impl SidecarManager {
fn build_dev_command(&self) -> Result<std::process::Command, String> {
let mut cmd = std::process::Command::new("python");
cmd.args(["-m", "backend.main_headless"]);
cmd.args(["-u", "-m", "backend.main_headless"]); // -u = unbuffered
// Try to find the project root (parent of src-tauri)
if let Some(dirs) = DIRS.get() {
@@ -501,6 +568,7 @@ impl SidecarManager {
}
}
cmd.env("PYTHONUNBUFFERED", "1");
Ok(cmd)
}
@@ -516,27 +584,51 @@ impl SidecarManager {
bin.parent()
.ok_or("Cannot determine sidecar parent dir")?,
);
// Force unbuffered stdout so the ready event is sent immediately.
// PyInstaller frozen executables buffer stdout when piped.
cmd.env("PYTHONUNBUFFERED", "1");
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();
use std::sync::mpsc;
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);
let timeout = std::time::Duration::from_secs(120);
// Read stdout in a background thread so we can enforce a real timeout.
// BufReader::lines() blocks indefinitely if no data arrives.
let (tx, rx) = mpsc::channel();
std::thread::spawn(move || {
let reader = std::io::BufReader::new(stdout);
for line in reader.lines() {
match line {
Ok(line) => {
eprintln!("[sidecar-stdout] {}", line);
if let Ok(evt) = serde_json::from_str::<ReadyEvent>(&line) {
if evt.event == "ready" {
let _ = tx.send(Ok(evt.port));
return;
}
}
}
Err(e) => {
let _ = tx.send(Err(format!("IO error reading stdout: {e}")));
return;
}
}
}
// Ignore other lines (e.g. log output)
}
Err("Sidecar process exited before sending ready event".into())
let _ = tx.send(Err(
"Sidecar process exited before sending ready event".into(),
));
});
rx.recv_timeout(timeout).unwrap_or_else(|_| {
Err(format!(
"Timed out after {}s waiting for sidecar ready event",
timeout.as_secs()
))
})
}
}
@@ -545,7 +637,8 @@ impl SidecarManager {
// ---------------------------------------------------------------------------
/// Wrapper so we can store `SidecarManager` in Tauri's managed state.
pub struct ManagedSidecar(pub Mutex<SidecarManager>);
/// Uses Arc so it can be cloned into background threads for async commands.
pub struct ManagedSidecar(pub std::sync::Arc<Mutex<SidecarManager>>);
#[tauri::command]
pub fn get_sidecar_port(state: tauri::State<'_, ManagedSidecar>) -> Result<Option<u16>, String> {
@@ -561,12 +654,16 @@ pub fn get_sidecar_port(state: tauri::State<'_, ManagedSidecar>) -> Result<Optio
}
#[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()
pub async fn start_sidecar(state: tauri::State<'_, ManagedSidecar>) -> Result<u16, String> {
let mgr = state.0.clone();
// Run blocking sidecar launch in a background thread so it doesn't
// freeze the Tauri UI while waiting for the ready event (up to 120s).
tokio::task::spawn_blocking(move || {
let mut mgr = mgr.lock().map_err(|e| format!("Lock error: {e}"))?;
mgr.ensure_running()
})
.await
.map_err(|e| format!("Task join error: {e}"))?
}
#[tauri::command]
@@ -739,7 +836,7 @@ mod tests {
fn sidecar_dir_for_version_contains_version() {
let data = ensure_dirs_initialised();
let dir = sidecar_dir_for_version("sidecar-v1.2.3");
assert_eq!(dir, data.join("sidecar-sidecar-v1.2.3"));
assert_eq!(dir, data.join("sidecar-v1.2.3"));
}
#[test]
@@ -784,9 +881,8 @@ mod tests {
std::fs::create_dir_all(data.join(d)).unwrap();
}
// cleanup_old_versions builds `current_dir_name = "sidecar-{version}"`.
// Passing "v1.0.2" produces "sidecar-v1.0.2" which matches our dir name.
cleanup_old_versions("v1.0.2");
// current_version is the full tag, e.g. "sidecar-v1.0.2"
cleanup_old_versions("sidecar-v1.0.2");
assert!(
!data.join("sidecar-v1.0.0").exists(),

View File

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

View File

@@ -9,11 +9,12 @@
import { backendStore } from "$lib/stores/backend";
import { configStore } from "$lib/stores/config";
type SidecarState = "checking" | "needs_setup" | "starting" | "connected";
type SidecarState = "checking" | "needs_setup" | "update_available" | "starting" | "connected";
let showSettings = $state(false);
let sidecarState = $state<SidecarState>("checking");
let debugLog = $state("");
let availableUpdate = $state("");
let obsDisplayUrl = $derived(backendStore.obsUrl);
let syncDisplayUrl = $derived(backendStore.syncUrl);
@@ -53,6 +54,20 @@
return;
}
// Check for sidecar updates before launching
try {
log("Checking for sidecar updates...");
const update = await invoke<string | null>("check_sidecar_update");
if (update) {
log(`Sidecar update available: ${update}`);
availableUpdate = update;
sidecarState = "update_available";
return;
}
} catch (err) {
log(`Update check failed (non-fatal): ${err}`);
}
await launchSidecar();
} catch (err) {
// Not running in Tauri (browser dev mode) - skip sidecar check
@@ -118,6 +133,26 @@
{:else if sidecarState === "needs_setup"}
<SidecarSetup onComplete={onSidecarReady} />
{:else if sidecarState === "update_available"}
<div class="connecting-overlay" style="background:#1e1e1e;color:#e0e0e0;display:flex;align-items:center;justify-content:center;height:100%;width:100%;">
<div class="connecting-content" style="text-align:center;max-width:400px;">
<h2 style="font-size:20px;margin:0 0 12px;">Sidecar Update Available</h2>
<p style="color:#a0a0a0;font-size:14px;margin:0 0 20px;">
A new version of the transcription engine is available ({availableUpdate}).
</p>
<div style="display:flex;gap:10px;justify-content:center;">
<button
style="padding:8px 20px;border:1px solid #555;border-radius:6px;background:transparent;color:#e0e0e0;cursor:pointer;"
onclick={() => launchSidecar()}
>Skip</button>
<button
style="padding:8px 20px;border:none;border-radius:6px;background:#4CAF50;color:white;cursor:pointer;font-weight:500;"
onclick={() => { sidecarState = "needs_setup"; }}
>Update Now</button>
</div>
</div>
</div>
{:else if !isConnected}
<div class="connecting-overlay" style="background:#1e1e1e;color:#e0e0e0;display:flex;align-items:center;justify-content:center;height:100%;width:100%;">
<div class="connecting-content" style="text-align:center;">

View File

@@ -1,7 +1,7 @@
"""Version information for Local Transcription."""
__version__ = "1.4.19"
__version_info__ = (1, 4, 19)
__version__ = "2.0.3"
__version_info__ = (2, 0, 3)
# Version history:
# 1.4.0 - Auto-update feature: