Compare commits

..

1 Commits

Author SHA1 Message Date
Developer
0a01dfb3fa Fix sidecar builds: macOS CUDA resolution and Windows uv install
macOS: pyproject.toml's [tool.uv.sources] forces torch from the CUDA
index which has no macOS ARM wheels. Use `uv sync --no-sources` to
bypass this and get torch from PyPI (which includes MPS support).

Windows: Add additional uv PATH locations ($LOCALAPPDATA\uv\bin) for
robustness with different runner environments.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 13:48:19 -07:00
59 changed files with 771 additions and 6145 deletions

View File

@@ -1,95 +0,0 @@
name: Build App (Linux)
on:
workflow_dispatch:
inputs:
tag:
description: 'Release tag to build (e.g. v1.4.5)'
required: true
jobs:
build-linux:
name: Build App (Linux)
runs-on: ubuntu-latest
env:
NODE_VERSION: "20"
RELEASE_TAG: "${{ inputs.tag }}"
steps:
- name: Show tag
run: |
echo "Building for tag: ${RELEASE_TAG}"
- uses: actions/checkout@v4
with:
ref: ${{ inputs.tag }}
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- name: Install Rust stable
run: |
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable
echo "$HOME/.cargo/bin" >> $GITHUB_PATH
- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf xdg-utils rpm
- name: Install npm dependencies
run: npm ci
- name: Build Tauri app
run: npm run tauri build
- name: Upload to release
env:
BUILD_TOKEN: ${{ secrets.BUILD_TOKEN }}
run: |
sudo apt-get install -y jq
REPO_API="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}"
TAG="${RELEASE_TAG}"
echo "Release tag: ${TAG}"
echo "Waiting for release ${TAG} to be available..."
RELEASE_ID=""
for i in $(seq 1 30); do
RELEASE_JSON=$(curl -s -H "Authorization: token ${BUILD_TOKEN}" \
"${REPO_API}/releases/tags/${TAG}")
RELEASE_ID=$(echo "$RELEASE_JSON" | jq -r '.id // empty')
if [ -n "${RELEASE_ID}" ] && [ "${RELEASE_ID}" != "null" ]; then
echo "Found release: ${TAG} (ID: ${RELEASE_ID})"
break
fi
echo "Attempt ${i}/30: Release not ready yet, retrying in 10s..."
sleep 10
done
if [ -z "${RELEASE_ID}" ] || [ "${RELEASE_ID}" = "null" ]; then
echo "ERROR: Failed to find release for tag ${TAG} after 30 attempts."
exit 1
fi
find src-tauri/target/release/bundle -type f \( -name "*.deb" -o -name "*.rpm" -o -name "*.AppImage" \) | while IFS= read -r file; do
filename=$(basename "$file")
encoded_name=$(echo "$filename" | sed 's/ /%20/g')
echo "Uploading ${filename} ($(du -h "$file" | cut -f1))..."
ASSET_ID=$(curl -s -H "Authorization: token ${BUILD_TOKEN}" \
"${REPO_API}/releases/${RELEASE_ID}/assets" | jq -r ".[] | select(.name == \"${filename}\") | .id // empty")
if [ -n "${ASSET_ID}" ]; then
curl -s -X DELETE -H "Authorization: token ${BUILD_TOKEN}" \
"${REPO_API}/releases/${RELEASE_ID}/assets/${ASSET_ID}"
fi
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" -X POST \
-H "Authorization: token ${BUILD_TOKEN}" \
-H "Content-Type: application/octet-stream" \
-T "$file" \
"${REPO_API}/releases/${RELEASE_ID}/assets?name=${encoded_name}")
echo "Upload response: HTTP ${HTTP_CODE}"
done

View File

@@ -1,93 +0,0 @@
name: Build App (macOS)
on:
workflow_dispatch:
inputs:
tag:
description: 'Release tag to build (e.g. v1.4.5)'
required: true
jobs:
build-macos:
name: Build App (macOS)
runs-on: macos-latest
env:
NODE_VERSION: "20"
RELEASE_TAG: "${{ inputs.tag }}"
steps:
- name: Show tag
run: |
echo "Building for tag: ${RELEASE_TAG}"
- uses: actions/checkout@v4
with:
ref: ${{ inputs.tag }}
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- name: Install Rust stable
run: |
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable
echo "$HOME/.cargo/bin" >> $GITHUB_PATH
- name: Install system dependencies
run: brew install --quiet create-dmg || true
- name: Install npm dependencies
run: npm ci
- name: Build Tauri app
run: npm run tauri build
- name: Upload to release
env:
BUILD_TOKEN: ${{ secrets.BUILD_TOKEN }}
run: |
which jq || brew install jq
REPO_API="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}"
TAG="${RELEASE_TAG}"
echo "Release tag: ${TAG}"
echo "Waiting for release ${TAG} to be available..."
RELEASE_ID=""
for i in $(seq 1 30); do
RELEASE_JSON=$(curl -s -H "Authorization: token ${BUILD_TOKEN}" \
"${REPO_API}/releases/tags/${TAG}")
RELEASE_ID=$(echo "$RELEASE_JSON" | jq -r '.id // empty')
if [ -n "${RELEASE_ID}" ] && [ "${RELEASE_ID}" != "null" ]; then
echo "Found release: ${TAG} (ID: ${RELEASE_ID})"
break
fi
echo "Attempt ${i}/30: Release not ready yet, retrying in 10s..."
sleep 10
done
if [ -z "${RELEASE_ID}" ] || [ "${RELEASE_ID}" = "null" ]; then
echo "ERROR: Failed to find release for tag ${TAG} after 30 attempts."
exit 1
fi
find src-tauri/target/release/bundle -type f -name "*.dmg" | while IFS= read -r file; do
filename=$(basename "$file")
encoded_name=$(echo "$filename" | sed 's/ /%20/g')
echo "Uploading ${filename} ($(du -h "$file" | cut -f1))..."
ASSET_ID=$(curl -s -H "Authorization: token ${BUILD_TOKEN}" \
"${REPO_API}/releases/${RELEASE_ID}/assets" | jq -r ".[] | select(.name == \"${filename}\") | .id // empty")
if [ -n "${ASSET_ID}" ]; then
curl -s -X DELETE -H "Authorization: token ${BUILD_TOKEN}" \
"${REPO_API}/releases/${RELEASE_ID}/assets/${ASSET_ID}"
fi
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" -X POST \
-H "Authorization: token ${BUILD_TOKEN}" \
-H "Content-Type: application/octet-stream" \
-T "$file" \
"${REPO_API}/releases/${RELEASE_ID}/assets?name=${encoded_name}")
echo "Upload response: HTTP ${HTTP_CODE}"
done

View File

@@ -1,117 +0,0 @@
name: Build App (Windows)
on:
workflow_dispatch:
inputs:
tag:
description: 'Release tag to build (e.g. v1.4.5)'
required: true
env:
NODE_VERSION: "20"
jobs:
build-windows:
name: Build App (Windows)
runs-on: windows-latest
env:
RELEASE_TAG: "${{ inputs.tag }}"
steps:
- name: Show tag
shell: powershell
run: |
Write-Host "Building for tag: $env:RELEASE_TAG"
- uses: actions/checkout@v4
with:
ref: ${{ inputs.tag }}
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- name: Install Rust stable
shell: powershell
run: |
if (Get-Command rustup -ErrorAction SilentlyContinue) {
rustup default stable
} else {
Invoke-WebRequest -Uri https://win.rustup.rs/x86_64 -OutFile rustup-init.exe
.\rustup-init.exe -y --default-toolchain stable
echo "$env:USERPROFILE\.cargo\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
}
- name: Install npm dependencies
shell: powershell
run: npm ci
- name: Build Tauri app
shell: powershell
run: npm run tauri build
- name: Upload to release
shell: powershell
env:
BUILD_TOKEN: ${{ secrets.BUILD_TOKEN }}
run: |
$REPO_API = "${{ github.server_url }}/api/v1/repos/${{ github.repository }}"
$Headers = @{ "Authorization" = "token $env:BUILD_TOKEN" }
$TAG = $env:RELEASE_TAG
Write-Host "Release tag: $TAG"
if (-not $TAG) {
Write-Host "ERROR: RELEASE_TAG is empty"
exit 1
}
Write-Host "Waiting for release $TAG to be available..."
$RELEASE_ID = $null
for ($i = 1; $i -le 30; $i++) {
try {
$release = Invoke-RestMethod -Uri "$REPO_API/releases/tags/$TAG" -Headers $Headers -ErrorAction Stop
$RELEASE_ID = $release.id
if ($RELEASE_ID) {
Write-Host "Found release: $TAG (ID: $RELEASE_ID)"
break
}
} catch {}
Write-Host "Attempt ${i}/30: Release not ready yet, retrying in 10s..."
Start-Sleep -Seconds 10
}
if (-not $RELEASE_ID) {
Write-Host "ERROR: Failed to find release for tag $TAG after 30 attempts."
exit 1
}
Get-ChildItem -Path src-tauri\target\release\bundle -Recurse -Include *.msi,*-setup.exe | ForEach-Object {
$filename = $_.Name
$encodedName = [System.Uri]::EscapeDataString($filename)
$size = [math]::Round($_.Length / 1MB, 1)
Write-Host "Uploading $filename ($size MB)..."
try {
$assets = Invoke-RestMethod -Uri "$REPO_API/releases/$RELEASE_ID/assets" -Headers $Headers
$existing = $assets | Where-Object { $_.name -eq $filename }
if ($existing) {
Invoke-RestMethod -Uri "$REPO_API/releases/$RELEASE_ID/assets/$($existing.id)" -Method Delete -Headers $Headers
}
} catch {}
$uploadUrl = "$REPO_API/releases/$RELEASE_ID/assets?name=$encodedName"
$result = curl.exe --fail --silent --show-error `
-X POST `
-H "Authorization: token $env:BUILD_TOKEN" `
-H "Content-Type: application/octet-stream" `
-T "$($_.FullName)" `
"$uploadUrl" 2>&1
if ($LASTEXITCODE -eq 0) {
Write-Host "Upload successful: $filename"
} else {
Write-Host "WARNING: Upload failed for ${filename}: $result"
}
}

View File

@@ -1,229 +0,0 @@
name: Build Sidecar (Cloud)
on:
workflow_dispatch:
inputs:
tag:
description: 'Sidecar release tag to build (e.g. sidecar-v1.0.5)'
required: true
jobs:
build-cloud-linux:
name: Build Cloud Sidecar (Linux)
runs-on: ubuntu-latest
env:
PYTHON_VERSION: "3.11"
RELEASE_TAG: "${{ inputs.tag }}"
steps:
- name: Show tag
run: |
echo "Building cloud sidecar for tag ${RELEASE_TAG}"
- uses: actions/checkout@v4
with:
ref: ${{ inputs.tag }}
- name: Install uv
run: |
curl -LsSf https://astral.sh/uv/install.sh | sh
echo "$HOME/.local/bin" >> $GITHUB_PATH
- name: Set up Python
run: uv python install ${{ env.PYTHON_VERSION }}
- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y portaudio19-dev
- name: Build cloud sidecar
env:
UV_NO_SOURCES: "1"
run: |
uv venv
uv pip install pyinstaller numpy sounddevice fastapi uvicorn websockets pydantic requests pyyaml packaging
.venv/bin/pyinstaller local-transcription-cloud.spec
- name: Package
run: |
cd dist/local-transcription-backend && zip -r ../../sidecar-linux-x86_64-cloud.zip .
- name: Upload to release
env:
BUILD_TOKEN: ${{ secrets.BUILD_TOKEN }}
run: |
sudo apt-get install -y jq
REPO_API="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}"
TAG="${RELEASE_TAG}"
for i in $(seq 1 30); do
RELEASE_ID=$(curl -s -H "Authorization: token ${BUILD_TOKEN}" \
"${REPO_API}/releases/tags/${TAG}" | jq -r '.id // empty')
if [ -n "${RELEASE_ID}" ] && [ "${RELEASE_ID}" != "null" ]; then
echo "Found release ${TAG} (ID: ${RELEASE_ID})"
break
fi
echo "Attempt ${i}/30: waiting for release..."
sleep 10
done
if [ -z "${RELEASE_ID}" ] || [ "${RELEASE_ID}" = "null" ]; then
echo "ERROR: Release not found"; exit 1
fi
for file in sidecar-*-cloud.zip; do
filename=$(basename "$file")
ASSET_ID=$(curl -s -H "Authorization: token ${BUILD_TOKEN}" \
"${REPO_API}/releases/${RELEASE_ID}/assets" | jq -r ".[] | select(.name == \"${filename}\") | .id // empty")
[ -n "${ASSET_ID}" ] && curl -s -X DELETE -H "Authorization: token ${BUILD_TOKEN}" "${REPO_API}/releases/${RELEASE_ID}/assets/${ASSET_ID}"
curl -s -o /dev/null -w "Upload ${filename}: HTTP %{http_code}\n" -X POST \
-H "Authorization: token ${BUILD_TOKEN}" -H "Content-Type: application/octet-stream" \
-T "$file" "${REPO_API}/releases/${RELEASE_ID}/assets?name=${filename}"
done
build-cloud-windows:
name: Build Cloud Sidecar (Windows)
runs-on: windows-latest
env:
PYTHON_VERSION: "3.11"
RELEASE_TAG: "${{ inputs.tag }}"
steps:
- name: Show tag
shell: powershell
run: Write-Host "Building cloud sidecar for tag $env:RELEASE_TAG"
- uses: actions/checkout@v4
with:
ref: ${{ inputs.tag }}
- name: Install uv
shell: powershell
run: |
if (Get-Command uv -ErrorAction SilentlyContinue) {
Write-Host "uv already installed"
} else {
irm https://astral.sh/uv/install.ps1 | iex
$uvPaths = @("$env:USERPROFILE\.local\bin", "$env:USERPROFILE\.cargo\bin", "$env:LOCALAPPDATA\uv\bin")
foreach ($p in $uvPaths) { if (Test-Path $p) { echo $p | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append } }
}
- name: Set up Python
shell: powershell
run: uv python install ${{ env.PYTHON_VERSION }}
- name: Build cloud sidecar
shell: powershell
env:
UV_NO_SOURCES: "1"
run: |
uv venv
uv pip install pyinstaller numpy sounddevice fastapi uvicorn websockets pydantic requests pyyaml packaging
.venv\Scripts\pyinstaller.exe local-transcription-cloud.spec
- name: Package
shell: powershell
run: |
if (-not (Get-Command 7z -ErrorAction SilentlyContinue)) { choco install 7zip -y }
7z a -tzip -mx=5 sidecar-windows-x86_64-cloud.zip .\dist\local-transcription-backend\*
- name: Upload to release
shell: powershell
env:
BUILD_TOKEN: ${{ secrets.BUILD_TOKEN }}
run: |
$REPO_API = "${{ github.server_url }}/api/v1/repos/${{ github.repository }}"
$Headers = @{ "Authorization" = "token $env:BUILD_TOKEN" }
$TAG = $env:RELEASE_TAG
$RELEASE_ID = $null
for ($i = 1; $i -le 30; $i++) {
try {
$release = Invoke-RestMethod -Uri "$REPO_API/releases/tags/$TAG" -Headers $Headers -ErrorAction Stop
$RELEASE_ID = $release.id
if ($RELEASE_ID) { Write-Host "Found release $TAG (ID: $RELEASE_ID)"; break }
} catch {}
Write-Host "Attempt ${i}/30: waiting..."; Start-Sleep -Seconds 10
}
if (-not $RELEASE_ID) { Write-Host "ERROR: Release not found"; exit 1 }
Get-ChildItem -Path . -Filter "sidecar-*-cloud.zip" | ForEach-Object {
$fn = $_.Name; $enc = [System.Uri]::EscapeDataString($fn)
try {
$assets = Invoke-RestMethod -Uri "$REPO_API/releases/$RELEASE_ID/assets" -Headers $Headers
$existing = $assets | Where-Object { $_.name -eq $fn }
if ($existing) { Invoke-RestMethod -Uri "$REPO_API/releases/$RELEASE_ID/assets/$($existing.id)" -Method Delete -Headers $Headers }
} catch {}
curl.exe --fail -s -X POST -H "Authorization: token $env:BUILD_TOKEN" -H "Content-Type: application/octet-stream" -T "$($_.FullName)" "$REPO_API/releases/$RELEASE_ID/assets?name=$enc"
Write-Host "Uploaded $fn"
}
build-cloud-macos:
name: Build Cloud Sidecar (macOS)
runs-on: macos-latest
env:
PYTHON_VERSION: "3.11"
RELEASE_TAG: "${{ inputs.tag }}"
steps:
- name: Show tag
run: |
echo "Building cloud sidecar for tag ${RELEASE_TAG}"
- uses: actions/checkout@v4
with:
ref: ${{ inputs.tag }}
- name: Install uv
run: |
curl -LsSf https://astral.sh/uv/install.sh | sh
echo "$HOME/.local/bin" >> $GITHUB_PATH
- name: Set up Python
run: uv python install ${{ env.PYTHON_VERSION }}
- name: Install system dependencies
run: brew install portaudio
- name: Build cloud sidecar
env:
UV_NO_SOURCES: "1"
run: |
uv venv
uv pip install pyinstaller numpy sounddevice fastapi uvicorn websockets pydantic requests pyyaml packaging
.venv/bin/pyinstaller local-transcription-cloud.spec
- name: Package
run: |
cd dist/local-transcription-backend && zip -r ../../sidecar-macos-aarch64-cloud.zip .
- name: Upload to release
env:
BUILD_TOKEN: ${{ secrets.BUILD_TOKEN }}
run: |
which jq || brew install jq
REPO_API="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}"
TAG="${RELEASE_TAG}"
for i in $(seq 1 30); do
RELEASE_ID=$(curl -s -H "Authorization: token ${BUILD_TOKEN}" \
"${REPO_API}/releases/tags/${TAG}" | jq -r '.id // empty')
if [ -n "${RELEASE_ID}" ] && [ "${RELEASE_ID}" != "null" ]; then
echo "Found release ${TAG} (ID: ${RELEASE_ID})"
break
fi
echo "Attempt ${i}/30: waiting for release..."
sleep 10
done
if [ -z "${RELEASE_ID}" ] || [ "${RELEASE_ID}" = "null" ]; then
echo "ERROR: Release not found"; exit 1
fi
for file in sidecar-*-cloud.zip; do
filename=$(basename "$file")
ASSET_ID=$(curl -s -H "Authorization: token ${BUILD_TOKEN}" \
"${REPO_API}/releases/${RELEASE_ID}/assets" | jq -r ".[] | select(.name == \"${filename}\") | .id // empty")
[ -n "${ASSET_ID}" ] && curl -s -X DELETE -H "Authorization: token ${BUILD_TOKEN}" "${REPO_API}/releases/${RELEASE_ID}/assets/${ASSET_ID}"
curl -s -o /dev/null -w "Upload ${filename}: HTTP %{http_code}\n" -X POST \
-H "Authorization: token ${BUILD_TOKEN}" -H "Content-Type: application/octet-stream" \
-T "$file" "${REPO_API}/releases/${RELEASE_ID}/assets?name=${filename}"
done

View File

@@ -1,101 +0,0 @@
name: Build Sidecar (Linux)
on:
workflow_dispatch:
inputs:
tag:
description: 'Sidecar release tag to build (e.g. sidecar-v1.0.3)'
required: true
jobs:
build-sidecar-linux:
name: Build Sidecar (Linux)
runs-on: ubuntu-latest
env:
PYTHON_VERSION: "3.11"
RELEASE_TAG: "${{ inputs.tag }}"
steps:
- name: Show tag
run: |
echo "Building for tag: ${RELEASE_TAG}"
- uses: actions/checkout@v4
with:
ref: ${{ inputs.tag }}
- name: Install uv
run: |
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: Set up Python
run: uv python install ${{ env.PYTHON_VERSION }}
- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y portaudio19-dev
- name: Build sidecar (CPU)
run: |
uv sync --no-sources
# PyPI's default torch on Linux includes CUDA (~800MB).
# Replace with CPU-only torch from the dedicated index.
uv pip install torch torchaudio --index-url https://download.pytorch.org/whl/cpu --force-reinstall
.venv/bin/pyinstaller local-transcription-headless.spec
- name: Package sidecar (CPU)
run: |
cd dist/local-transcription-backend && zip -9 -r ../../sidecar-linux-x86_64-cpu.zip .
- name: Upload to sidecar release
env:
BUILD_TOKEN: ${{ secrets.BUILD_TOKEN }}
run: |
sudo apt-get install -y jq
REPO_API="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}"
TAG="${RELEASE_TAG}"
echo "Waiting for sidecar release ${TAG} to be available..."
for i in $(seq 1 30); do
RELEASE_JSON=$(curl -s -H "Authorization: token ${BUILD_TOKEN}" \
"${REPO_API}/releases/tags/${TAG}")
RELEASE_ID=$(echo "$RELEASE_JSON" | jq -r '.id // empty')
if [ -n "${RELEASE_ID}" ] && [ "${RELEASE_ID}" != "null" ]; then
echo "Found sidecar release: ${TAG} (ID: ${RELEASE_ID})"
break
fi
echo "Attempt ${i}/30: Release not ready yet, retrying in 10s..."
sleep 10
done
if [ -z "${RELEASE_ID}" ] || [ "${RELEASE_ID}" = "null" ]; then
echo "ERROR: Failed to find sidecar release for tag ${TAG} after 30 attempts."
exit 1
fi
for file in sidecar-*.zip; do
filename=$(basename "$file")
encoded_name=$(echo "$filename" | sed 's/ /%20/g')
echo "Uploading ${filename} ($(du -h "$file" | cut -f1))..."
ASSET_ID=$(curl -s -H "Authorization: token ${BUILD_TOKEN}" \
"${REPO_API}/releases/${RELEASE_ID}/assets" | jq -r ".[] | select(.name == \"${filename}\") | .id // empty")
if [ -n "${ASSET_ID}" ]; then
curl -s -X DELETE -H "Authorization: token ${BUILD_TOKEN}" \
"${REPO_API}/releases/${RELEASE_ID}/assets/${ASSET_ID}"
fi
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" -X POST \
-H "Authorization: token ${BUILD_TOKEN}" \
-H "Content-Type: application/octet-stream" \
-T "$file" \
"${REPO_API}/releases/${RELEASE_ID}/assets?name=${encoded_name}")
echo "Upload response: HTTP ${HTTP_CODE}"
done

View File

@@ -1,101 +0,0 @@
name: Build Sidecar (macOS)
on:
workflow_dispatch:
inputs:
tag:
description: 'Sidecar release tag to build (e.g. sidecar-v1.0.3)'
required: true
jobs:
build-sidecar-macos:
name: Build Sidecar (macOS)
runs-on: macos-latest
env:
PYTHON_VERSION: "3.11"
RELEASE_TAG: "${{ inputs.tag }}"
steps:
- name: Show tag
run: |
echo "Building for tag: ${RELEASE_TAG}"
- uses: actions/checkout@v4
with:
ref: ${{ inputs.tag }}
- name: Install uv
run: |
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: Set up Python
run: uv python install ${{ env.PYTHON_VERSION }}
- name: Install system dependencies
run: brew install portaudio
- name: Build sidecar (CPU)
env:
UV_NO_SOURCES: "1"
run: |
# UV_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
.venv/bin/pyinstaller local-transcription-headless.spec
- name: Package sidecar (CPU)
run: |
cd dist/local-transcription-backend && zip -r ../../sidecar-macos-aarch64-cpu.zip .
- name: Upload to sidecar release
env:
BUILD_TOKEN: ${{ secrets.BUILD_TOKEN }}
run: |
which jq || brew install jq
REPO_API="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}"
TAG="${RELEASE_TAG}"
echo "Waiting for sidecar release ${TAG} to be available..."
for i in $(seq 1 30); do
RELEASE_JSON=$(curl -s -H "Authorization: token ${BUILD_TOKEN}" \
"${REPO_API}/releases/tags/${TAG}")
RELEASE_ID=$(echo "$RELEASE_JSON" | jq -r '.id // empty')
if [ -n "${RELEASE_ID}" ] && [ "${RELEASE_ID}" != "null" ]; then
echo "Found sidecar release: ${TAG} (ID: ${RELEASE_ID})"
break
fi
echo "Attempt ${i}/30: Release not ready yet, retrying in 10s..."
sleep 10
done
if [ -z "${RELEASE_ID}" ] || [ "${RELEASE_ID}" = "null" ]; then
echo "ERROR: Failed to find sidecar release for tag ${TAG} after 30 attempts."
exit 1
fi
for file in sidecar-*.zip; do
filename=$(basename "$file")
encoded_name=$(echo "$filename" | sed 's/ /%20/g')
echo "Uploading ${filename} ($(du -h "$file" | cut -f1))..."
ASSET_ID=$(curl -s -H "Authorization: token ${BUILD_TOKEN}" \
"${REPO_API}/releases/${RELEASE_ID}/assets" | jq -r ".[] | select(.name == \"${filename}\") | .id // empty")
if [ -n "${ASSET_ID}" ]; then
curl -s -X DELETE -H "Authorization: token ${BUILD_TOKEN}" \
"${REPO_API}/releases/${RELEASE_ID}/assets/${ASSET_ID}"
fi
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" -X POST \
-H "Authorization: token ${BUILD_TOKEN}" \
-H "Content-Type: application/octet-stream" \
-T "$file" \
"${REPO_API}/releases/${RELEASE_ID}/assets?name=${encoded_name}")
echo "Upload response: HTTP ${HTTP_CODE}"
done

View File

@@ -1,135 +0,0 @@
name: Build Sidecar (Windows)
on:
workflow_dispatch:
inputs:
tag:
description: 'Sidecar release tag to build (e.g. sidecar-v1.0.3)'
required: true
jobs:
build-sidecar-windows:
name: Build Sidecar (Windows)
runs-on: windows-latest
env:
PYTHON_VERSION: "3.11"
RELEASE_TAG: "${{ inputs.tag }}"
steps:
- name: Show tag
shell: powershell
run: |
Write-Host "Building for tag: $env:RELEASE_TAG"
- uses: actions/checkout@v4
with:
ref: ${{ inputs.tag }}
- name: Install uv
shell: powershell
run: |
if (Get-Command uv -ErrorAction SilentlyContinue) {
Write-Host "uv already installed: $(uv --version)"
} else {
irm https://astral.sh/uv/install.ps1 | iex
$uvPaths = @(
"$env:USERPROFILE\.local\bin",
"$env:USERPROFILE\.cargo\bin",
"$env:LOCALAPPDATA\uv\bin"
)
foreach ($p in $uvPaths) {
if (Test-Path $p) {
echo $p | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
}
}
}
- name: Set up Python
shell: powershell
run: uv python install ${{ env.PYTHON_VERSION }}
- name: Install 7-Zip
shell: powershell
run: |
if (-not (Get-Command 7z -ErrorAction SilentlyContinue)) {
choco install 7zip -y
}
- name: Build sidecar (CPU)
shell: powershell
run: |
$env:UV_NO_SOURCES = "1"
uv sync
# PyPI's default torch includes CUDA. Replace with CPU-only.
uv pip install torch torchaudio --index-url https://download.pytorch.org/whl/cpu --force-reinstall
.venv\Scripts\pyinstaller.exe local-transcription-headless.spec
- name: Package sidecar (CPU)
shell: powershell
run: |
7z a -tzip -mx=9 sidecar-windows-x86_64-cpu.zip .\dist\local-transcription-backend\*
- name: Upload to sidecar release
shell: powershell
env:
BUILD_TOKEN: ${{ secrets.BUILD_TOKEN }}
run: |
$REPO_API = "${{ github.server_url }}/api/v1/repos/${{ github.repository }}"
$Headers = @{ "Authorization" = "token $env:BUILD_TOKEN" }
$TAG = $env:RELEASE_TAG
Write-Host "Release tag: $TAG"
if (-not $TAG) {
Write-Host "ERROR: RELEASE_TAG is empty"
exit 1
}
Write-Host "Waiting for sidecar release $TAG to be available..."
$RELEASE_ID = $null
for ($i = 1; $i -le 30; $i++) {
try {
$release = Invoke-RestMethod -Uri "$REPO_API/releases/tags/$TAG" -Headers $Headers -ErrorAction Stop
$RELEASE_ID = $release.id
if ($RELEASE_ID) {
Write-Host "Found sidecar release: $TAG (ID: $RELEASE_ID)"
break
}
} catch {}
Write-Host "Attempt ${i}/30: Release not ready yet, retrying in 10s..."
Start-Sleep -Seconds 10
}
if (-not $RELEASE_ID) {
Write-Host "ERROR: Failed to find sidecar release for tag $TAG after 30 attempts."
exit 1
}
Get-ChildItem -Path . -Filter "sidecar-*.zip" | ForEach-Object {
$filename = $_.Name
$encodedName = [System.Uri]::EscapeDataString($filename)
$size = [math]::Round($_.Length / 1MB, 1)
Write-Host "Uploading $filename ($size MB)..."
try {
$assets = Invoke-RestMethod -Uri "$REPO_API/releases/$RELEASE_ID/assets" -Headers $Headers
$existing = $assets | Where-Object { $_.name -eq $filename }
if ($existing) {
Invoke-RestMethod -Uri "$REPO_API/releases/$RELEASE_ID/assets/$($existing.id)" -Method Delete -Headers $Headers
}
} catch {}
$uploadUrl = "$REPO_API/releases/$RELEASE_ID/assets?name=$encodedName"
$result = curl.exe --fail --silent --show-error `
-X POST `
-H "Authorization: token $env:BUILD_TOKEN" `
-H "Content-Type: application/octet-stream" `
-T "$($_.FullName)" `
"$uploadUrl" 2>&1
if ($LASTEXITCODE -eq 0) {
Write-Host "Upload successful: $filename"
} else {
Write-Host "WARNING: Upload failed for ${filename}: $result"
}
}

View File

@@ -0,0 +1,425 @@
name: Build Sidecars
on:
push:
branches: [main]
paths:
- 'client/**'
- 'server/**'
- 'backend/**'
- 'pyproject.toml'
- 'local-transcription-headless.spec'
workflow_dispatch:
jobs:
bump-sidecar-version:
name: Bump sidecar version and tag
if: "!contains(github.event.head_commit.message, '[skip ci]')"
runs-on: ubuntu-latest
outputs:
version: ${{ steps.bump.outputs.version }}
tag: ${{ steps.bump.outputs.tag }}
has_changes: ${{ steps.check_changes.outputs.has_changes }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 2
- name: Check for backend changes
id: check_changes
run: |
# If triggered by workflow_dispatch, always build
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
echo "has_changes=true" >> $GITHUB_OUTPUT
exit 0
fi
# Check if relevant files changed in this commit
CHANGED=$(git diff --name-only HEAD~1 HEAD -- client/ server/ backend/ pyproject.toml local-transcription-headless.spec 2>/dev/null || echo "")
if [ -n "$CHANGED" ]; then
echo "has_changes=true" >> $GITHUB_OUTPUT
echo "Backend changes detected: $CHANGED"
else
echo "has_changes=false" >> $GITHUB_OUTPUT
echo "No backend changes detected, skipping sidecar build"
fi
- name: Configure git
if: steps.check_changes.outputs.has_changes == 'true'
run: |
git config user.name "Gitea Actions"
git config user.email "actions@gitea.local"
- name: Bump sidecar patch version
if: steps.check_changes.outputs.has_changes == 'true'
id: bump
run: |
# Read current version from pyproject.toml
CURRENT=$(grep '^version = ' pyproject.toml | head -1 | sed 's/version = "\(.*\)"/\1/')
echo "Current sidecar version: ${CURRENT}"
# Increment patch number
MAJOR=$(echo "${CURRENT}" | cut -d. -f1)
MINOR=$(echo "${CURRENT}" | cut -d. -f2)
PATCH=$(echo "${CURRENT}" | cut -d. -f3)
NEW_PATCH=$((PATCH + 1))
NEW_VERSION="${MAJOR}.${MINOR}.${NEW_PATCH}"
echo "New sidecar version: ${NEW_VERSION}"
# Update pyproject.toml
sed -i "s/^version = \"${CURRENT}\"/version = \"${NEW_VERSION}\"/" pyproject.toml
# Update version.py
sed -i "s/__version__ = \"${CURRENT}\"/__version__ = \"${NEW_VERSION}\"/" version.py
sed -i "s/__version_info__ = .*/__version_info__ = (${MAJOR}, ${MINOR}, ${NEW_PATCH})/" version.py
echo "version=${NEW_VERSION}" >> $GITHUB_OUTPUT
echo "tag=sidecar-v${NEW_VERSION}" >> $GITHUB_OUTPUT
- name: Commit and tag
if: steps.check_changes.outputs.has_changes == 'true'
env:
BUILD_TOKEN: ${{ secrets.BUILD_TOKEN }}
run: |
NEW_VERSION="${{ steps.bump.outputs.version }}"
TAG="${{ steps.bump.outputs.tag }}"
git add pyproject.toml version.py
git commit -m "chore: bump sidecar version to ${NEW_VERSION} [skip ci]"
git tag "${TAG}"
REMOTE_URL=$(git remote get-url origin | sed "s|://|://gitea-actions:${BUILD_TOKEN}@|")
git pull --rebase "${REMOTE_URL}" main || true
git push "${REMOTE_URL}" HEAD:main
git push "${REMOTE_URL}" "${TAG}"
- name: Create Gitea release
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}"
TAG="${{ steps.bump.outputs.tag }}"
VERSION="${{ steps.bump.outputs.version }}"
RELEASE_NAME="Sidecar v${VERSION}"
curl -s -X POST \
-H "Authorization: token ${BUILD_TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"tag_name\": \"${TAG}\", \"name\": \"${RELEASE_NAME}\", \"body\": \"Automated sidecar build.\", \"draft\": false, \"prerelease\": false}" \
"${REPO_API}/releases"
echo "Created release: ${RELEASE_NAME}"
# ── Linux sidecar (CUDA + CPU) ──
build-sidecar-linux:
name: Build Sidecar (Linux)
needs: bump-sidecar-version
if: needs.bump-sidecar-version.outputs.has_changes == 'true'
runs-on: ubuntu-latest
env:
PYTHON_VERSION: "3.11"
steps:
- uses: actions/checkout@v4
with:
ref: ${{ needs.bump-sidecar-version.outputs.tag }}
- name: Install uv
run: |
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: Set up Python
run: uv python install ${{ env.PYTHON_VERSION }}
- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y portaudio19-dev
- name: Build sidecar (CUDA)
run: |
uv sync --frozen || uv sync
uv run pyinstaller local-transcription-headless.spec
- name: Package sidecar (CUDA)
run: |
cd dist/local-transcription-backend && zip -r ../../sidecar-linux-x86_64-cuda.zip .
- name: Build sidecar (CPU)
run: |
rm -rf dist/local-transcription-backend build/
uv pip install torch torchaudio --index-url https://download.pytorch.org/whl/cpu --force-reinstall
uv run pyinstaller local-transcription-headless.spec
- name: Package sidecar (CPU)
run: |
cd dist/local-transcription-backend && zip -r ../../sidecar-linux-x86_64-cpu.zip .
- name: Upload to sidecar release
env:
BUILD_TOKEN: ${{ secrets.BUILD_TOKEN }}
run: |
sudo apt-get install -y jq
REPO_API="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}"
TAG="${{ needs.bump-sidecar-version.outputs.tag }}"
echo "Waiting for sidecar release ${TAG} to be available..."
for i in $(seq 1 30); do
RELEASE_JSON=$(curl -s -H "Authorization: token ${BUILD_TOKEN}" \
"${REPO_API}/releases/tags/${TAG}")
RELEASE_ID=$(echo "$RELEASE_JSON" | jq -r '.id // empty')
if [ -n "${RELEASE_ID}" ] && [ "${RELEASE_ID}" != "null" ]; then
echo "Found sidecar release: ${TAG} (ID: ${RELEASE_ID})"
break
fi
echo "Attempt ${i}/30: Release not ready yet, retrying in 10s..."
sleep 10
done
if [ -z "${RELEASE_ID}" ] || [ "${RELEASE_ID}" = "null" ]; then
echo "ERROR: Failed to find sidecar release for tag ${TAG} after 30 attempts."
exit 1
fi
for file in sidecar-*.zip; do
filename=$(basename "$file")
encoded_name=$(echo "$filename" | sed 's/ /%20/g')
echo "Uploading ${filename} ($(du -h "$file" | cut -f1))..."
ASSET_ID=$(curl -s -H "Authorization: token ${BUILD_TOKEN}" \
"${REPO_API}/releases/${RELEASE_ID}/assets" | jq -r ".[] | select(.name == \"${filename}\") | .id // empty")
if [ -n "${ASSET_ID}" ]; then
curl -s -X DELETE -H "Authorization: token ${BUILD_TOKEN}" \
"${REPO_API}/releases/${RELEASE_ID}/assets/${ASSET_ID}"
fi
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" -X POST \
-H "Authorization: token ${BUILD_TOKEN}" \
-H "Content-Type: application/octet-stream" \
-T "$file" \
"${REPO_API}/releases/${RELEASE_ID}/assets?name=${encoded_name}")
echo "Upload response: HTTP ${HTTP_CODE}"
done
# ── Windows sidecar (CUDA + CPU) ──
build-sidecar-windows:
name: Build Sidecar (Windows)
needs: bump-sidecar-version
if: needs.bump-sidecar-version.outputs.has_changes == 'true'
runs-on: windows-latest
env:
PYTHON_VERSION: "3.11"
steps:
- uses: actions/checkout@v4
with:
ref: ${{ needs.bump-sidecar-version.outputs.tag }}
- name: Install uv
shell: powershell
run: |
if (Get-Command uv -ErrorAction SilentlyContinue) {
Write-Host "uv already installed: $(uv --version)"
} else {
irm https://astral.sh/uv/install.ps1 | iex
# Add both possible uv install locations to PATH
$uvPaths = @(
"$env:USERPROFILE\.local\bin",
"$env:USERPROFILE\.cargo\bin",
"$env:LOCALAPPDATA\uv\bin"
)
foreach ($p in $uvPaths) {
if (Test-Path $p) {
echo $p | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
}
}
}
- name: Set up Python
shell: powershell
run: uv python install ${{ env.PYTHON_VERSION }}
- name: Install 7-Zip
shell: powershell
run: |
if (-not (Get-Command 7z -ErrorAction SilentlyContinue)) {
choco install 7zip -y
}
- name: Build sidecar (CUDA)
shell: powershell
run: |
uv sync --frozen
if ($LASTEXITCODE -ne 0) { uv sync }
uv run pyinstaller local-transcription-headless.spec
- name: Package sidecar (CUDA)
shell: powershell
run: |
7z a -tzip -mx=5 sidecar-windows-x86_64-cuda.zip .\dist\local-transcription-backend\*
- name: Build sidecar (CPU)
shell: powershell
run: |
Remove-Item -Recurse -Force dist\local-transcription-backend, build -ErrorAction SilentlyContinue
uv pip install torch torchaudio --index-url https://download.pytorch.org/whl/cpu --force-reinstall
uv run pyinstaller local-transcription-headless.spec
- name: Package sidecar (CPU)
shell: powershell
run: |
7z a -tzip -mx=5 sidecar-windows-x86_64-cpu.zip .\dist\local-transcription-backend\*
- name: Upload to sidecar release
shell: powershell
env:
BUILD_TOKEN: ${{ secrets.BUILD_TOKEN }}
run: |
$REPO_API = "${{ github.server_url }}/api/v1/repos/${{ github.repository }}"
$Headers = @{ "Authorization" = "token $env:BUILD_TOKEN" }
$TAG = "${{ needs.bump-sidecar-version.outputs.tag }}"
Write-Host "Waiting for sidecar release ${TAG} to be available..."
$RELEASE_ID = $null
for ($i = 1; $i -le 30; $i++) {
try {
$release = Invoke-RestMethod -Uri "${REPO_API}/releases/tags/${TAG}" -Headers $Headers -ErrorAction Stop
$RELEASE_ID = $release.id
if ($RELEASE_ID) {
Write-Host "Found sidecar release: ${TAG} (ID: ${RELEASE_ID})"
break
}
} catch {}
Write-Host "Attempt ${i}/30: Release not ready yet, retrying in 10s..."
Start-Sleep -Seconds 10
}
if (-not $RELEASE_ID) {
Write-Host "ERROR: Failed to find sidecar release for tag ${TAG} after 30 attempts."
exit 1
}
Get-ChildItem -Path . -Filter "sidecar-*.zip" | ForEach-Object {
$filename = $_.Name
$encodedName = [System.Uri]::EscapeDataString($filename)
$size = [math]::Round($_.Length / 1MB, 1)
Write-Host "Uploading ${filename} (${size} MB)..."
try {
$assets = Invoke-RestMethod -Uri "${REPO_API}/releases/${RELEASE_ID}/assets" -Headers $Headers
$existing = $assets | Where-Object { $_.name -eq $filename }
if ($existing) {
Invoke-RestMethod -Uri "${REPO_API}/releases/${RELEASE_ID}/assets/$($existing.id)" -Method Delete -Headers $Headers
}
} catch {}
$uploadUrl = "${REPO_API}/releases/${RELEASE_ID}/assets?name=${encodedName}"
$result = curl.exe --fail --silent --show-error `
-X POST `
-H "Authorization: token $env:BUILD_TOKEN" `
-H "Content-Type: application/octet-stream" `
-T "$($_.FullName)" `
"$uploadUrl" 2>&1
if ($LASTEXITCODE -eq 0) {
Write-Host "Upload successful: ${filename}"
} else {
Write-Host "WARNING: Upload failed for ${filename}: ${result}"
}
}
# ── macOS sidecar (CPU only — no CUDA on macOS) ──
build-sidecar-macos:
name: Build Sidecar (macOS)
needs: bump-sidecar-version
if: needs.bump-sidecar-version.outputs.has_changes == 'true'
runs-on: macos-latest
env:
PYTHON_VERSION: "3.11"
steps:
- uses: actions/checkout@v4
with:
ref: ${{ needs.bump-sidecar-version.outputs.tag }}
- name: Install uv
run: |
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: Set up Python
run: uv python install ${{ env.PYTHON_VERSION }}
- name: Install system dependencies
run: brew install portaudio
- name: Build sidecar (CPU)
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 run pyinstaller local-transcription-headless.spec
- name: Package sidecar (CPU)
run: |
cd dist/local-transcription-backend && zip -r ../../sidecar-macos-aarch64-cpu.zip .
- name: Upload to sidecar release
env:
BUILD_TOKEN: ${{ secrets.BUILD_TOKEN }}
run: |
which jq || brew install jq
REPO_API="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}"
TAG="${{ needs.bump-sidecar-version.outputs.tag }}"
echo "Waiting for sidecar release ${TAG} to be available..."
for i in $(seq 1 30); do
RELEASE_JSON=$(curl -s -H "Authorization: token ${BUILD_TOKEN}" \
"${REPO_API}/releases/tags/${TAG}")
RELEASE_ID=$(echo "$RELEASE_JSON" | jq -r '.id // empty')
if [ -n "${RELEASE_ID}" ] && [ "${RELEASE_ID}" != "null" ]; then
echo "Found sidecar release: ${TAG} (ID: ${RELEASE_ID})"
break
fi
echo "Attempt ${i}/30: Release not ready yet, retrying in 10s..."
sleep 10
done
if [ -z "${RELEASE_ID}" ] || [ "${RELEASE_ID}" = "null" ]; then
echo "ERROR: Failed to find sidecar release for tag ${TAG} after 30 attempts."
exit 1
fi
for file in sidecar-*.zip; do
filename=$(basename "$file")
encoded_name=$(echo "$filename" | sed 's/ /%20/g')
echo "Uploading ${filename} ($(du -h "$file" | cut -f1))..."
ASSET_ID=$(curl -s -H "Authorization: token ${BUILD_TOKEN}" \
"${REPO_API}/releases/${RELEASE_ID}/assets" | jq -r ".[] | select(.name == \"${filename}\") | .id // empty")
if [ -n "${ASSET_ID}" ]; then
curl -s -X DELETE -H "Authorization: token ${BUILD_TOKEN}" \
"${REPO_API}/releases/${RELEASE_ID}/assets/${ASSET_ID}"
fi
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" -X POST \
-H "Authorization: token ${BUILD_TOKEN}" \
-H "Content-Type: application/octet-stream" \
-T "$file" \
"${REPO_API}/releases/${RELEASE_ID}/assets?name=${encoded_name}")
echo "Upload response: HTTP ${HTTP_CODE}"
done

View File

@@ -1,102 +0,0 @@
name: Cleanup Old Releases
on:
workflow_dispatch:
inputs:
keep_app_releases:
description: 'Number of app releases to keep'
required: false
default: '3'
keep_sidecar_releases:
description: 'Number of sidecar releases to keep'
required: false
default: '2'
dry_run:
description: 'Dry run (show what would be deleted without deleting)'
required: false
default: 'true'
jobs:
cleanup:
name: Cleanup Old Releases
runs-on: ubuntu-latest
steps:
- name: Cleanup releases
env:
BUILD_TOKEN: ${{ secrets.BUILD_TOKEN }}
run: |
REPO_API="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}"
KEEP_APP="${{ inputs.keep_app_releases }}"
KEEP_SIDECAR="${{ inputs.keep_sidecar_releases }}"
DRY_RUN="${{ inputs.dry_run }}"
echo "=== Cleanup Configuration ==="
echo "Keep app releases: ${KEEP_APP}"
echo "Keep sidecar releases: ${KEEP_SIDECAR}"
echo "Dry run: ${DRY_RUN}"
echo ""
# Fetch all releases
ALL_RELEASES=$(curl -s -H "Authorization: token ${BUILD_TOKEN}" \
"${REPO_API}/releases?limit=50")
# ── App releases (v* tags, not sidecar-v*) ──
echo "=== App Releases ==="
APP_RELEASES=$(echo "$ALL_RELEASES" | jq -c '[.[] | select(.tag_name | startswith("v")) | select(.tag_name | startswith("sidecar") | not)]')
APP_TOTAL=$(echo "$APP_RELEASES" | jq 'length')
echo "Found ${APP_TOTAL} app releases, keeping ${KEEP_APP}"
if [ "$APP_TOTAL" -gt "$KEEP_APP" ]; then
echo "$APP_RELEASES" | jq -c ".[$KEEP_APP:][]" | while read -r release; do
ID=$(echo "$release" | jq -r '.id')
TAG=$(echo "$release" | jq -r '.tag_name')
SIZE=$(echo "$release" | jq '[.assets[]?.size // 0] | add // 0')
SIZE_MB=$(echo "scale=1; $SIZE / 1048576" | bc 2>/dev/null || echo "?")
# Protect v1.4.0 (last pre-Tauri release)
if [ "$TAG" = "v1.4.0" ]; then
echo " PROTECT ${TAG} (${SIZE_MB} MB)"
continue
fi
if [ "$DRY_RUN" = "true" ]; then
echo " WOULD DELETE ${TAG} (ID: ${ID}, ${SIZE_MB} MB)"
else
echo " DELETING ${TAG} (ID: ${ID}, ${SIZE_MB} MB)..."
curl -s -X DELETE -H "Authorization: token ${BUILD_TOKEN}" \
"${REPO_API}/releases/${ID}"
fi
done
else
echo " Nothing to clean up"
fi
echo ""
# ── Sidecar releases (sidecar-v* tags) ──
echo "=== Sidecar Releases ==="
SIDECAR_RELEASES=$(echo "$ALL_RELEASES" | jq -c '[.[] | select(.tag_name | startswith("sidecar-v"))]')
SIDECAR_TOTAL=$(echo "$SIDECAR_RELEASES" | jq 'length')
echo "Found ${SIDECAR_TOTAL} sidecar releases, keeping ${KEEP_SIDECAR}"
if [ "$SIDECAR_TOTAL" -gt "$KEEP_SIDECAR" ]; then
echo "$SIDECAR_RELEASES" | jq -c ".[$KEEP_SIDECAR:][]" | while read -r release; do
ID=$(echo "$release" | jq -r '.id')
TAG=$(echo "$release" | jq -r '.tag_name')
SIZE=$(echo "$release" | jq '[.assets[]?.size // 0] | add // 0')
SIZE_MB=$(echo "scale=1; $SIZE / 1048576" | bc 2>/dev/null || echo "?")
if [ "$DRY_RUN" = "true" ]; then
echo " WOULD DELETE ${TAG} (ID: ${ID}, ${SIZE_MB} MB)"
else
echo " DELETING ${TAG} (ID: ${ID}, ${SIZE_MB} MB)..."
curl -s -X DELETE -H "Authorization: token ${BUILD_TOKEN}" \
"${REPO_API}/releases/${ID}"
fi
done
else
echo " Nothing to clean up"
fi
echo ""
echo "=== Done ==="

View File

@@ -1,41 +1,17 @@
name: Release
on:
workflow_dispatch:
push:
branches: [main]
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
needs: test
if: "!contains(github.event.head_commit.message, '[skip ci]')"
runs-on: ubuntu-latest
outputs:
new_version: ${{ steps.bump.outputs.new_version }}
tag: ${{ steps.bump.outputs.tag }}
steps:
- uses: actions/checkout@v4
with:
@@ -47,10 +23,13 @@ jobs:
git config user.email "actions@gitea.local"
- name: Bump patch version
id: bump
run: |
# Read current version from package.json
CURRENT=$(grep '"version"' package.json | head -1 | sed 's/.*"version": *"\([^"]*\)".*/\1/')
echo "Current version: ${CURRENT}"
# Increment patch number
MAJOR=$(echo "${CURRENT}" | cut -d. -f1)
MINOR=$(echo "${CURRENT}" | cut -d. -f2)
PATCH=$(echo "${CURRENT}" | cut -d. -f3)
@@ -58,56 +37,264 @@ jobs:
NEW_VERSION="${MAJOR}.${MINOR}.${NEW_PATCH}"
echo "New version: ${NEW_VERSION}"
# Update package.json
sed -i "s/\"version\": \"${CURRENT}\"/\"version\": \"${NEW_VERSION}\"/" package.json
# Update src-tauri/tauri.conf.json
sed -i "s/\"version\": \"${CURRENT}\"/\"version\": \"${NEW_VERSION}\"/" src-tauri/tauri.conf.json
# Update src-tauri/Cargo.toml
sed -i "s/^version = \"${CURRENT}\"/version = \"${NEW_VERSION}\"/" src-tauri/Cargo.toml
# Update version.py
sed -i "s/__version__ = \"${CURRENT}\"/__version__ = \"${NEW_VERSION}\"/" version.py
sed -i "s/__version_info__ = .*/__version_info__ = (${MAJOR}, ${MINOR}, ${NEW_PATCH})/" version.py
# Write to env file instead of step outputs (avoids act runner bug)
echo "NEW_VERSION=${NEW_VERSION}" >> $GITHUB_ENV
echo "RELEASE_TAG=v${NEW_VERSION}" >> $GITHUB_ENV
echo "new_version=${NEW_VERSION}" >> $GITHUB_OUTPUT
echo "tag=v${NEW_VERSION}" >> $GITHUB_OUTPUT
- name: Commit and tag
env:
BUILD_TOKEN: ${{ secrets.BUILD_TOKEN }}
run: |
NEW_VERSION="${{ steps.bump.outputs.new_version }}"
git add package.json src-tauri/tauri.conf.json src-tauri/Cargo.toml version.py
git commit -m "chore: bump version to ${NEW_VERSION} [skip ci]"
git tag "${RELEASE_TAG}"
git tag "v${NEW_VERSION}"
REMOTE_URL=$(git remote get-url origin | sed "s|://|://gitea-actions:${BUILD_TOKEN}@|")
git pull --rebase "${REMOTE_URL}" main || true
git push "${REMOTE_URL}" HEAD:main
git push "${REMOTE_URL}" "${RELEASE_TAG}"
git push "${REMOTE_URL}" "v${NEW_VERSION}"
- name: Create Gitea release
env:
BUILD_TOKEN: ${{ secrets.BUILD_TOKEN }}
run: |
REPO_API="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}"
RELEASE_NAME="Local Transcription ${RELEASE_TAG}"
TAG="${{ steps.bump.outputs.tag }}"
RELEASE_NAME="Local Transcription ${TAG}"
curl -s -X POST \
-H "Authorization: token ${BUILD_TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"tag_name\": \"${RELEASE_TAG}\", \"name\": \"${RELEASE_NAME}\", \"body\": \"Automated build.\", \"draft\": false, \"prerelease\": false}" \
-d "{\"tag_name\": \"${TAG}\", \"name\": \"${RELEASE_NAME}\", \"body\": \"Automated build.\", \"draft\": false, \"prerelease\": false}" \
"${REPO_API}/releases"
echo "Created release: ${RELEASE_NAME}"
- name: Trigger per-OS app builds
# ── Platform builds (run after version bump) ──
build-linux:
name: Build App (Linux)
needs: bump-version
runs-on: ubuntu-latest
env:
NODE_VERSION: "20"
steps:
- uses: actions/checkout@v4
with:
ref: ${{ needs.bump-version.outputs.tag }}
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- name: Install Rust stable
run: |
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable
echo "$HOME/.cargo/bin" >> $GITHUB_PATH
- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf xdg-utils rpm
- name: Install npm dependencies
run: npm ci
- name: Build Tauri app
run: npm run tauri build
- name: Upload to release
env:
BUILD_TOKEN: ${{ secrets.BUILD_TOKEN }}
run: |
sudo apt-get install -y jq
REPO_API="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}"
TAG="${{ needs.bump-version.outputs.tag }}"
echo "Release tag: ${TAG}"
for workflow in build-app-linux.yml build-app-windows.yml build-app-macos.yml; do
echo "Dispatching ${workflow} for ${RELEASE_TAG}..."
HTTP_CODE=$(curl -s -w "%{http_code}" -o /tmp/dispatch_resp.txt -X POST \
RELEASE_ID=$(curl -s -H "Authorization: token ${BUILD_TOKEN}" \
"${REPO_API}/releases/tags/${TAG}" | jq -r '.id // empty')
if [ -z "${RELEASE_ID}" ] || [ "${RELEASE_ID}" = "null" ]; then
echo "ERROR: Failed to find release for tag ${TAG}."
exit 1
fi
echo "Release ID: ${RELEASE_ID}"
find src-tauri/target/release/bundle -type f \( -name "*.deb" -o -name "*.rpm" -o -name "*.AppImage" \) | while IFS= read -r file; do
filename=$(basename "$file")
encoded_name=$(echo "$filename" | sed 's/ /%20/g')
echo "Uploading ${filename} ($(du -h "$file" | cut -f1))..."
ASSET_ID=$(curl -s -H "Authorization: token ${BUILD_TOKEN}" \
"${REPO_API}/releases/${RELEASE_ID}/assets" | jq -r ".[] | select(.name == \"${filename}\") | .id // empty")
if [ -n "${ASSET_ID}" ]; then
curl -s -X DELETE -H "Authorization: token ${BUILD_TOKEN}" \
"${REPO_API}/releases/${RELEASE_ID}/assets/${ASSET_ID}"
fi
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" -X POST \
-H "Authorization: token ${BUILD_TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"ref\": \"main\", \"inputs\": {\"tag\": \"${RELEASE_TAG}\"}}" \
"${REPO_API}/actions/workflows/${workflow}/dispatches")
echo " -> HTTP ${HTTP_CODE}"
if [ "$HTTP_CODE" != "204" ]; then cat /tmp/dispatch_resp.txt; echo ""; fi
-H "Content-Type: application/octet-stream" \
-T "$file" \
"${REPO_API}/releases/${RELEASE_ID}/assets?name=${encoded_name}")
echo "Upload response: HTTP ${HTTP_CODE}"
done
build-windows:
name: Build App (Windows)
needs: bump-version
runs-on: windows-latest
env:
NODE_VERSION: "20"
steps:
- uses: actions/checkout@v4
with:
ref: ${{ needs.bump-version.outputs.tag }}
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- name: Install Rust stable
shell: powershell
run: |
if (Get-Command rustup -ErrorAction SilentlyContinue) {
rustup default stable
} else {
Invoke-WebRequest -Uri https://win.rustup.rs/x86_64 -OutFile rustup-init.exe
.\rustup-init.exe -y --default-toolchain stable
echo "$env:USERPROFILE\.cargo\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
}
- name: Install npm dependencies
shell: powershell
run: npm ci
- name: Build Tauri app
shell: powershell
run: npm run tauri build
- name: Upload to release
shell: powershell
env:
BUILD_TOKEN: ${{ secrets.BUILD_TOKEN }}
run: |
$REPO_API = "${{ github.server_url }}/api/v1/repos/${{ github.repository }}"
$Headers = @{ "Authorization" = "token $env:BUILD_TOKEN" }
$TAG = "${{ needs.bump-version.outputs.tag }}"
Write-Host "Release tag: ${TAG}"
$release = Invoke-RestMethod -Uri "${REPO_API}/releases/tags/${TAG}" -Headers $Headers -ErrorAction Stop
$RELEASE_ID = $release.id
Write-Host "Release ID: ${RELEASE_ID}"
Get-ChildItem -Path src-tauri\target\release\bundle -Recurse -Include *.msi,*-setup.exe | ForEach-Object {
$filename = $_.Name
$encodedName = [System.Uri]::EscapeDataString($filename)
$size = [math]::Round($_.Length / 1MB, 1)
Write-Host "Uploading ${filename} (${size} MB)..."
try {
$assets = Invoke-RestMethod -Uri "${REPO_API}/releases/${RELEASE_ID}/assets" -Headers $Headers
$existing = $assets | Where-Object { $_.name -eq $filename }
if ($existing) {
Invoke-RestMethod -Uri "${REPO_API}/releases/${RELEASE_ID}/assets/$($existing.id)" -Method Delete -Headers $Headers
}
} catch {}
$uploadUrl = "${REPO_API}/releases/${RELEASE_ID}/assets?name=${encodedName}"
$result = curl.exe --fail --silent --show-error `
-X POST `
-H "Authorization: token $env:BUILD_TOKEN" `
-H "Content-Type: application/octet-stream" `
-T "$($_.FullName)" `
"$uploadUrl" 2>&1
if ($LASTEXITCODE -eq 0) {
Write-Host "Upload successful: ${filename}"
} else {
Write-Host "WARNING: Upload failed for ${filename}: ${result}"
}
}
build-macos:
name: Build App (macOS)
needs: bump-version
runs-on: macos-latest
env:
NODE_VERSION: "20"
steps:
- uses: actions/checkout@v4
with:
ref: ${{ needs.bump-version.outputs.tag }}
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- name: Install Rust stable
run: |
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable
echo "$HOME/.cargo/bin" >> $GITHUB_PATH
- name: Install system dependencies
run: brew install --quiet create-dmg || true
- name: Install npm dependencies
run: npm ci
- name: Build Tauri app
run: npm run tauri build
- name: Upload to release
env:
BUILD_TOKEN: ${{ secrets.BUILD_TOKEN }}
run: |
which jq || brew install jq
REPO_API="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}"
TAG="${{ needs.bump-version.outputs.tag }}"
echo "Release tag: ${TAG}"
RELEASE_ID=$(curl -s -H "Authorization: token ${BUILD_TOKEN}" \
"${REPO_API}/releases/tags/${TAG}" | jq -r '.id // empty')
if [ -z "${RELEASE_ID}" ] || [ "${RELEASE_ID}" = "null" ]; then
echo "ERROR: Failed to find release for tag ${TAG}."
exit 1
fi
echo "Release ID: ${RELEASE_ID}"
find src-tauri/target/release/bundle -type f -name "*.dmg" | while IFS= read -r file; do
filename=$(basename "$file")
encoded_name=$(echo "$filename" | sed 's/ /%20/g')
echo "Uploading ${filename} ($(du -h "$file" | cut -f1))..."
ASSET_ID=$(curl -s -H "Authorization: token ${BUILD_TOKEN}" \
"${REPO_API}/releases/${RELEASE_ID}/assets" | jq -r ".[] | select(.name == \"${filename}\") | .id // empty")
if [ -n "${ASSET_ID}" ]; then
curl -s -X DELETE -H "Authorization: token ${BUILD_TOKEN}" \
"${REPO_API}/releases/${RELEASE_ID}/assets/${ASSET_ID}"
fi
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" -X POST \
-H "Authorization: token ${BUILD_TOKEN}" \
-H "Content-Type: application/octet-stream" \
-T "$file" \
"${REPO_API}/releases/${RELEASE_ID}/assets?name=${encoded_name}")
echo "Upload response: HTTP ${HTTP_CODE}"
done

View File

@@ -1,102 +0,0 @@
name: Sidecar Release
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
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 2
- name: Configure git
run: |
git config user.name "Gitea Actions"
git config user.email "actions@gitea.local"
- name: Bump sidecar patch version
run: |
CURRENT=$(grep '^version = ' pyproject.toml | head -1 | sed 's/version = "\(.*\)"/\1/')
echo "Current sidecar version: ${CURRENT}"
MAJOR=$(echo "${CURRENT}" | cut -d. -f1)
MINOR=$(echo "${CURRENT}" | cut -d. -f2)
PATCH=$(echo "${CURRENT}" | cut -d. -f3)
NEW_PATCH=$((PATCH + 1))
NEW_VERSION="${MAJOR}.${MINOR}.${NEW_PATCH}"
echo "New sidecar version: ${NEW_VERSION}"
sed -i "s/^version = \"${CURRENT}\"/version = \"${NEW_VERSION}\"/" pyproject.toml
# Write to env file instead of step outputs (avoids act runner bug)
echo "NEW_VERSION=${NEW_VERSION}" >> $GITHUB_ENV
echo "RELEASE_TAG=sidecar-v${NEW_VERSION}" >> $GITHUB_ENV
- name: Commit and tag
env:
BUILD_TOKEN: ${{ secrets.BUILD_TOKEN }}
run: |
git add pyproject.toml
git commit -m "chore: bump sidecar version to ${NEW_VERSION} [skip ci]"
git tag "${RELEASE_TAG}"
REMOTE_URL=$(git remote get-url origin | sed "s|://|://gitea-actions:${BUILD_TOKEN}@|")
git pull --rebase "${REMOTE_URL}" main || true
git push "${REMOTE_URL}" HEAD:main
git push "${REMOTE_URL}" "${RELEASE_TAG}"
- name: Create Gitea release
env:
BUILD_TOKEN: ${{ secrets.BUILD_TOKEN }}
run: |
REPO_API="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}"
RELEASE_NAME="Sidecar v${NEW_VERSION}"
curl -s -X POST \
-H "Authorization: token ${BUILD_TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"tag_name\": \"${RELEASE_TAG}\", \"name\": \"${RELEASE_NAME}\", \"body\": \"Automated sidecar build.\", \"draft\": false, \"prerelease\": false}" \
"${REPO_API}/releases"
echo "Created release: ${RELEASE_NAME}"
- name: Trigger per-OS sidecar builds
env:
BUILD_TOKEN: ${{ secrets.BUILD_TOKEN }}
run: |
REPO_API="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}"
for workflow in build-sidecar-linux.yml build-sidecar-windows.yml build-sidecar-macos.yml build-sidecar-cloud.yml; do
echo "Dispatching ${workflow} for ${RELEASE_TAG}..."
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" -X POST \
-H "Authorization: token ${BUILD_TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"ref\": \"main\", \"inputs\": {\"tag\": \"${RELEASE_TAG}\"}}" \
"${REPO_API}/actions/workflows/${workflow}/dispatches")
echo " -> HTTP ${HTTP_CODE}"
done
# NOTE: Automatic cleanup disabled -- it races with async builds.
# Clean up old releases manually from the Gitea UI when needed.

View File

@@ -1,66 +0,0 @@
name: Tests
on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:
jobs:
python-tests:
name: Python Backend Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install uv
run: |
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: |
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
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- name: Install dependencies
run: npm ci
- name: Run Vitest
run: npx vitest run
rust-tests:
name: Rust Sidecar Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Rust
run: |
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable
echo "$HOME/.cargo/bin" >> $GITHUB_PATH
- name: Install Tauri system dependencies
run: |
sudo apt-get update
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
- name: Run cargo test
working-directory: src-tauri
run: cargo test

3
.gitignore vendored
View File

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

Binary file not shown.

View File

@@ -64,14 +64,8 @@ local-transcription/
│ ├── web_display.py # FastAPI OBS display server (WebSocket + HTML)
│ └── nodejs/ # Optional multi-user sync server
├── .gitea/workflows/ # CI/CD
│ ├── release.yml # Coordinator: version bump, tag, release creation
── build-app-linux.yml # Linux Tauri app build (triggered by v* tag)
│ ├── build-app-windows.yml # Windows Tauri app build (triggered by v* tag)
│ ├── build-app-macos.yml # macOS Tauri app build (triggered by v* tag)
│ ├── sidecar-release.yml # Sidecar coordinator: version bump, tag, release
│ ├── build-sidecar-linux.yml # Linux sidecar build (triggered by sidecar-v* tag)
│ ├── build-sidecar-windows.yml # Windows sidecar build (triggered by sidecar-v* tag)
│ └── build-sidecar-macos.yml # macOS sidecar build (triggered by sidecar-v* tag)
│ ├── release.yml # Tauri app builds (Linux/Windows/macOS)
── build-sidecar.yml # Python sidecar builds (CUDA + CPU)
├── config/default_config.yaml # Default settings template
├── main.py # Legacy PySide6 GUI entry point
├── main_cli.py # CLI version for testing
@@ -211,21 +205,12 @@ Uses Svelte 5 runes throughout (`$state`, `$derived`, `$effect`, `$props`). No S
## CI/CD
Eight Gitea Actions workflows in `.gitea/workflows/`, split into coordinators and per-OS builders:
Two Gitea Actions workflows in `.gitea/workflows/`:
**App release (Tauri):**
- **`release.yml`**: Coordinator. Triggers on push to `main`. Auto-bumps version in package.json/tauri.conf.json/Cargo.toml/version.py, commits, tags `v{VERSION}`, creates Gitea release.
- **`build-app-linux.yml`**: Triggers on `v*` tag push or `workflow_dispatch`. Builds Tauri app, uploads `.deb`/`.rpm`/`.AppImage`.
- **`build-app-windows.yml`**: Triggers on `v*` tag push or `workflow_dispatch`. Builds Tauri app, uploads `.msi`/`*-setup.exe`.
- **`build-app-macos.yml`**: Triggers on `v*` tag push or `workflow_dispatch`. Builds Tauri app, uploads `.dmg`.
- **`release.yml`**: Triggers on push to `main`. Auto-bumps version, builds Tauri app on Linux/Windows/macOS, uploads `.deb`, `.rpm`, `.msi`, `.dmg` to Gitea release.
- **`build-sidecar.yml`**: Triggers on changes to `client/`, `server/`, `backend/`, `pyproject.toml`. Builds headless Python sidecar via PyInstaller. CUDA + CPU for Linux/Windows, CPU-only for macOS.
**Sidecar release (Python backend):**
- **`sidecar-release.yml`**: Coordinator. Triggers on push to `main` with changes in `client/`, `server/`, `backend/`, `pyproject.toml`, or `local-transcription-headless.spec`. Bumps version in pyproject.toml/version.py, tags `sidecar-v{VERSION}`, creates Gitea release.
- **`build-sidecar-linux.yml`**: Triggers on `sidecar-v*` tag push or `workflow_dispatch`. Builds CUDA + CPU sidecars via PyInstaller.
- **`build-sidecar-windows.yml`**: Triggers on `sidecar-v*` tag push or `workflow_dispatch`. Builds CUDA + CPU sidecars via PyInstaller.
- **`build-sidecar-macos.yml`**: Triggers on `sidecar-v*` tag push or `workflow_dispatch`. Builds CPU-only sidecar via PyInstaller.
All per-OS build workflows can be re-run independently via `workflow_dispatch` with an optional `tag` input. All require a `BUILD_TOKEN` secret (Gitea API token with release write access).
Both require a `BUILD_TOKEN` secret (Gitea API token with release write access).
## Common Patterns

Binary file not shown.

View File

@@ -267,15 +267,6 @@ Both workflows require a `BUILD_TOKEN` secret in the repo settings (Gitea API to
## Troubleshooting
### macOS: "App is damaged and can't be opened"
macOS Gatekeeper blocks unsigned applications. Since the app is not yet signed with an Apple Developer certificate, you need to remove the quarantine flag before opening:
```bash
xattr -cr "/Applications/Local Transcription.app"
```
Then open the app normally. You only need to do this once after downloading.
### Model Loading Issues
- Models download automatically on first use to `~/.cache/huggingface/`
- First run requires internet connection

View File

@@ -99,19 +99,11 @@ class APIServer:
self.controller.on_credits_low = on_credits_low
def set_event_loop(self, loop: asyncio.AbstractEventLoop):
"""Set the event loop used for broadcasting (call from uvicorn startup)."""
self._event_loop = loop
def _broadcast_control(self, data: dict):
"""Send a message to all connected /ws/control clients."""
if not self.control_connections:
return
loop = getattr(self, '_event_loop', None)
if loop is None:
return
message = json.dumps(data)
disconnected = []
@@ -119,7 +111,7 @@ class APIServer:
try:
asyncio.run_coroutine_threadsafe(
ws.send_text(message),
loop,
asyncio.get_event_loop(),
)
except Exception:
disconnected.append(ws)
@@ -132,10 +124,6 @@ class APIServer:
app = self.app
ctrl = self.controller
@app.on_event("startup")
async def on_startup():
self.set_event_loop(asyncio.get_event_loop())
# ── Status ─────────────────────────────────────────────
@app.get("/api/status")
@@ -151,24 +139,14 @@ class APIServer:
@app.post("/api/start")
async def start_transcription():
import asyncio
# Run in thread pool to avoid blocking the event loop
# (start_recording can block up to 15s waiting for Deepgram WS)
loop = asyncio.get_event_loop()
success, message = await loop.run_in_executor(
None, ctrl.start_transcription
)
success, message = ctrl.start_transcription()
if not success:
raise HTTPException(status_code=400, detail=message)
return {"status": "ok", "message": message}
@app.post("/api/stop")
async def stop_transcription():
import asyncio
loop = asyncio.get_event_loop()
success, message = await loop.run_in_executor(
None, ctrl.stop_transcription
)
success, message = ctrl.stop_transcription()
if not success:
raise HTTPException(status_code=400, detail=message)
return {"status": "ok", "message": message}
@@ -233,11 +211,7 @@ class APIServer:
@app.post("/api/reload-engine")
async def reload_engine():
import asyncio
loop = asyncio.get_event_loop()
success, message = await loop.run_in_executor(
None, ctrl.reload_engine
)
success, message = ctrl.reload_engine()
if not success:
raise HTTPException(status_code=500, detail=message)
return {"status": "ok", "message": message}

View File

@@ -18,18 +18,13 @@ import sys
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from client.config import Config
from client.models import TranscriptionResult
from client.device_utils import DeviceManager
from client.transcription_engine_realtime import RealtimeTranscriptionEngine, TranscriptionResult
from client.deepgram_transcription import DeepgramTranscriptionEngine
from client.server_sync import ServerSyncClient
from server.web_display import TranscriptionWebServer
from version import __version__
# Heavy imports (torch, RealtimeSTT, faster-whisper) are deferred so
# the cloud-only sidecar build can exclude them entirely.
# Imported lazily in _initialize_engine() when remote.mode == "local".
RealtimeTranscriptionEngine = None
DeviceManager = None
class AppState:
"""Enum-like class for application states."""
@@ -94,24 +89,7 @@ class AppController:
def __init__(self, config: Optional[Config] = None):
self.config = config or Config()
# DeviceManager is only needed for local Whisper mode.
# Lazy-import to keep the cloud-only sidecar lightweight.
global DeviceManager
if DeviceManager is None:
try:
from client.device_utils import DeviceManager as _DM
DeviceManager = _DM
except ImportError:
DeviceManager = None
self.device_manager = DeviceManager() if DeviceManager else None
self.is_cloud_only = DeviceManager is None
# If this is the cloud-only sidecar and mode is still "local",
# auto-switch to "byok" so the engine doesn't try to load Whisper.
if self.is_cloud_only and self.config.get('remote.mode', 'local') == 'local':
self.config.set('remote.mode', 'byok')
self.device_manager = DeviceManager()
# State
self._state = AppState.INITIALIZING
@@ -265,12 +243,15 @@ class AppController:
def _initialize_engine(self):
"""Initialize the transcription engine in a background thread."""
device_config = self.config.get('transcription.device', 'auto')
self.device_manager.set_device(device_config)
audio_device_str = self.config.get('audio.input_device', 'default')
audio_device = None if audio_device_str == 'default' else int(audio_device_str)
model = self.config.get('transcription.model', 'base.en')
language = self.config.get('transcription.language', 'en')
device_config = self.config.get('transcription.device', 'auto')
device = self.device_manager.get_device_for_whisper()
compute_type = self.config.get('transcription.compute_type', 'default')
self.current_model_size = model
@@ -303,27 +284,6 @@ class AppController:
self.transcription_engine.set_error_callback(self._on_remote_error)
self.transcription_engine.set_credits_low_callback(self._on_credits_low)
else:
# Lazy-import heavy local transcription dependencies
global RealtimeTranscriptionEngine
if RealtimeTranscriptionEngine is None:
try:
from client.transcription_engine_realtime import RealtimeTranscriptionEngine as _RTE
RealtimeTranscriptionEngine = _RTE
except ImportError:
# Cloud-only sidecar -- local engine not available
self._set_state(
AppState.ERROR,
"Local transcription not available in this build. "
"Please switch to Cloud (Deepgram) mode in Settings."
)
return
if self.device_manager:
self.device_manager.set_device(device_config)
device = self.device_manager.get_device_for_whisper()
else:
device = "cpu"
self.transcription_engine = RealtimeTranscriptionEngine(
model=model,
device=device,
@@ -372,14 +332,6 @@ class AppController:
device_display = "Unknown"
self._set_state(AppState.READY, f"Ready | Device: {device_display}")
else:
# Cloud sidecar with no API key -- show helpful setup message
# instead of a scary error. The user needs to enter their key.
if self.is_cloud_only:
self._set_state(
AppState.READY,
"Setup needed: Open Settings > Remote Transcription > enter your Deepgram API key"
)
else:
self._set_state(AppState.ERROR, message)
@@ -396,14 +348,7 @@ class AppController:
try:
success = self.transcription_engine.start_recording()
if not success:
import logging
# Check if there's a recent error in the logger
err_detail = getattr(self.transcription_engine, '_last_error', '')
msg = f"Failed to start recording"
if err_detail:
msg += f": {err_detail}"
print(f"ERROR: {msg}")
return False, msg
return False, "Failed to start recording"
# Start server sync if enabled
if self.config.get('server_sync.enabled', False):
@@ -632,18 +577,12 @@ class AppController:
if self.config.get('server_sync.enabled', False):
self._start_server_sync()
# Check if model/device/remote mode changed -- any of these require
# a full engine reload since they change which engine class is used
# Check if model/device changed
new_model = self.config.get('transcription.model', 'base.en')
new_device = self.config.get('transcription.device', 'auto')
new_remote_mode = self.config.get('remote.mode', 'local')
current_remote_mode = 'local'
if self.transcription_engine:
current_remote_mode = getattr(self.transcription_engine, 'mode', 'local')
engine_reload_needed = (
self.current_model_size != new_model
or self.current_device_config != new_device
or current_remote_mode != new_remote_mode
)
if engine_reload_needed:
@@ -657,7 +596,7 @@ class AppController:
host = self.config.get('web_server.host', '127.0.0.1')
port = self.actual_web_port or self.config.get('web_server.port', 8080)
device_info = self.device_manager.get_device_info() if self.device_manager else []
device_info = self.device_manager.get_device_info()
remote_mode = self.config.get('remote.mode', 'local')
if remote_mode in ('managed', 'byok') and self.transcription_engine:
@@ -701,13 +640,10 @@ class AppController:
def get_compute_devices(self) -> list[dict]:
"""List available compute devices."""
devices = [{"id": "auto", "name": "Auto-detect"}]
if self.device_manager:
device_info = self.device_manager.get_device_info()
devices = [{"id": "auto", "name": "Auto-detect"}]
for dev_id, dev_name in device_info:
devices.append({"id": dev_id, "name": dev_name})
else:
devices.append({"id": "cloud", "name": "Cloud (Deepgram)"})
return devices
# ── Update Checking ────────────────────────────────────────────

View File

@@ -88,16 +88,11 @@ def main():
# Create API server wrapping the controller
api_server = APIServer(controller)
# OBS display runs on the configured port, API server on port+1
obs_port = controller.actual_web_port or args.port
api_port = obs_port + 1
# Determine actual port (web server may have shifted if port was in use)
actual_port = controller.actual_web_port or args.port
# Print ready event so Tauri can discover the API port
print(json.dumps({
"event": "ready",
"port": api_port,
"obs_port": obs_port,
}), flush=True)
# Print ready event so Tauri can discover the port
print(json.dumps({"event": "ready", "port": actual_port}), flush=True)
# Run the API server (blocks)
import uvicorn
@@ -109,7 +104,7 @@ def main():
uvicorn.run(
api_server.app,
host=args.host,
port=api_port,
port=actual_port + 1, # API on port+1, OBS display on the main port
log_level="error",
access_log=False,
)

View File

@@ -1,159 +0,0 @@
"""Shared fixtures for backend tests.
Heavy third-party modules (torch, sounddevice, numpy, RealtimeSTT, etc.) are
stubbed at the *sys.modules* level before any backend code is imported. This
lets the test suite run on a plain Python install without GPU drivers, audio
hardware, or heavyweight ML libraries.
"""
import sys
import types
from pathlib import Path
from unittest.mock import MagicMock
import pytest
# ── Project root on sys.path ────────────────────────────────────────
project_root = Path(__file__).resolve().parent.parent.parent
if str(project_root) not in sys.path:
sys.path.insert(0, str(project_root))
# ── Stub heavy modules before anything imports them ─────────────────
def _stub(name: str) -> types.ModuleType:
"""Create a stub module and register it in sys.modules if not already present."""
if name in sys.modules:
return sys.modules[name]
mod = types.ModuleType(name)
sys.modules[name] = mod
return mod
# numpy -- must behave like a real module for `import numpy as np`
_np = _stub("numpy")
_np.float32 = float
_np.float64 = float
_np.int16 = int
_np.ndarray = MagicMock
_np.array = MagicMock(return_value=MagicMock())
_np.zeros = MagicMock(return_value=MagicMock())
_np.frombuffer = MagicMock(return_value=MagicMock())
# torch + sub-modules
_torch = _stub("torch")
_torch.cuda = MagicMock()
_torch.cuda.is_available = MagicMock(return_value=False)
_torch.backends = MagicMock()
_torch.backends.mps = MagicMock()
_torch.backends.mps.is_available = MagicMock(return_value=False)
_stub("torch.cuda")
_stub("torch.backends")
_stub("torch.backends.mps")
_stub("torchaudio")
# sounddevice
_sd = _stub("sounddevice")
_sd.query_devices = MagicMock(return_value=[])
# RealtimeSTT (imported by transcription_engine_realtime)
_rtstt = _stub("RealtimeSTT")
_rtstt.AudioToTextRecorder = MagicMock
# faster_whisper (sometimes imported transitively)
_stub("faster_whisper")
# noisereduce
_stub("noisereduce")
# scipy
_scipy = _stub("scipy")
_stub("scipy.signal")
_stub("scipy.io")
_stub("scipy.io.wavfile")
# webrtcvad
_stub("webrtcvad")
# openwakeword
_stub("openwakeword")
# pvporcupine
_stub("pvporcupine")
# PySide6 (should not be needed, but just in case)
_stub("PySide6")
_stub("PySide6.QtWidgets")
_stub("PySide6.QtCore")
_stub("PySide6.QtGui")
# websockets
_ws = _stub("websockets")
_ws.connect = MagicMock
# deepgram (cloud transcription)
_stub("deepgram")
# ── Fixtures ────────────────────────────────────────────────────────
@pytest.fixture
def mock_config(tmp_path):
"""Return a Config object backed by a temporary file.
This avoids touching the real user config at ~/.local-transcription/.
"""
config_file = tmp_path / "test_config.yaml"
from client.config import Config
config = Config(config_path=str(config_file))
return config
@pytest.fixture
def controller(mock_config):
"""Return an AppController wired to *mock_config* without starting heavy
subsystems (engine, web server, device manager).
The transcription engine, web server thread, and DeviceManager are all
replaced with lightweight mocks so the test suite can run without a GPU,
audio hardware, or a free network port.
"""
from unittest.mock import patch
with patch("backend.app_controller.DeviceManager") as MockDM, \
patch("backend.app_controller.RealtimeTranscriptionEngine"), \
patch("backend.app_controller.DeepgramTranscriptionEngine"), \
patch("backend.app_controller.TranscriptionWebServer"), \
patch("backend.app_controller.ServerSyncClient"):
# DeviceManager stub
dm_instance = MagicMock()
dm_instance.get_device_info.return_value = [("cpu", "CPU")]
dm_instance.get_device_for_whisper.return_value = "cpu"
MockDM.return_value = dm_instance
from backend.app_controller import AppController
ctrl = AppController(config=mock_config)
yield ctrl
@pytest.fixture
def api_client(controller):
"""Return an httpx.AsyncClient speaking ASGI to the APIServer's FastAPI app.
Usage in tests::
async def test_something(api_client):
resp = await api_client.get("/api/status")
assert resp.status_code == 200
"""
from backend.api_server import APIServer
import httpx
api = APIServer(controller)
transport = httpx.ASGITransport(app=api.app)
client = httpx.AsyncClient(transport=transport, base_url="http://testserver")
return client

View File

@@ -1,150 +0,0 @@
"""Tests for backend.api_server.APIServer REST endpoints."""
import sys
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
import pytest_asyncio
# Ensure project root is on path
project_root = Path(__file__).resolve().parent.parent.parent
sys.path.insert(0, str(project_root))
# ── GET /api/status ─────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_get_status(api_client):
resp = await api_client.get("/api/status")
assert resp.status_code == 200
data = resp.json()
assert "state" in data
assert "is_transcribing" in data
assert "version" in data
assert "web_server" in data
# ── GET /api/config ─────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_get_config(api_client):
resp = await api_client.get("/api/config")
assert resp.status_code == 200
data = resp.json()
# The config should be a dict (the raw config mapping)
assert isinstance(data, dict)
# ── PUT /api/config ─────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_put_config(api_client, controller):
"""Updating config via PUT should persist and return success."""
# Patch reload_engine to avoid heavy lifting
controller.reload_engine = MagicMock(return_value=(True, "ok"))
controller.current_model_size = "base.en"
controller.current_device_config = "auto"
controller.config.set("transcription.model", "base.en")
controller.config.set("transcription.device", "auto")
resp = await api_client.put(
"/api/config",
json={"settings": {"display.font_size": 24}},
)
assert resp.status_code == 200
body = resp.json()
assert body["status"] == "ok"
# Verify the value was actually saved
assert controller.config.get("display.font_size") == 24
# ── POST /api/start (engine not ready) ─────────────────────────────
@pytest.mark.asyncio
async def test_start_when_not_ready(api_client, controller):
"""Starting transcription without an engine should return 400."""
controller.transcription_engine = None
resp = await api_client.post("/api/start")
assert resp.status_code == 400
# ── POST /api/clear ─────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_clear(api_client, controller):
from client.models import TranscriptionResult
from datetime import datetime
controller.transcriptions = [
TranscriptionResult(text="One", is_final=True, timestamp=datetime.now(), user_name="U"),
]
resp = await api_client.post("/api/clear")
assert resp.status_code == 200
body = resp.json()
assert body["cleared"] == 1
assert len(controller.transcriptions) == 0
# ── GET /api/audio-devices ──────────────────────────────────────────
@pytest.mark.asyncio
async def test_get_audio_devices(api_client, controller):
"""Audio devices endpoint should return a list, even when mocked."""
# Mock sounddevice so the test works without audio hardware
with patch("backend.app_controller.AppController.get_audio_devices",
return_value=[{"index": 0, "name": "Mock Mic"}]):
resp = await api_client.get("/api/audio-devices")
assert resp.status_code == 200
data = resp.json()
assert "devices" in data
assert len(data["devices"]) >= 1
# ── GET /api/compute-devices ────────────────────────────────────────
@pytest.mark.asyncio
async def test_get_compute_devices(api_client, controller):
resp = await api_client.get("/api/compute-devices")
assert resp.status_code == 200
data = resp.json()
assert "devices" in data
# At minimum we get the "Auto-detect" entry
assert any(d["id"] == "auto" for d in data["devices"])
# ── GET /api/check-update ──────────────────────────────────────────
@pytest.mark.asyncio
async def test_check_update(api_client, controller):
"""check-update should return a dict with an 'available' key."""
with patch.object(controller, "check_for_updates",
return_value={"available": False, "current_version": "1.0.0"}):
resp = await api_client.get("/api/check-update")
assert resp.status_code == 200
data = resp.json()
assert "available" in data
# ── GET /api/version ────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_version(api_client):
resp = await api_client.get("/api/version")
assert resp.status_code == 200
data = resp.json()
assert "version" in data
# Should be a non-empty string
assert isinstance(data["version"], str)
assert len(data["version"]) > 0

View File

@@ -1,181 +0,0 @@
"""Tests for backend.app_controller.AppController."""
import sys
from datetime import datetime
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
# Ensure project root is on path
project_root = Path(__file__).resolve().parent.parent.parent
sys.path.insert(0, str(project_root))
from backend.app_controller import AppState
# ── basic state ─────────────────────────────────────────────────────
def test_initial_state(controller):
"""A freshly constructed controller should be INITIALIZING and not transcribing."""
assert controller.state == AppState.INITIALIZING
assert controller.is_transcribing is False
# ── start / stop ────────────────────────────────────────────────────
def test_start_transcription_without_engine(controller):
"""Starting transcription before the engine is ready should fail gracefully."""
controller.transcription_engine = None
success, message = controller.start_transcription()
assert success is False
assert "not ready" in message.lower()
def test_start_stop_cycle(controller):
"""Full start -> stop cycle with a mocked engine that reports ready."""
engine = MagicMock()
engine.is_ready.return_value = True
engine.start_recording.return_value = True
controller.transcription_engine = engine
# Start
ok, msg = controller.start_transcription()
assert ok is True
assert controller.is_transcribing is True
assert controller.state == AppState.TRANSCRIBING
# Stop
ok, msg = controller.stop_transcription()
assert ok is True
assert controller.is_transcribing is False
engine.stop_recording.assert_called_once()
def test_double_start_rejected(controller):
"""Calling start_transcription twice should reject the second call."""
engine = MagicMock()
engine.is_ready.return_value = True
engine.start_recording.return_value = True
controller.transcription_engine = engine
controller.start_transcription()
success, message = controller.start_transcription()
assert success is False
assert "already" in message.lower()
# ── transcription storage ───────────────────────────────────────────
def test_clear_transcriptions(controller):
"""clear_transcriptions should empty the list and return the count."""
from client.models import TranscriptionResult
controller.transcriptions = [
TranscriptionResult(text="Hello", is_final=True, timestamp=datetime.now(), user_name="Alice"),
TranscriptionResult(text="World", is_final=True, timestamp=datetime.now(), user_name="Bob"),
]
count = controller.clear_transcriptions()
assert count == 2
assert len(controller.transcriptions) == 0
def test_get_transcriptions_text_with_timestamps(controller):
"""get_transcriptions_text should include [HH:MM:SS] prefixes when requested."""
from client.models import TranscriptionResult
ts = datetime(2025, 1, 15, 10, 30, 45)
controller.transcriptions = [
TranscriptionResult(text="Test line", is_final=True, timestamp=ts, user_name="User"),
]
text = controller.get_transcriptions_text(include_timestamps=True)
assert "[10:30:45]" in text
assert "User:" in text
assert "Test line" in text
# ── settings / engine reload ────────────────────────────────────────
def test_apply_settings_triggers_reload_on_model_change(controller):
"""Changing the transcription model should trigger an engine reload."""
controller.current_model_size = "base.en"
controller.current_device_config = "auto"
# Patch reload_engine so it doesn't actually try to spin up threads
controller.reload_engine = MagicMock(return_value=(True, "reloaded"))
reloaded, msg = controller.apply_settings({
"transcription.model": "small.en",
})
assert reloaded is True
controller.reload_engine.assert_called_once()
def test_apply_settings_no_reload_when_same(controller):
"""If model and device haven't changed, no reload should happen."""
controller.current_model_size = "base.en"
controller.current_device_config = "auto"
# Ensure config returns the same values
controller.config.set("transcription.model", "base.en")
controller.config.set("transcription.device", "auto")
controller.reload_engine = MagicMock(return_value=(True, "reloaded"))
reloaded, msg = controller.apply_settings({
"display.font_size": 20,
})
assert reloaded is False
controller.reload_engine.assert_not_called()
# ── transcription callbacks ─────────────────────────────────────────
def test_on_final_transcription_callback_fires(controller):
"""_on_final_transcription should append and invoke on_transcription callback."""
from client.models import TranscriptionResult
received = []
controller.on_transcription = lambda data: received.append(data)
controller.is_transcribing = True
controller._set_state(AppState.TRANSCRIBING)
result = TranscriptionResult(
text="Hello world",
is_final=True,
timestamp=datetime.now(),
user_name="Tester",
)
controller._on_final_transcription(result)
assert len(controller.transcriptions) == 1
assert len(received) == 1
assert received[0]["text"] == "Hello world"
assert received[0]["user_name"] == "Tester"
assert received[0]["is_preview"] is False
def test_on_final_transcription_ignored_when_not_transcribing(controller):
"""If the controller is not in transcribing state the callback should be a no-op."""
from client.models import TranscriptionResult
controller.is_transcribing = False
result = TranscriptionResult(
text="Should be ignored",
is_final=True,
timestamp=datetime.now(),
user_name="Ghost",
)
controller._on_final_transcription(result)
assert len(controller.transcriptions) == 0

View File

@@ -1,56 +0,0 @@
"""Tests for backend.main_headless ready-event JSON format."""
import sys
from pathlib import Path
import pytest
# Ensure project root is on path
project_root = Path(__file__).resolve().parent.parent.parent
sys.path.insert(0, str(project_root))
def test_ready_event_reports_api_port_not_obs_port():
"""The ready JSON printed by main_headless must set ``port`` to
``obs_port + 1`` (the API port), not the OBS display port.
From main_headless.py::
obs_port = controller.actual_web_port or args.port
api_port = obs_port + 1
print(json.dumps({
"event": "ready",
"port": api_port,
"obs_port": obs_port,
}), flush=True)
We verify this contract by reading the source and checking the
structure directly (running main() would start a real server).
"""
import ast
import textwrap
source_path = project_root / "backend" / "main_headless.py"
source = source_path.read_text()
# Verify the key relationships exist in the source:
# 1. api_port = obs_port + 1
assert "api_port = obs_port + 1" in source, (
"Expected `api_port = obs_port + 1` in main_headless.py"
)
# 2. The ready event JSON uses api_port for "port", not obs_port
assert '"port": api_port' in source or "'port': api_port" in source, (
"The ready event should report api_port as 'port'"
)
# 3. obs_port is also included separately
assert '"obs_port": obs_port' in source or "'obs_port': obs_port" in source, (
"The ready event should also include 'obs_port'"
)
# 4. Verify the event name
assert '"event": "ready"' in source or "'event': 'ready'" in source, (
"The ready event should have event='ready'"
)

View File

@@ -17,7 +17,7 @@ from datetime import datetime
from queue import Queue, Empty
from typing import Optional, Callable
from client.models import TranscriptionResult
from client.transcription_engine_realtime import TranscriptionResult
logger = logging.getLogger(__name__)
@@ -67,7 +67,7 @@ class DeepgramTranscriptionEngine:
# Audio parameters
self.sample_rate: int = 16000
self.channels: int = 1
self.blocksize: int = 1024 # ~64ms chunks for lower latency streaming
self.blocksize: int = 4096
# Callbacks
self.realtime_callback: Optional[Callable[[TranscriptionResult], None]] = None
@@ -156,30 +156,17 @@ class DeepgramTranscriptionEngine:
return True
self._stop_event.clear()
self._ws_connected = threading.Event()
self._is_recording = True
# Start the asyncio event-loop thread (handles WS send/receive)
self._thread = threading.Thread(target=self._run_event_loop, daemon=True)
self._thread.start()
# Wait for the WebSocket to connect before starting audio capture.
# Without this, audio chunks arrive before the WS is open -> broken pipe.
if not self._ws_connected.wait(timeout=15):
logger.error("Timed out waiting for Deepgram WebSocket connection")
print("ERROR: Timed out waiting for Deepgram WebSocket connection")
self._last_error = "Timed out connecting to Deepgram"
self._is_recording = False
self._stop_event.set()
return False
# Start the audio capture stream
try:
self._start_audio_stream()
except Exception as exc:
logger.error("Failed to open audio stream: %s", exc)
print(f"ERROR: Failed to open audio stream: {exc}")
self._last_error = f"Audio stream error: {exc}"
self._is_recording = False
self._stop_event.set()
return False
@@ -296,11 +283,6 @@ class DeepgramTranscriptionEngine:
if not await self._managed_handshake():
return
# Signal that the WebSocket is connected and ready
logger.info("WebSocket connected to Deepgram")
if hasattr(self, '_ws_connected'):
self._ws_connected.set()
# Run send and receive concurrently
await asyncio.gather(
self._send_loop(),
@@ -332,8 +314,6 @@ class DeepgramTranscriptionEngine:
f"model={self.deepgram_model}"
f"&language={self.language}"
"&interim_results=true"
"&punctuate=true"
"&smart_format=true"
"&encoding=linear16"
f"&sample_rate={self.sample_rate}"
f"&channels={self.channels}"
@@ -390,16 +370,10 @@ class DeepgramTranscriptionEngine:
async def _send_loop(self):
"""Drain the audio queue and push raw PCM bytes over the WebSocket."""
loop = asyncio.get_event_loop()
while not self._stop_event.is_set():
try:
# Use run_in_executor to avoid blocking the async event loop
# (which would stall the receive loop and delay transcriptions)
pcm_bytes = await asyncio.wait_for(
loop.run_in_executor(None, lambda: self._audio_queue.get(timeout=0.5)),
timeout=1.0,
)
except (Empty, asyncio.TimeoutError):
pcm_bytes = self._audio_queue.get(timeout=0.1)
except Empty:
continue
try:

View File

@@ -1,29 +0,0 @@
"""Shared data models used across transcription engines."""
from datetime import datetime
class TranscriptionResult:
"""Represents a transcription result."""
def __init__(self, text: str, is_final: bool, timestamp: datetime, user_name: str = ""):
"""
Initialize transcription result.
Args:
text: Transcribed text
is_final: Whether this is a final transcription or realtime preview
timestamp: Timestamp of transcription
user_name: Name of the user/speaker
"""
self.text = text.strip()
self.is_final = is_final
self.timestamp = timestamp
self.user_name = user_name
def __repr__(self) -> str:
time_str = self.timestamp.strftime("%H:%M:%S")
prefix = "[FINAL]" if self.is_final else "[PREVIEW]"
if self.user_name and self.user_name.strip():
return f"{prefix} [{time_str}] {self.user_name}: {self.text}"
return f"{prefix} [{time_str}] {self.text}"

View File

@@ -1,78 +0,0 @@
"""Tests for client.config.Config."""
import sys
from pathlib import Path
import pytest
# Ensure project root is on path
project_root = Path(__file__).resolve().parent.parent.parent
sys.path.insert(0, str(project_root))
from client.config import Config
@pytest.fixture
def cfg(tmp_path):
"""A Config backed by a temp file so we never touch the real user config."""
return Config(config_path=str(tmp_path / "test_config.yaml"))
# ── dot-notation get ────────────────────────────────────────────────
def test_dot_notation_get(cfg):
"""Config.get should traverse nested dicts using dot-separated keys."""
cfg.config = {"audio": {"sample_rate": 16000}}
assert cfg.get("audio.sample_rate") == 16000
# ── dot-notation set ────────────────────────────────────────────────
def test_dot_notation_set(cfg):
"""Config.set should create/update nested values via dot-separated keys."""
cfg.set("audio.sample_rate", 44100)
assert cfg.config["audio"]["sample_rate"] == 44100
# Also readable via .get
assert cfg.get("audio.sample_rate") == 44100
# ── missing key returns default ─────────────────────────────────────
def test_missing_key_returns_default(cfg):
"""Accessing a nonexistent key should return the supplied default."""
assert cfg.get("nonexistent.path", "fallback") == "fallback"
assert cfg.get("also.missing") is None # default default is None
# ── nested set creates intermediate dicts ───────────────────────────
def test_nested_set_creates_path(cfg):
"""Setting a deeply nested key should create all intermediate dicts."""
cfg.config = {}
cfg.set("a.b.c.d", 42)
assert cfg.config["a"]["b"]["c"]["d"] == 42
assert cfg.get("a.b.c.d") == 42
# ── save and reload round-trip ──────────────────────────────────────
def test_save_and_reload(tmp_path):
"""Values persisted via save() should survive a fresh Config load."""
config_file = str(tmp_path / "roundtrip.yaml")
# Create and populate
cfg1 = Config(config_path=config_file)
cfg1.set("user.name", "TestUser")
cfg1.set("transcription.model", "tiny.en")
# Load a fresh instance from the same file
cfg2 = Config(config_path=config_file)
assert cfg2.get("user.name") == "TestUser"
assert cfg2.get("transcription.model") == "tiny.en"

View File

@@ -8,8 +8,30 @@ from threading import Lock
import logging
# Re-export TranscriptionResult from the shared models module for backward compatibility
from client.models import TranscriptionResult # noqa: F401
class TranscriptionResult:
"""Represents a transcription result."""
def __init__(self, text: str, is_final: bool, timestamp: datetime, user_name: str = ""):
"""
Initialize transcription result.
Args:
text: Transcribed text
is_final: Whether this is a final transcription or realtime preview
timestamp: Timestamp of transcription
user_name: Name of the user/speaker
"""
self.text = text.strip()
self.is_final = is_final
self.timestamp = timestamp
self.user_name = user_name
def __repr__(self) -> str:
time_str = self.timestamp.strftime("%H:%M:%S")
prefix = "[FINAL]" if self.is_final else "[PREVIEW]"
if self.user_name and self.user_name.strip():
return f"{prefix} [{time_str}] {self.user_name}: {self.text}"
return f"{prefix} [{time_str}] {self.text}"
def to_dict(self) -> dict:
"""Convert to dictionary."""

View File

@@ -1,169 +0,0 @@
# -*- mode: python ; coding: utf-8 -*-
"""PyInstaller spec file for cloud-only Local Transcription backend.
This builds a lightweight sidecar (~50MB) that only supports Deepgram
cloud transcription (managed + BYOK). No local Whisper models, no
PyTorch, no CUDA -- just audio capture and WebSocket streaming.
"""
import sys
import os
block_cipher = None
is_windows = sys.platform == 'win32'
from PyInstaller.utils.hooks import collect_submodules, collect_data_files
# Data files
datas = [
('config/default_config.yaml', 'config'),
]
# Collect sounddevice's bundled PortAudio library (_sounddevice_data)
try:
import sounddevice
sd_path = os.path.dirname(sounddevice.__file__)
sd_data = os.path.join(sd_path, '_sounddevice_data')
if os.path.exists(sd_data):
datas.append((sd_data, '_sounddevice_data'))
print(f" + Collected sounddevice PortAudio data from {sd_data}")
# Also collect the package itself
sd_datas = collect_data_files('sounddevice')
if sd_datas:
datas += sd_datas
print(f" + Collected {len(sd_datas)} sounddevice data files")
except ImportError:
print(" - Warning: sounddevice not found")
# Hidden imports -- only lightweight deps needed for Deepgram streaming
hiddenimports = [
'sounddevice',
'_sounddevice_data',
'numpy',
# FastAPI and dependencies
'fastapi',
'fastapi.routing',
'fastapi.responses',
'starlette',
'starlette.applications',
'starlette.routing',
'starlette.responses',
'starlette.websockets',
'starlette.middleware',
'starlette.middleware.cors',
'pydantic',
'pydantic.fields',
'pydantic.main',
'anyio',
'anyio._backends',
'anyio._backends._asyncio',
'sniffio',
# Uvicorn
'uvicorn',
'uvicorn.logging',
'uvicorn.loops',
'uvicorn.loops.auto',
'uvicorn.protocols',
'uvicorn.protocols.http',
'uvicorn.protocols.http.auto',
'uvicorn.protocols.http.h11_impl',
'uvicorn.protocols.websockets',
'uvicorn.protocols.websockets.auto',
'uvicorn.protocols.websockets.wsproto_impl',
'uvicorn.lifespan',
'uvicorn.lifespan.on',
'h11',
'websockets',
'websockets.legacy',
'websockets.legacy.server',
# HTTP client
'requests',
'urllib3',
'certifi',
'charset_normalizer',
]
# Collect submodules for key packages
print("Collecting submodules for cloud backend packages...")
for package in ['fastapi', 'starlette', 'pydantic', 'pydantic_core', 'anyio', 'uvicorn', 'websockets', 'h11']:
try:
submodules = collect_submodules(package)
hiddenimports += submodules
print(f" + Collected {len(submodules)} submodules from {package}")
except Exception as e:
print(f" - Warning: Could not collect {package}: {e}")
# Collect data files
for package in ['fastapi', 'starlette', 'pydantic', 'uvicorn']:
try:
data_files = collect_data_files(package)
if data_files:
datas += data_files
except Exception:
pass
# Pydantic critical deps
hiddenimports += [
'colorsys', 'decimal', 'json', 'ipaddress', 'pathlib', 'uuid',
'email.message', 'typing_extensions',
]
a = Analysis(
['backend/main_headless.py'],
pathex=[],
binaries=[],
datas=datas,
hiddenimports=hiddenimports,
hookspath=['hooks'],
hooksconfig={},
runtime_hooks=[],
excludes=[
# Exclude all heavy ML/local transcription deps
'torch', 'torchaudio', 'torchvision',
'faster_whisper', 'ctranslate2',
'RealtimeSTT', 'webrtcvad', 'webrtcvad_wheels',
'silero_vad', 'onnxruntime',
'openwakeword', 'pvporcupine', 'pyaudio',
'noisereduce', 'scipy',
# Exclude GUI frameworks
'PySide6', 'PyQt5', 'PyQt6', 'tkinter',
# Exclude other unnecessary heavy packages
'matplotlib', 'PIL', 'cv2',
],
win_no_prefer_redirects=False,
win_private_assemblies=False,
cipher=block_cipher,
noarchive=False,
)
pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)
exe = EXE(
pyz,
a.scripts,
[],
exclude_binaries=True,
name='local-transcription-backend',
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
console=True,
disable_windowed_traceback=False,
argv_emulation=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
icon='LocalTranscription.ico' if is_windows else None,
)
coll = COLLECT(
exe,
a.binaries,
a.zipfiles,
a.datas,
strip=False,
upx=True,
upx_exclude=[],
name='local-transcription-backend',
)

View File

@@ -38,21 +38,6 @@ datas = [
(vad_assets_path, 'faster_whisper/assets'),
] + pvporcupine_data_files
# Collect sounddevice's bundled PortAudio library (_sounddevice_data)
try:
import sounddevice
sd_path = os.path.dirname(sounddevice.__file__)
sd_data = os.path.join(sd_path, '_sounddevice_data')
if os.path.exists(sd_data):
datas.append((sd_data, '_sounddevice_data'))
print(f" + Collected sounddevice PortAudio data from {sd_data}")
sd_datas = collect_data_files('sounddevice')
if sd_datas:
datas += sd_datas
print(f" + Collected {len(sd_datas)} sounddevice data files")
except ImportError:
print(" - Warning: sounddevice not found")
# Hidden imports -- NO PySide6/Qt needed for headless backend
hiddenimports = [
# Transcription engine
@@ -61,7 +46,6 @@ hiddenimports = [
'faster_whisper.vad',
'ctranslate2',
'sounddevice',
'_sounddevice_data',
'scipy',
'scipy.signal',
'numpy',

1085
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
{
"name": "local-transcription",
"private": true,
"version": "2.0.13",
"version": "1.4.0",
"type": "module",
"scripts": {
"dev": "vite dev",
@@ -12,19 +12,16 @@
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"@tauri-apps/cli": "^2.0.0",
"@testing-library/svelte": "^5.3.1",
"@tsconfig/svelte": "^5.0.0",
"jsdom": "^29.0.2",
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"typescript": "~5.6.0",
"vite": "^6.0.0",
"vitest": "^4.1.3"
"vite": "^6.0.0"
},
"dependencies": {
"@tauri-apps/api": "^2.0.0",
"@tauri-apps/plugin-dialog": "^2.0.0",
"@tauri-apps/plugin-process": "^2.0.0",
"@tauri-apps/plugin-shell": "^2.0.0"
"@tauri-apps/plugin-shell": "^2.0.0",
"@tauri-apps/plugin-process": "^2.0.0"
}
}

View File

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

498
src-tauri/Cargo.lock generated
View File

@@ -47,15 +47,6 @@ 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"
@@ -316,10 +307,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0"
dependencies = [
"iana-time-zone",
"js-sys",
"num-traits",
"serde",
"wasm-bindgen",
"windows-link 0.2.1",
]
@@ -349,16 +338,6 @@ 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"
@@ -382,9 +361,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97"
dependencies = [
"bitflags 2.11.0",
"core-foundation 0.10.1",
"core-foundation",
"core-graphics-types",
"foreign-types 0.5.0",
"foreign-types",
"libc",
]
@@ -395,7 +374,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb"
dependencies = [
"bitflags 2.11.0",
"core-foundation 0.10.1",
"core-foundation",
"libc",
]
@@ -536,17 +515,6 @@ 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"
@@ -824,15 +792,6 @@ 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"
@@ -840,7 +799,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965"
dependencies = [
"foreign-types-macros",
"foreign-types-shared 0.3.1",
"foreign-types-shared",
]
[[package]]
@@ -854,12 +813,6 @@ 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"
@@ -1269,25 +1222,6 @@ 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"
@@ -1398,7 +1332,6 @@ dependencies = [
"bytes",
"futures-channel",
"futures-core",
"h2",
"http",
"http-body",
"httparse",
@@ -1409,38 +1342,6 @@ 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"
@@ -1459,11 +1360,9 @@ dependencies = [
"percent-encoding",
"pin-project-lite",
"socket2",
"system-configuration",
"tokio",
"tower-service",
"tracing",
"windows-registry",
]
[[package]]
@@ -1867,12 +1766,6 @@ 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"
@@ -1881,12 +1774,8 @@ checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
[[package]]
name = "local-transcription"
version = "2.0.8"
version = "1.4.0"
dependencies = [
"bytes",
"chrono",
"futures-util",
"reqwest 0.12.28",
"serde",
"serde_json",
"tauri",
@@ -1894,9 +1783,6 @@ dependencies = [
"tauri-plugin-dialog",
"tauri-plugin-process",
"tauri-plugin-shell",
"tempfile",
"tokio",
"zip",
]
[[package]]
@@ -2025,23 +1911,6 @@ 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"
@@ -2263,50 +2132,6 @@ 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"
@@ -2902,49 +2727,6 @@ 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"
@@ -2975,7 +2757,7 @@ dependencies = [
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
"wasm-streams 0.5.0",
"wasm-streams",
"web-sys",
]
@@ -3003,20 +2785,6 @@ 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"
@@ -3032,64 +2800,12 @@ 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"
@@ -3099,15 +2815,6 @@ 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"
@@ -3165,29 +2872,6 @@ 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"
@@ -3330,18 +3014,6 @@ 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"
@@ -3622,12 +3294,6 @@ 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"
@@ -3681,27 +3347,6 @@ 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"
@@ -3723,7 +3368,7 @@ checksum = "9103edf55f2da3c82aea4c7fab7c4241032bfeea0e71fa557d98e00e7ce7cc20"
dependencies = [
"bitflags 2.11.0",
"block2",
"core-foundation 0.10.1",
"core-foundation",
"core-graphics",
"crossbeam-channel",
"dispatch2",
@@ -3800,7 +3445,7 @@ dependencies = [
"percent-encoding",
"plist",
"raw-window-handle",
"reqwest 0.13.2",
"reqwest",
"serde",
"serde_json",
"serde_repr",
@@ -4074,19 +3719,6 @@ 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"
@@ -4198,45 +3830,11 @@ 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"
@@ -4518,12 +4116,6 @@ 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"
@@ -4573,12 +4165,6 @@ 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"
@@ -4737,19 +4323,6 @@ 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"
@@ -5026,17 +4599,6 @@ 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"
@@ -5082,15 +4644,6 @@ 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"
@@ -5579,12 +5132,6 @@ 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"
@@ -5618,37 +5165,8 @@ 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 = "2.0.13"
version = "1.4.0"
description = "Real-time speech-to-text transcription for streamers"
authors = ["Local Transcription Contributors"]
edition = "2021"
@@ -19,12 +19,3 @@ 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"] }
chrono = "0.4"
[dev-dependencies]
tempfile = "3"

View File

@@ -1,14 +0,0 @@
{
"identifier": "default",
"description": "Default permissions for the main window",
"windows": ["main"],
"permissions": [
"core:default",
"core:event:default",
"core:event:allow-listen",
"core:event:allow-emit",
"shell:default",
"dialog:default",
"process:default"
]
}

View File

@@ -1 +1 @@
{"default":{"identifier":"default","description":"Default permissions for the main window","local":true,"windows":["main"],"permissions":["core:default","core:event:default","core:event:allow-listen","core:event:allow-emit","shell:default","dialog:default","process:default"]}}
{}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 739 B

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 52 KiB

View File

@@ -1,98 +1,9 @@
mod sidecar;
use std::sync::Mutex;
use tauri::Manager;
/// App log directory, set during setup.
static LOG_DIR: std::sync::OnceLock<std::path::PathBuf> = std::sync::OnceLock::new();
/// Write a log message to the app's log file (for debugging).
#[tauri::command]
fn write_log(message: String) {
if let Some(log_dir) = LOG_DIR.get() {
let log_path = log_dir.join("frontend.log");
use std::io::Write;
if let Ok(mut f) = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&log_path)
{
let _ = writeln!(f, "[{}] {}", chrono::Local::now().format("%H:%M:%S%.3f"), message);
}
}
eprintln!("[frontend] {}", message);
}
#[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(std::sync::Arc::new(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");
std::fs::create_dir_all(&data_dir).expect("failed to create app data dir");
// Set up logging
LOG_DIR.set(data_dir.clone()).ok();
let log_path = data_dir.join("app.log");
if let Ok(mut f) = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&log_path)
{
use std::io::Write;
let _ = writeln!(f, "\n=== App started at {} ===", chrono::Local::now());
let _ = writeln!(f, "Resource dir: {}", resource_dir.display());
let _ = writeln!(f, "Data dir: {}", data_dir.display());
}
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,
sidecar::reset_sidecar,
write_log,
])
.build(tauri::generate_context!())
.expect("error while building tauri application")
.run(|app, event| {
match event {
tauri::RunEvent::Exit => {
if let Some(state) = app.try_state::<sidecar::ManagedSidecar>() {
if let Ok(mut mgr) = state.0.lock() {
eprintln!("[app] Stopping sidecar on exit...");
mgr.stop();
}
}
}
tauri::RunEvent::ExitRequested { .. } => {
// Also stop sidecar on exit request (Cmd+Q on macOS)
if let Some(state) = app.try_state::<sidecar::ManagedSidecar>() {
if let Ok(mut mgr) = state.0.lock() {
eprintln!("[app] Stopping sidecar on exit request...");
mgr.stop();
}
}
}
_ => {}
}
});
.run(tauri::generate_context!())
.expect("error while running tauri application");
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
{
"productName": "Local Transcription",
"version": "2.0.13",
"identifier": "net.anhonesthost.local-transcription",
"version": "1.4.0",
"identifier": "com.localtranscription.app",
"build": {
"frontendDist": "../dist",
"devUrl": "http://localhost:1420",
@@ -30,7 +30,6 @@
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico",
"icons/icon.png"
]

View File

@@ -5,22 +5,13 @@
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" | "update_available" | "starting" | "connected";
let showSettings = $state(false);
let sidecarState = $state<SidecarState>("checking");
let debugLog = $state("");
let availableUpdate = $state("");
let appVersion = $state("");
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;
@@ -30,94 +21,9 @@
showSettings = false;
}
let tauriInvoke: ((cmd: string, args?: Record<string, unknown>) => Promise<unknown>) | null = null;
function log(msg: string) {
console.log(`[App] ${msg}`);
debugLog = msg;
// Also write to file via Tauri if available
tauriInvoke?.("write_log", { message: msg });
}
async function checkAndLaunchSidecar() {
try {
log("Importing Tauri API...");
const { invoke } = await import("@tauri-apps/api/core");
tauriInvoke = invoke;
log("Checking if sidecar is installed...");
sidecarState = "checking";
const installed = await invoke<boolean>("check_sidecar");
log(`Sidecar installed: ${installed}`);
if (!installed) {
sidecarState = "needs_setup";
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
// and connect directly to localhost:8081
log(`Tauri not available (${err}), using dev mode`);
sidecarState = "starting";
backendStore.setPort(8081);
backendStore.connect();
configStore.fetchConfig();
}
}
async function launchSidecar() {
try {
const { invoke } = await import("@tauri-apps/api/core");
log("Starting sidecar...");
sidecarState = "starting";
await invoke("start_sidecar");
log("Getting sidecar port...");
const port = await invoke<number>("get_sidecar_port");
log(`Sidecar ready on port ${port}`);
backendStore.setPort(port);
backendStore.connect();
configStore.fetchConfig();
} catch (err) {
// If sidecar launch fails, still try connecting to default port
log(`Sidecar launch failed: ${err}, trying default port`);
sidecarState = "starting";
backendStore.connect();
configStore.fetchConfig();
}
}
async function onSidecarReady() {
await launchSidecar();
}
onMount(() => {
// Get app version from Tauri
import("@tauri-apps/api/app").then(({ getVersion }) =>
getVersion().then((v) => { appVersion = v; })
).catch(() => {
// Browser dev mode -- read from package.json or use fallback
appVersion = "dev";
});
checkAndLaunchSidecar();
backendStore.connect();
configStore.loadConfig();
return () => {
backendStore.disconnect();
@@ -125,73 +31,7 @@
});
</script>
{#if sidecarState === "checking"}
<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;">
<div class="connecting-icon">
<div class="spinner"></div>
</div>
<h2 style="font-size:20px;margin:16px 0 8px;">Local Transcription</h2>
<p style="color:#a0a0a0;font-size:14px;">Checking setup...</p>
{#if debugLog}
<p style="color:#707070;font-size:11px;margin-top:12px;">{debugLog}</p>
{/if}
</div>
</div>
{: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;">
<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 style="font-size:20px;margin:16px 0 8px;">Local Transcription</h2>
{#if connectionState === "error"}
<p style="color:#a0a0a0;">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 style="color:#a0a0a0;">Connecting to backend...</p>
{/if}
{#if debugLog}
<p style="color:#707070;font-size:11px;margin-top:12px;">{debugLog}</p>
{/if}
</div>
</div>
{:else}
<div class="app-shell">
<div class="app-shell">
<Header onSettingsClick={openSettings} />
<StatusBar />
@@ -210,76 +50,14 @@
<TranscriptionDisplay />
<Controls />
<div class="version-label">v{appVersion || backendStore.version}</div>
</div>
<div class="version-label">v{backendStore.version}</div>
</div>
{#if showSettings}
{#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

@@ -8,29 +8,18 @@
);
let isLoading = $state(false);
let errorMessage = $state("");
async function toggleTranscription() {
if (isLoading) return;
isLoading = true;
errorMessage = "";
try {
if (isTranscribing) {
await backendStore.apiPost("/api/stop");
} else {
await backendStore.apiPost("/api/start");
}
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
// Ignore "Already transcribing/not transcribing" -- just sync the state
if (!msg.includes("400")) {
console.error("Failed to toggle transcription:", msg);
errorMessage = msg;
}
} catch (err) {
console.error("Failed to toggle transcription:", err);
} finally {
// Always poll status to sync UI with actual backend state,
// even if the API call failed (e.g. "Already transcribing")
await backendStore.pollStatus();
isLoading = false;
}
}
@@ -112,19 +101,9 @@
<button onclick={saveTranscriptions} disabled={!backendStore.connected}>
Save
</button>
{#if errorMessage}
<span class="error-msg">{errorMessage}</span>
{/if}
</div>
<style>
.error-msg {
color: #f44336;
font-size: 12px;
margin-left: 8px;
}
.controls {
display: flex;
align-items: center;

View File

@@ -27,10 +27,6 @@
let showTimestamps = $state(true);
let fadeSeconds = $state(10);
let maxLines = $state(100);
let fontSource = $state("System Font");
let fontFamily = $state("Courier");
let websafeFont = $state("Arial");
let googleFont = $state("Roboto");
let fontSize = $state(12);
let userColor = $state("#4CAF50");
let textColor = $state("#FFFFFF");
@@ -41,14 +37,10 @@
let syncPassphrase = $state("");
let remoteMode = $state("local");
let remoteServerUrl = $state("");
let byokApiKey = $state("");
let managedEmail = $state("");
let managedPassword = $state("");
let autoCheckUpdates = $state(true);
let saving = $state(false);
let saveMessage = $state("");
// Fetched device lists
let audioDevices = $state<{ id: string; name: string }[]>([]);
let computeDevices = $state<{ id: string; name: string }[]>([]);
@@ -103,10 +95,6 @@
showTimestamps = cfg.display.show_timestamps;
fadeSeconds = cfg.display.fade_after_seconds;
maxLines = cfg.display.max_lines;
fontSource = cfg.display.font_source ?? "System Font";
fontFamily = cfg.display.font_family ?? "Courier";
websafeFont = cfg.display.websafe_font ?? "Arial";
googleFont = cfg.display.google_font ?? "Roboto";
fontSize = cfg.display.font_size;
userColor = cfg.display.user_color;
textColor = cfg.display.text_color;
@@ -119,7 +107,6 @@
syncPassphrase = cfg.server_sync.passphrase;
remoteMode = cfg.remote.mode;
remoteServerUrl = cfg.remote.server_url;
byokApiKey = cfg.remote.byok_api_key ?? "";
autoCheckUpdates = cfg.updates.auto_check;
});
@@ -182,10 +169,6 @@
show_timestamps: showTimestamps,
fade_after_seconds: fadeSeconds,
max_lines: maxLines,
font_source: fontSource,
font_family: fontFamily,
websafe_font: websafeFont,
google_font: googleFont,
font_size: fontSize,
user_color: userColor,
text_color: textColor,
@@ -200,23 +183,17 @@
remote: {
mode: remoteMode,
server_url: remoteServerUrl,
byok_api_key: byokApiKey,
},
updates: {
auto_check: autoCheckUpdates,
},
};
saving = true;
saveMessage = "";
try {
await configStore.updateConfig(updates);
saveMessage = "Settings saved!";
setTimeout(() => onClose(), 600);
await configStore.saveConfig(updates);
onClose();
} catch (err) {
console.error("Failed to save settings:", err);
saveMessage = `Error: ${err}`;
saving = false;
}
}
@@ -226,27 +203,15 @@
async function handleCheckUpdates() {
try {
await backendStore.apiGet("/api/check-update");
await backendStore.apiPost("/api/check-updates");
} catch (err) {
console.error("Failed to check for updates:", err);
}
}
async function handleChangeSidecar() {
try {
const { invoke } = await import("@tauri-apps/api/core");
await invoke("reset_sidecar");
// Force a page reload which will re-trigger the setup flow
window.location.reload();
} catch (err) {
console.error("Failed to reset sidecar:", err);
saveMessage = `Error: ${err}`;
}
}
async function handleManagedLogin() {
try {
await backendStore.apiPost("/api/login", {
await backendStore.apiPost("/api/remote/login", {
email: managedEmail,
password: managedPassword,
});
@@ -257,7 +222,7 @@
async function handleManagedRegister() {
try {
await backendStore.apiPost("/api/register", {
await backendStore.apiPost("/api/remote/register", {
email: managedEmail,
password: managedPassword,
});
@@ -509,95 +474,6 @@
bind:value={maxLines}
/>
</div>
<div class="field">
<label for="font-source">Font Source</label>
<select id="font-source" bind:value={fontSource}>
<option value="System Font">System Font</option>
<option value="Web-Safe">Web-Safe</option>
<option value="Google Font">Google Font</option>
</select>
</div>
{#if fontSource === "System Font"}
<div class="field">
<label for="font-family">System Font Family</label>
<input id="font-family" type="text" bind:value={fontFamily} placeholder="Courier" />
</div>
{/if}
{#if fontSource === "Web-Safe"}
<div class="field">
<label for="websafe-font">Web-Safe Font</label>
<select id="websafe-font" bind:value={websafeFont}>
<option value="Arial">Arial</option>
<option value="Arial Black">Arial Black</option>
<option value="Comic Sans MS">Comic Sans MS</option>
<option value="Courier New">Courier New</option>
<option value="Georgia">Georgia</option>
<option value="Impact">Impact</option>
<option value="Lucida Console">Lucida Console</option>
<option value="Lucida Sans Unicode">Lucida Sans Unicode</option>
<option value="Palatino Linotype">Palatino Linotype</option>
<option value="Tahoma">Tahoma</option>
<option value="Times New Roman">Times New Roman</option>
<option value="Trebuchet MS">Trebuchet MS</option>
<option value="Verdana">Verdana</option>
</select>
</div>
{/if}
{#if fontSource === "Google Font"}
<div class="field">
<label for="google-font">Google Font</label>
<select id="google-font" bind:value={googleFont}>
<optgroup label="Sans Serif">
<option value="Roboto">Roboto</option>
<option value="Open Sans">Open Sans</option>
<option value="Lato">Lato</option>
<option value="Montserrat">Montserrat</option>
<option value="Poppins">Poppins</option>
<option value="Nunito">Nunito</option>
<option value="Raleway">Raleway</option>
<option value="Ubuntu">Ubuntu</option>
<option value="Rubik">Rubik</option>
<option value="Work Sans">Work Sans</option>
<option value="Inter">Inter</option>
<option value="Outfit">Outfit</option>
<option value="Quicksand">Quicksand</option>
<option value="Comfortaa">Comfortaa</option>
<option value="Varela Round">Varela Round</option>
</optgroup>
<optgroup label="Serif">
<option value="Playfair Display">Playfair Display</option>
<option value="Merriweather">Merriweather</option>
<option value="Lora">Lora</option>
<option value="PT Serif">PT Serif</option>
<option value="Crimson Text">Crimson Text</option>
</optgroup>
<optgroup label="Monospace">
<option value="Roboto Mono">Roboto Mono</option>
<option value="Source Code Pro">Source Code Pro</option>
<option value="Fira Code">Fira Code</option>
<option value="JetBrains Mono">JetBrains Mono</option>
<option value="IBM Plex Mono">IBM Plex Mono</option>
</optgroup>
<optgroup label="Display">
<option value="Bebas Neue">Bebas Neue</option>
<option value="Oswald">Oswald</option>
<option value="Righteous">Righteous</option>
<option value="Bangers">Bangers</option>
<option value="Permanent Marker">Permanent Marker</option>
</optgroup>
<optgroup label="Handwriting">
<option value="Pacifico">Pacifico</option>
<option value="Lobster">Lobster</option>
<option value="Dancing Script">Dancing Script</option>
<option value="Caveat">Caveat</option>
<option value="Satisfy">Satisfy</option>
</optgroup>
</select>
<p style="font-size: 11px; color: var(--text-muted); margin-top: 4px;">
Browse more at <a href="https://fonts.google.com" target="_blank" rel="noopener" style="color: var(--accent-blue);">fonts.google.com</a>
</p>
</div>
{/if}
<div class="field">
<label for="font-size">Font Size: {fontSize}px</label>
<input
@@ -696,7 +572,7 @@
BYOK (Bring Your Own Key)
</label>
</div>
{#if remoteMode === "managed"}
{#if remoteMode !== "local"}
<div class="field">
<label for="remote-url">Server URL</label>
<input
@@ -707,20 +583,6 @@
/>
</div>
{/if}
{#if remoteMode === "byok"}
<div class="field">
<label for="byok-key">Deepgram API Key</label>
<input
id="byok-key"
type="password"
bind:value={byokApiKey}
placeholder="Enter your Deepgram API key"
/>
<p style="font-size: 11px; color: var(--text-muted); margin-top: 4px;">
Get a key at <a href="https://console.deepgram.com" target="_blank" rel="noopener" style="color: var(--accent-blue);">console.deepgram.com</a>
</p>
</div>
{/if}
{#if remoteMode === "managed"}
<div class="managed-auth">
<div class="field">
@@ -761,27 +623,11 @@
</div>
<button onclick={handleCheckUpdates}>Check Now</button>
</section>
<!-- Transcription Engine -->
<section class="settings-section">
<h3>Transcription Engine</h3>
<p style="font-size: 12px; color: var(--text-secondary); margin-bottom: 12px;">
Switch between local (Whisper) and cloud (Deepgram) transcription engines.
This will stop the current engine, remove the downloaded files, and restart
with the new engine selection.
</p>
<button class="danger-btn" onclick={handleChangeSidecar}>Change Transcription Engine</button>
</section>
</div>
<div class="settings-footer">
{#if saveMessage}
<span class="save-message" class:error={saveMessage.startsWith("Error")}>{saveMessage}</span>
{/if}
<button onclick={handleCancel} disabled={saving}>Cancel</button>
<button class="primary" onclick={handleSave} disabled={saving}>
{saving ? "Saving..." : "Save"}
</button>
<button onclick={handleCancel}>Cancel</button>
<button class="primary" onclick={handleSave}>Save</button>
</div>
</div>
</div>
@@ -925,35 +771,10 @@
.settings-footer {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
padding: 16px 20px;
border-top: 1px solid var(--border-color);
flex-shrink: 0;
}
.save-message {
margin-right: auto;
font-size: 13px;
color: #4CAF50;
}
.save-message.error {
color: #f44336;
}
.danger-btn {
background: transparent;
border: 1px solid var(--accent-red, #f44336);
color: var(--accent-red, #f44336);
padding: 8px 16px;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
}
.danger-btn:hover {
background: rgba(244, 67, 54, 0.1);
}
</style>

View File

@@ -1,420 +0,0 @@
<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<{ downloaded: number; total: number; phase: string; message: string }>(
"sidecar-download-progress",
(event) => {
const { downloaded, total, message } = event.payload;
progress = total > 0 ? (downloaded / total) * 100 : 0;
progressMessage = 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">
Choose a transcription engine. You can change this later in Settings.
</p>
<div class="variant-options">
<label class="variant-option" class:selected={variant === "cloud"}>
<input
type="radio"
name="variant"
value="cloud"
bind:group={variant}
/>
<div class="variant-info">
<span class="variant-name">Cloud (Deepgram)</span>
<span class="variant-desc">~50 MB download</span>
<span class="variant-detail">
Fast, accurate streaming transcription via Deepgram's servers.
Requires internet and a Deepgram API key.
Best for most users — low resource usage, works on any hardware.
</span>
<span class="variant-tag recommended">Recommended</span>
</div>
</label>
<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">Local - CPU</span>
<span class="variant-desc">~500 MB download</span>
<span class="variant-detail">
Runs Whisper AI models locally on your CPU. No internet needed
after download. Good for privacy or offline use, but slower and
uses more system resources than cloud.
</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;
}
.variant-detail {
font-size: 11px;
color: #666;
line-height: 1.4;
margin-top: 2px;
}
.variant-tag {
display: inline-block;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
padding: 2px 6px;
border-radius: 3px;
margin-top: 4px;
width: fit-content;
}
.variant-tag.recommended {
background: rgba(76, 175, 80, 0.15);
color: #4CAF50;
}
.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,77 +0,0 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { backendStore } from "./backend.svelte.ts";
// Mock WebSocket globally so the store module can reference it
class MockWebSocket {
onopen: ((ev: Event) => void) | null = null;
onclose: ((ev: CloseEvent) => void) | null = null;
onmessage: ((ev: MessageEvent) => void) | null = null;
onerror: ((ev: Event) => void) | null = null;
close = vi.fn();
}
vi.stubGlobal("WebSocket", MockWebSocket);
// Mock fetch to prevent real network calls
vi.stubGlobal(
"fetch",
vi.fn(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve({}),
})
)
);
describe("backend store", () => {
beforeEach(() => {
backendStore.disconnect();
backendStore.setPort(8081);
});
it("test_exports_expected_properties", () => {
expect(backendStore).toHaveProperty("port");
expect(backendStore).toHaveProperty("connectionState");
expect(backendStore).toHaveProperty("connected");
expect(backendStore).toHaveProperty("appState");
expect(backendStore).toHaveProperty("stateMessage");
expect(backendStore).toHaveProperty("deviceInfo");
expect(backendStore).toHaveProperty("version");
expect(backendStore).toHaveProperty("lastError");
expect(backendStore).toHaveProperty("apiBaseUrl");
expect(backendStore).toHaveProperty("wsUrl");
expect(backendStore).toHaveProperty("obsUrl");
expect(backendStore).toHaveProperty("syncUrl");
});
it("test_exports_expected_methods", () => {
expect(typeof backendStore.setPort).toBe("function");
expect(typeof backendStore.connect).toBe("function");
expect(typeof backendStore.disconnect).toBe("function");
expect(typeof backendStore.apiUrl).toBe("function");
expect(typeof backendStore.apiFetch).toBe("function");
expect(typeof backendStore.apiGet).toBe("function");
expect(typeof backendStore.apiPost).toBe("function");
expect(typeof backendStore.apiPut).toBe("function");
});
it("test_obsUrl_derives_from_port", () => {
backendStore.setPort(8081);
expect(backendStore.obsUrl).toBe("http://localhost:8080");
});
it("test_apiBaseUrl_uses_port", () => {
backendStore.setPort(8081);
expect(backendStore.apiBaseUrl).toBe("http://localhost:8081");
});
it("test_wsUrl_uses_port", () => {
backendStore.setPort(8081);
expect(backendStore.wsUrl).toBe("ws://localhost:8081/ws/control");
});
it("test_initial_state", () => {
// After disconnect() in beforeEach, state should be disconnected
expect(backendStore.connectionState).toBe("disconnected");
expect(backendStore.appState).toBe("initializing");
});
});

View File

@@ -54,35 +54,6 @@ async function apiFetch(path: string, options?: RequestInit): Promise<Response>
return fetch(url, { ...options, headers });
}
// ── Status polling ──────────────────────────────────────────────────
let statusPollTimer: ReturnType<typeof setTimeout> | null = null;
async function pollStatus() {
try {
const resp = await fetch(apiUrl("/api/status"));
if (resp.ok) {
const data = await resp.json();
if (data.state) {
state.appState = data.state as AppState;
}
if (data.engine_device) {
state.deviceInfo = data.engine_device;
}
if (data.version) {
state.version = data.version;
}
}
} catch {
// API not ready yet, will retry
}
// Keep polling every 2s while still initializing
if (state.appState === "initializing" && state.connectionState === "connected") {
statusPollTimer = setTimeout(pollStatus, 2000);
}
}
// ── WebSocket management ─────────────────────────────────────────────
function connectWebSocket() {
@@ -109,9 +80,6 @@ function _openSocket() {
clearTimeout(reconnectTimer);
reconnectTimer = null;
}
// Poll status to catch engine ready state that may have been
// missed (engine can finish before WebSocket connects)
pollStatus();
};
ws.onmessage = (event) => {
@@ -164,10 +132,6 @@ function _scheduleReconnect() {
}
function disconnect() {
if (statusPollTimer) {
clearTimeout(statusPollTimer);
statusPollTimer = null;
}
if (reconnectTimer) {
clearTimeout(reconnectTimer);
reconnectTimer = null;
@@ -291,18 +255,9 @@ export const backendStore = {
get wsUrl() {
return `ws://localhost:${state.port}/ws/control`;
},
get obsUrl() {
// OBS display runs on the web server port (one below the API port)
const obsPort = state.port > 0 ? state.port - 1 : 8080;
return `http://localhost:${obsPort}`;
},
get syncUrl() {
return "";
},
setPort,
connect: connectWebSocket,
disconnect,
pollStatus,
apiUrl,
apiFetch,
apiGet,

View File

@@ -1,48 +0,0 @@
import { describe, it, expect, vi } from "vitest";
// Mock fetch so the backend store module doesn't make real requests
vi.stubGlobal(
"fetch",
vi.fn(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve({}),
})
)
);
// Mock WebSocket for the backend store dependency
class MockWebSocket {
onopen: ((ev: Event) => void) | null = null;
onclose: ((ev: CloseEvent) => void) | null = null;
onmessage: ((ev: MessageEvent) => void) | null = null;
onerror: ((ev: Event) => void) | null = null;
close = vi.fn();
}
vi.stubGlobal("WebSocket", MockWebSocket);
import { configStore } from "./config.svelte.ts";
describe("config store", () => {
it("test_has_fetchConfig_method", () => {
expect(typeof configStore.fetchConfig).toBe("function");
});
it("test_has_updateConfig_method", () => {
expect(typeof configStore.updateConfig).toBe("function");
});
it("test_config_defaults_have_expected_keys", () => {
const cfg = configStore.config;
expect(cfg).toHaveProperty("user");
expect(cfg).toHaveProperty("audio");
expect(cfg).toHaveProperty("transcription");
expect(cfg).toHaveProperty("display");
expect(cfg).toHaveProperty("remote");
expect(cfg).toHaveProperty("updates");
});
it("test_remote_config_has_byok_api_key", () => {
expect(configStore.config.remote.byok_api_key).toBeDefined();
});
});

View File

@@ -1,34 +0,0 @@
import { describe, it, expect } from "vitest";
import * as fs from "node:fs";
import * as path from "node:path";
describe("store file extensions", () => {
it("test_store_files_use_svelte_ts_extension", () => {
const storesDir = path.resolve(__dirname);
const files = fs.readdirSync(storesDir);
// Find .ts files that are NOT .svelte.ts and NOT test files
const plainTsFiles = files.filter(
(f) =>
f.endsWith(".ts") &&
!f.endsWith(".svelte.ts") &&
!f.endsWith(".test.ts")
);
for (const file of plainTsFiles) {
const content = fs.readFileSync(path.join(storesDir, file), "utf-8");
expect(content).not.toMatch(
/\$state\s*[<(]/,
`${file} uses $state() but does not have .svelte.ts extension`
);
expect(content).not.toMatch(
/\$derived\s*[<(]/,
`${file} uses $derived() but does not have .svelte.ts extension`
);
expect(content).not.toMatch(
/\$effect\s*[<(]/,
`${file} uses $effect() but does not have .svelte.ts extension`
);
}
});
});

View File

@@ -1,71 +0,0 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
// Mock WebSocket for the backend store dependency (loaded transitively)
class MockWebSocket {
onopen: ((ev: Event) => void) | null = null;
onclose: ((ev: CloseEvent) => void) | null = null;
onmessage: ((ev: MessageEvent) => void) | null = null;
onerror: ((ev: Event) => void) | null = null;
close = vi.fn();
}
vi.stubGlobal("WebSocket", MockWebSocket);
vi.stubGlobal(
"fetch",
vi.fn(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve({}),
})
)
);
import { transcriptionStore } from "./transcriptions.svelte.ts";
describe("transcriptions store", () => {
beforeEach(() => {
transcriptionStore.clearAll();
});
it("test_addTranscription", () => {
transcriptionStore.addTranscription({
text: "Hello world",
user_name: "TestUser",
timestamp: "12:00:00",
});
expect(transcriptionStore.items.length).toBe(1);
expect(transcriptionStore.items[0].text).toBe("Hello world");
expect(transcriptionStore.items[0].userName).toBe("TestUser");
expect(transcriptionStore.items[0].timestamp).toBe("12:00:00");
expect(transcriptionStore.items[0].isPreview).toBe(false);
});
it("test_clearAll", () => {
transcriptionStore.addTranscription({ text: "One" });
transcriptionStore.addTranscription({ text: "Two" });
expect(transcriptionStore.items.length).toBe(2);
transcriptionStore.clearAll();
expect(transcriptionStore.items.length).toBe(0);
});
it("test_getPlainText", () => {
transcriptionStore.addTranscription({
text: "Hello",
user_name: "Alice",
timestamp: "10:00",
});
transcriptionStore.addTranscription({
text: "World",
user_name: "Bob",
timestamp: "10:01",
});
const text = transcriptionStore.getPlainText();
expect(text).toContain("[10:00] Alice: Hello");
expect(text).toContain("[10:01] Bob: World");
// Lines separated by newline
expect(text.split("\n").length).toBe(2);
});
});

View File

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

View File

@@ -10,7 +10,6 @@ export default defineConfig({
alias: {
$lib: path.resolve("./src/lib"),
},
extensions: [".svelte.ts", ".ts", ".svelte", ".js", ".mjs", ".mts"],
},
server: {
port: 1420,
@@ -19,8 +18,4 @@ export default defineConfig({
ignored: ["**/src-tauri/**", "**/client/**", "**/server/**", "**/backend/**", "**/gui/**"],
},
},
test: {
environment: "jsdom",
include: ["src/**/*.test.ts"],
},
});