diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index cc413af4f..fafdd1349 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -19,7 +19,7 @@ on: asset-prefix: required: false type: string - default: "parler" + default: "phraser" asset-name-pattern: required: false type: string @@ -80,7 +80,6 @@ jobs: - name: Setup Bun (standard) if: ${{ !(contains(inputs.platform, 'windows') && contains(inputs.target, 'aarch64')) }} uses: oven-sh/setup-bun@v2 - # Bun does not fully support Windows ARM64 yet, so we pin the baseline build. # See https://github.com/oven-sh/bun/issues/9824 for details. - name: Setup Bun (Windows ARM64 baseline) @@ -263,22 +262,30 @@ jobs: fi echo "platform=${patched_platform}" >> $GITHUB_OUTPUT + - name: Set Tauri signing env vars + shell: bash + run: | + echo "TAURI_SIGNING_PRIVATE_KEY=${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}" >> $GITHUB_ENV + echo "TAURI_SIGNING_PRIVATE_KEY_PASSWORD=${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}" >> $GITHUB_ENV + + - name: Set macOS signing env vars + if: contains(inputs.platform, 'macos') && inputs.sign-binaries + shell: bash + run: | + echo "APPLE_CERTIFICATE=${{ secrets.APPLE_CERTIFICATE }}" >> $GITHUB_ENV + echo "APPLE_CERTIFICATE_PASSWORD=${{ secrets.APPLE_CERTIFICATE_PASSWORD }}" >> $GITHUB_ENV + echo "APPLE_SIGNING_IDENTITY=${{ env.CERT_ID }}" >> $GITHUB_ENV + echo "APPLE_ID=${{ secrets.APPLE_ID }}" >> $GITHUB_ENV + echo "APPLE_ID_PASSWORD=${{ secrets.APPLE_ID_PASSWORD }}" >> $GITHUB_ENV + echo "APPLE_PASSWORD=${{ secrets.APPLE_PASSWORD }}" >> $GITHUB_ENV + echo "APPLE_TEAM_ID=${{ secrets.APPLE_TEAM_ID }}" >> $GITHUB_ENV - name: Build with Tauri uses: tauri-apps/tauri-action@v0 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - APPLE_ID: ${{ inputs.sign-binaries && secrets.APPLE_ID || '' }} - APPLE_ID_PASSWORD: ${{ inputs.sign-binaries && secrets.APPLE_ID_PASSWORD || '' }} - APPLE_PASSWORD: ${{ inputs.sign-binaries && secrets.APPLE_PASSWORD || '' }} - APPLE_TEAM_ID: ${{ inputs.sign-binaries && secrets.APPLE_TEAM_ID || '' }} - APPLE_CERTIFICATE: ${{ inputs.sign-binaries && secrets.APPLE_CERTIFICATE || '' }} - APPLE_CERTIFICATE_PASSWORD: ${{ inputs.sign-binaries && secrets.APPLE_CERTIFICATE_PASSWORD || '' }} - APPLE_SIGNING_IDENTITY: ${{ inputs.sign-binaries && env.CERT_ID || '' }} AZURE_CLIENT_ID: ${{ inputs.sign-binaries && secrets.AZURE_CLIENT_ID || '' }} AZURE_CLIENT_SECRET: ${{ inputs.sign-binaries && secrets.AZURE_CLIENT_SECRET || '' }} AZURE_TENANT_ID: ${{ inputs.sign-binaries && secrets.AZURE_TENANT_ID || '' }} - TAURI_SIGNING_PRIVATE_KEY: ${{ inputs.sign-binaries && secrets.TAURI_SIGNING_PRIVATE_KEY || '' }} - TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ inputs.sign-binaries && secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD || '' }} WHISPER_NO_AVX: ${{ contains(inputs.platform, 'ubuntu') && !contains(inputs.platform, 'arm') && 'ON' || '' }} WHISPER_NO_AVX2: ${{ contains(inputs.platform, 'ubuntu') && !contains(inputs.platform, 'arm') && 'ON' || '' }} with: @@ -307,49 +314,29 @@ jobs: - name: Remove libwayland-client.so from AppImage if: contains(inputs.platform, 'ubuntu') run: | - # Find the AppImage file APPIMAGE_PATH=$(find src-tauri/target/${{ steps.build-profile.outputs.profile }}/bundle/appimage -name "*.AppImage" | head -1) - if [ -n "$APPIMAGE_PATH" ]; then echo "Processing AppImage: $APPIMAGE_PATH" - - # Make AppImage executable chmod +x "$APPIMAGE_PATH" - - # Extract AppImage cd "$(dirname "$APPIMAGE_PATH")" APPIMAGE_NAME=$(basename "$APPIMAGE_PATH") - - # Extract using the AppImage itself "./$APPIMAGE_NAME" --appimage-extract - - # Remove libwayland-client.so files echo "Removing libwayland-client.so files..." find squashfs-root -name "libwayland-client.so*" -type f -delete - - # List what was removed for verification echo "Files remaining in lib directories:" find squashfs-root -name "lib*" -type d | head -5 | while read dir; do echo "Contents of $dir:" ls "$dir" | grep -E "(wayland|fuse)" || echo " No wayland/fuse libraries found" done - - # Detect architecture and get appropriate appimagetool if [[ "$(uname -m)" == "aarch64" ]]; then APPIMAGETOOL_ARCH="aarch64" else APPIMAGETOOL_ARCH="x86_64" fi - wget -q "https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-${APPIMAGETOOL_ARCH}.AppImage" chmod +x "appimagetool-${APPIMAGETOOL_ARCH}.AppImage" - - # Repackage AppImage with no-appstream to avoid warnings ARCH="${APPIMAGETOOL_ARCH}" "./appimagetool-${APPIMAGETOOL_ARCH}.AppImage" --no-appstream squashfs-root "$APPIMAGE_NAME" - - # Clean up rm -rf squashfs-root "appimagetool-${APPIMAGETOOL_ARCH}.AppImage" - echo "libwayland-client.so removed from AppImage successfully" else echo "No AppImage found to process" @@ -371,7 +358,6 @@ jobs: uses: actions/upload-artifact@v4 with: name: ${{ inputs.asset-prefix }}-${{ inputs.target }} - # Default Windows builds place bundles under release/, but cross-compiles (ARM64) nest under target//release. path: | src-tauri/target/${{ inputs.target != '' && inputs.target != 'x86_64-pc-windows-msvc' && format('{0}/{1}', inputs.target, steps.build-profile.outputs.profile) || steps.build-profile.outputs.profile }}/bundle/msi/*.msi src-tauri/target/${{ inputs.target != '' && inputs.target != 'x86_64-pc-windows-msvc' && format('{0}/{1}', inputs.target, steps.build-profile.outputs.profile) || steps.build-profile.outputs.profile }}/bundle/nsis/*.exe diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7e76dc0fb..fcc0dfaf7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -52,23 +52,14 @@ jobs: - platform: "macos-latest" # for Intel based macs. args: "--target x86_64-apple-darwin" target: "x86_64-apple-darwin" - - platform: "ubuntu-22.04" # Build .deb on 22.04 - args: "--bundles deb" - target: "x86_64-unknown-linux-gnu" - - platform: "ubuntu-24.04" # Build AppImage and RPM on 24.04 - args: "--bundles appimage,rpm" - target: "x86_64-unknown-linux-gnu" - - platform: "ubuntu-24.04-arm" # Build for ARM64 Linux - args: "--bundles appimage,deb,rpm" - target: "aarch64-unknown-linux-gnu" uses: ./.github/workflows/build.yml with: platform: ${{ matrix.platform }} target: ${{ matrix.target }} build-args: ${{ matrix.args }} - sign-binaries: true - asset-prefix: "parler" + sign-binaries: false + asset-prefix: "phraser" upload-artifacts: false release-id: ${{ needs.create-release.outputs.release-id }} secrets: inherit diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml new file mode 100644 index 000000000..4d0a658c8 --- /dev/null +++ b/.github/workflows/security.yml @@ -0,0 +1,98 @@ +name: security + +on: + pull_request: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + security-events: write + +jobs: + layer1-dependency-audit: + name: L1 Dependency Risk + runs-on: ubuntu-24.04 + continue-on-error: true + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v1 + with: + bun-version: latest + - uses: dtolnay/rust-toolchain@stable + - name: Install audit tools + run: cargo install cargo-audit --locked + - name: Bun audit + run: bun audit --production + - name: Cargo audit + working-directory: src-tauri + run: cargo audit + + layer2-secrets: + name: L2 Secrets + runs-on: ubuntu-24.04 + continue-on-error: true + steps: + - uses: actions/checkout@v4 + - name: Gitleaks + uses: gitleaks/gitleaks-action@v2 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + layer3-sast: + name: L3 SAST + runs-on: ubuntu-24.04 + continue-on-error: true + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v1 + with: + bun-version: latest + - uses: dtolnay/rust-toolchain@stable + - name: Install frontend deps + run: bun install --frozen-lockfile + - name: Frontend lint + run: bun run lint + - name: Rust clippy + working-directory: src-tauri + run: cargo clippy --all-targets --all-features -- -D warnings + + layer4-human-review: + name: L4 Human/AI Review Reminder + runs-on: ubuntu-24.04 + steps: + - name: Reminder + run: | + echo "Review security-sensitive changes manually (settings, commands, tauri config, updater/network paths)." + + layer5-runtime-checks: + name: L5 Runtime Checks Reminder + runs-on: ubuntu-24.04 + steps: + - name: Reminder + run: | + echo "Run Playwright/runtime checks when user-facing command behavior changes." + + layer6-supply-chain: + name: L6 Supply Chain Integrity + runs-on: ubuntu-24.04 + continue-on-error: true + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v1 + with: + bun-version: latest + - name: Lockfile-enforced install + run: bun install --frozen-lockfile + - name: Ensure lockfiles unchanged + run: | + git diff --exit-code -- bun.lock src-tauri/Cargo.lock + + layer7-observability: + name: L7 Observability Reminder + runs-on: ubuntu-24.04 + steps: + - name: Reminder + run: | + echo "Attach security evidence to PRs (audit outputs, key findings, remediation notes)." diff --git a/.pre-commit-config.yaml.template b/.pre-commit-config.yaml.template new file mode 100644 index 000000000..68886ee6a --- /dev/null +++ b/.pre-commit-config.yaml.template @@ -0,0 +1,34 @@ +repos: + - repo: https://github.com/gitleaks/gitleaks + rev: v8.25.1 + hooks: + - id: gitleaks + + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: check-merge-conflict + - id: check-yaml + - id: check-json + - id: end-of-file-fixer + - id: trailing-whitespace + + - repo: local + hooks: + - id: bun-lint + name: bun lint + entry: bun run lint + language: system + pass_filenames: false + + - id: rust-fmt-check + name: cargo fmt check + entry: bash -lc 'cd src-tauri && cargo fmt -- --check' + language: system + pass_filenames: false + + - id: rust-clippy + name: cargo clippy + entry: bash -lc 'cd src-tauri && cargo clippy --all-targets --all-features -- -D warnings' + language: system + pass_filenames: false diff --git a/.project-hooks/pre-commit b/.project-hooks/pre-commit new file mode 100755 index 000000000..b679ec78b --- /dev/null +++ b/.project-hooks/pre-commit @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(git rev-parse --show-toplevel)" +cd "$repo_root" + +echo "[pre-commit] Running format checks..." +bun run format:check + +echo "[pre-commit] Running frontend lint..." +bun run lint + +echo "[pre-commit] Running Rust checks..." +cargo check --manifest-path src-tauri/Cargo.toml + +echo "[pre-commit] Running Rust tests..." +cargo test --manifest-path src-tauri/Cargo.toml --quiet + +echo "[pre-commit] All checks passed." diff --git a/CLAUDE.md b/CLAUDE.md index 9c717e930..4cf8927bf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -34,7 +34,7 @@ curl -o src-tauri/resources/models/silero_vad_v4.onnx https://blob.handy.compute ## Architecture Overview -Handy is a cross-platform desktop speech-to-text app built with Tauri 2.x (Rust backend + React/TypeScript frontend). +Phraser is a cross-platform desktop speech-to-text app built with Tauri 2.x (Rust backend + React/TypeScript frontend). ### Backend Structure (src-tauri/src/) @@ -121,7 +121,7 @@ Use conventional commits: ## CLI Parameters -Handy supports command-line parameters on all platforms for integration with scripts, window managers, and autostart configurations. +Phraser supports command-line parameters on all platforms for integration with scripts, window managers, and autostart configurations. **Implementation files:** diff --git a/IMPLEMENTATION_SUMMARY.md b/IMPLEMENTATION_SUMMARY.md new file mode 100644 index 000000000..7f4655b31 --- /dev/null +++ b/IMPLEMENTATION_SUMMARY.md @@ -0,0 +1,74 @@ +# 7-Layer Security Pipeline Implementation Summary (Phraser Adaptation) + +## Scope + +Adapted from the RepoSec 7-layer model to this repository: + +- Repo: `Phraser` +- Stack: Tauri (Rust backend) + React/TypeScript + Bun +- Date: 2026-03-04 + +## What Was Added + +### 1. Security Framework Docs + +- `docs/7_LAYER_SECURITY_MODEL.md` +- `docs/PIPELINE.md` + +These documents map the seven layers to Phraser-specific tooling and risks, including settings/logging exposure, Tauri command boundaries, and lockfile integrity. + +### 2. CI Security Workflow + +- `.github/workflows/security.yml` + +Adds layer-oriented CI jobs: + +- L1: `bun audit`, `cargo audit` +- L2: `gitleaks` +- L3: `bun run lint`, `cargo clippy` +- L4/L5/L7: review/runtime/observability reminders +- L6: lockfile-enforced installs + lockfile drift check + +### 3. Local Security Make Targets + +- `Makefile` + +Provides reproducible local commands: + +- `make security` +- `make security-l1` … `make security-l7` + +### 4. Pre-commit Template + +- `.pre-commit-config.yaml.template` + +Template includes: + +- gitleaks +- standard file hygiene checks +- local Bun lint +- Rust fmt check +- Rust clippy + +## Design Notes + +- Keeps existing project workflows intact (no modifications to existing CI files). +- Uses tools aligned with current stack and prior repo usage. +- Avoids hardcoded local user paths. +- Keeps the 7-layer model practical for a desktop app (L5 via runtime/integration checks, not only web DAST). + +## Recommended Next Steps + +1. Run `make security` locally and resolve any findings. +2. Trigger `.github/workflows/security.yml` on a PR to validate CI execution. +3. Copy `.pre-commit-config.yaml.template` to `.pre-commit-config.yaml` and install hooks. +4. Optionally pin all workflow actions to immutable SHAs for stronger supply-chain guarantees. + +## Files Added + +- `docs/7_LAYER_SECURITY_MODEL.md` +- `docs/PIPELINE.md` +- `.github/workflows/security.yml` +- `Makefile` +- `.pre-commit-config.yaml.template` +- `IMPLEMENTATION_SUMMARY.md` diff --git a/Makefile b/Makefile new file mode 100644 index 000000000..17b5079f4 --- /dev/null +++ b/Makefile @@ -0,0 +1,39 @@ +.PHONY: help security security-l1 security-l2 security-l3 security-l4 security-l5 security-l6 security-l7 + +help: + @echo "Security targets:" + @echo " make security - Run L1 + L2 + L3 + L6" + @echo " make security-l1 - Dependency audits (bun + cargo)" + @echo " make security-l2 - Secrets scan (gitleaks)" + @echo " make security-l3 - Static analysis (lint + clippy)" + @echo " make security-l4 - Human/AI review reminder" + @echo " make security-l5 - Runtime/DAST reminder" + @echo " make security-l6 - Supply chain checks" + @echo " make security-l7 - Observability reminder" + +security: security-l1 security-l2 security-l3 security-l6 + +security-l1: + bun audit --production + cd src-tauri && cargo audit + +security-l2: + @command -v gitleaks >/dev/null || (echo "gitleaks not installed" && exit 1) + gitleaks detect --source . --no-git --redact + +security-l3: + bun run lint + cd src-tauri && cargo clippy --all-targets --all-features -- -D warnings + +security-l4: + @echo "Perform manual security review for sensitive files before merge." + +security-l5: + @echo "Run runtime checks (Playwright/manual IPC checks) for behavior-sensitive changes." + +security-l6: + bun install --frozen-lockfile + git diff --exit-code -- bun.lock src-tauri/Cargo.lock + +security-l7: + @echo "Capture and retain security scan evidence in PR description/release notes." diff --git a/README.md b/README.md index d673a1348..a66426922 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,16 @@ -# Parler +# Phraser -> **This is a personal fork of [cjpais/Handy](https://github.com/cjpais/Handy)** by Melvyn. -> It adds custom features on top of the original Handy app while keeping full compatibility with upstream. +> **This is a personal fork of [Melvynx/Parler](https://github.com/Melvynx/Parler)** by newblacc, which itself is a fork of [cjpais/Handy](https://github.com/cjpais/Handy). +> It adds custom features on top of the original while keeping full compatibility with upstream. ## Custom Additions - **Conditional model switching**: Automatically use a different (larger) model when audio recordings exceed a configurable duration threshold (default: 10 seconds). This lets you use a fast lightweight model for short recordings and a more accurate model for longer ones. +- **Security dependency hardening**: Updated Rust transitive dependencies in `Cargo.lock` to address current `cargo audit` vulnerability findings (`bytes`, `rkyv`, `time`). +- **Stronger history-path validation**: Hardened audio history file-name validation (including empty-name rejection) and expanded unit test coverage for history/settings command logic. +- **Project quality gate hook**: Added `.project-hooks/pre-commit` with format, lint, Rust check, and Rust test checks, plus documented usage in the README. +- **Branding and app identity refresh**: Updated repository and app identity to `newblacc` and regenerated the Tauri app icon set. +- **Claude Desktop workflow defaults**: Tuned speech output defaults and submit behavior for faster dictation-to-send workflows. --- @@ -13,24 +18,24 @@ **A free, open source, and extensible speech-to-text application that works completely offline.** -Parler is a cross-platform desktop application that provides simple, privacy-focused speech transcription. Press a shortcut, speak, and have your words appear in any text field. This happens on your own computer without sending any information to the cloud. +Phraser is a cross-platform desktop application that provides simple, privacy-focused speech transcription. Press a shortcut, speak, and have your words appear in any text field. This happens on your own computer without sending any information to the cloud. -## Why Parler? +## Why Phraser? -Parler was created to fill the gap for a truly open source, extensible speech-to-text tool. As stated on [handy.computer](https://handy.computer): +Phraser was created to fill the gap for a truly open source, extensible speech-to-text tool: - **Free**: Accessibility tooling belongs in everyone's hands, not behind a paywall -- **Open Source**: Together we can build further. Extend Parler for yourself and contribute to something bigger +- **Open Source**: Together we can build further. Extend Phraser for yourself and contribute to something bigger - **Private**: Your voice stays on your computer. Get transcriptions without sending audio to the cloud - **Simple**: One tool, one job. Transcribe what you say and put it into a text box -Parler isn't trying to be the best speech-to-text app—it's trying to be the most forkable one. +Phraser isn't trying to be the best speech-to-text app—it's trying to be the most forkable one. ## How It Works 1. **Press** a configurable keyboard shortcut to start/stop recording (or use push-to-talk mode) 2. **Speak** your words while the shortcut is active -3. **Release** and Parler processes your speech using Whisper +3. **Release** and Phraser processes your speech using Whisper 4. **Get** your transcribed text pasted directly into whatever app you're using The process is entirely local: @@ -45,10 +50,9 @@ The process is entirely local: ### Installation -1. Download the latest release from the [releases page](https://github.com/Melvynx/Parler/releases) or the [website](https://handy.computer) - - **macOS**: Also available via [Homebrew cask](https://formulae.brew.sh/cask/handy): `brew install --cask handy` +1. Download the latest release from the [releases page](https://github.com/newblacc/Phraser/releases) 2. Install the application -3. Launch Parler and grant necessary system permissions (microphone, accessibility) +3. Launch Phraser and grant necessary system permissions (microphone, accessibility) 4. Configure your preferred keyboard shortcuts in Settings 5. Start transcribing! @@ -56,9 +60,52 @@ The process is entirely local: For detailed build instructions including platform-specific requirements, see [BUILD.md](BUILD.md). +Create a local macOS app bundle from source: + +```bash +bun run app:create +``` + +The generated app is placed at: + +```bash +src-tauri/target/release/bundle/macos/Phraser.app +``` + +### Quality & Security Checks + +Before committing, run the same checks we used in the ship pipeline: + +```bash +# Frontend/JS dependency audit +bun audit + +# Rust dependency advisories +(cd src-tauri && cargo audit) + +# Rust tests +(cd src-tauri && cargo test) + +# Frontend build validation +bun run build +``` + +This repository also includes a local project hook: + +```bash +.project-hooks/pre-commit +``` + +It runs formatting checks, frontend lint, Rust compile checks, and Rust tests. +If you want to use it as your git hook for this repo: + +```bash +git config core.hooksPath .project-hooks +``` + ## Architecture -Parler is built as a Tauri application combining: +Phraser is built as a Tauri application combining: - **Frontend**: React + TypeScript with Tailwind CSS for the settings UI - **Backend**: Rust for system integration, audio processing, and ML inference @@ -72,47 +119,47 @@ Parler is built as a Tauri application combining: ### Debug Mode -Parler includes an advanced debug mode for development and troubleshooting. Access it by pressing: +Phraser includes an advanced debug mode for development and troubleshooting. Access it by pressing: - **macOS**: `Cmd+Shift+D` - **Windows/Linux**: `Ctrl+Shift+D` ### CLI Parameters -Parler supports command-line flags for controlling a running instance and customizing startup behavior. These work on all platforms (macOS, Windows, Linux). +Phraser supports command-line flags for controlling a running instance and customizing startup behavior. These work on all platforms (macOS, Windows, Linux). **Remote control flags** (sent to an already-running instance via the single-instance plugin): ```bash -handy --toggle-transcription # Toggle recording on/off -handy --toggle-post-process # Toggle recording with post-processing on/off -handy --cancel # Cancel the current operation +phraser --toggle-transcription # Toggle recording on/off +phraser --toggle-post-process # Toggle recording with post-processing on/off +phraser --cancel # Cancel the current operation ``` **Startup flags:** ```bash -handy --start-hidden # Start without showing the main window -handy --no-tray # Start without the system tray icon -handy --debug # Enable debug mode with verbose logging -handy --help # Show all available flags +phraser --start-hidden # Start without showing the main window +phraser --no-tray # Start without the system tray icon +phraser --debug # Enable debug mode with verbose logging +phraser --help # Show all available flags ``` Flags can be combined for autostart scenarios: ```bash -handy --start-hidden --no-tray +phraser --start-hidden --no-tray ``` -> **macOS tip:** When Parler is installed as an app bundle, invoke the binary directly: +> **macOS tip:** When Phraser is installed as an app bundle, invoke the binary directly: > > ```bash -> /Applications/Parler.app/Contents/MacOS/Parler --toggle-transcription +> /Applications/Phraser.app/Contents/MacOS/Phraser --toggle-transcription > ``` ## Known Issues & Current Limitations -This project is actively being developed and has some [known issues](https://github.com/Melvynx/Parler/issues). We believe in transparency about the current state: +This project is actively being developed and has some [known issues](https://github.com/newblacc/Phraser/issues). We believe in transparency about the current state: ### Major Issues (Help Wanted) @@ -143,12 +190,12 @@ For reliable text input on Linux, install the appropriate tool for your display - **Wayland**: Install `wtype` (preferred) or `dotool` for text input to work correctly - **dotool setup**: Requires adding your user to the `input` group: `sudo usermod -aG input $USER` (then log out and back in) -Without these tools, Parler falls back to enigo which may have limited compatibility, especially on Wayland. +Without these tools, Phraser falls back to enigo which may have limited compatibility, especially on Wayland. **Other Notes:** - **Runtime library dependency (`libgtk-layer-shell.so.0`)**: - - Parler links `gtk-layer-shell` on Linux. If startup fails with `error while loading shared libraries: libgtk-layer-shell.so.0`, install the runtime package for your distro: + - Phraser links `gtk-layer-shell` on Linux. If startup fails with `error while loading shared libraries: libgtk-layer-shell.so.0`, install the runtime package for your distro: | Distro | Package to install | Example command | | ------------- | --------------------- | -------------------------------------- | @@ -158,30 +205,30 @@ Without these tools, Parler falls back to enigo which may have limited compatibi - For building from source on Ubuntu/Debian, you may also need `libgtk-layer-shell-dev`. -- The recording overlay is disabled by default on Linux (`Overlay Position: None`) because certain compositors treat it as the active window. When the overlay is visible it can steal focus, which prevents Parler from pasting back into the application that triggered transcription. If you enable the overlay anyway, be aware that clipboard-based pasting might fail or end up in the wrong window. +- The recording overlay is disabled by default on Linux (`Overlay Position: None`) because certain compositors treat it as the active window. When the overlay is visible it can steal focus, which prevents Phraser from pasting back into the application that triggered transcription. If you enable the overlay anyway, be aware that clipboard-based pasting might fail or end up in the wrong window. - If you are having trouble with the app, running with the environment variable `WEBKIT_DISABLE_DMABUF_RENDERER=1` may help - **Global keyboard shortcuts (Wayland):** On Wayland, system-level shortcuts must be configured through your desktop environment or window manager. Use the [CLI flags](#cli-parameters) as the command for your custom shortcut. **GNOME:** 1. Open **Settings > Keyboard > Keyboard Shortcuts > Custom Shortcuts** 2. Click the **+** button to add a new shortcut - 3. Set the **Name** to `Toggle Parler Transcription` - 4. Set the **Command** to `handy --toggle-transcription` + 3. Set the **Name** to `Toggle Phraser Transcription` + 4. Set the **Command** to `phraser --toggle-transcription` 5. Click **Set Shortcut** and press your desired key combination (e.g., `Super+O`) **KDE Plasma:** 1. Open **System Settings > Shortcuts > Custom Shortcuts** 2. Click **Edit > New > Global Shortcut > Command/URL** - 3. Name it `Toggle Parler Transcription` + 3. Name it `Toggle Phraser Transcription` 4. In the **Trigger** tab, set your desired key combination - 5. In the **Action** tab, set the command to `handy --toggle-transcription` + 5. In the **Action** tab, set the command to `phraser --toggle-transcription` **Sway / i3:** Add to your config file (`~/.config/sway/config` or `~/.config/i3/config`): ```ini - bindsym $mod+o exec handy --toggle-transcription + bindsym $mod+o exec phraser --toggle-transcription ``` **Hyprland:** @@ -189,21 +236,21 @@ Without these tools, Parler falls back to enigo which may have limited compatibi Add to your config file (`~/.config/hypr/hyprland.conf`): ```ini - bind = $mainMod, O, exec, handy --toggle-transcription + bind = $mainMod, O, exec, phraser --toggle-transcription ``` -- You can also manage global shortcuts outside of Parler via Unix signals, which lets Wayland window managers or other hotkey daemons keep ownership of keybindings: +- You can also manage global shortcuts outside of Phraser via Unix signals, which lets Wayland window managers or other hotkey daemons keep ownership of keybindings: - | Signal | Action | Example | - | --------- | ----------------------------------------- | ---------------------- | - | `SIGUSR2` | Toggle transcription | `pkill -USR2 -n handy` | - | `SIGUSR1` | Toggle transcription with post-processing | `pkill -USR1 -n handy` | + | Signal | Action | Example | + | --------- | ----------------------------------------- | ------------------------ | + | `SIGUSR2` | Toggle transcription | `pkill -USR2 -n phraser` | + | `SIGUSR1` | Toggle transcription with post-processing | `pkill -USR1 -n phraser` | Example Sway config: ```ini - bindsym $mod+o exec pkill -USR2 -n handy - bindsym $mod+p exec pkill -USR1 -n handy + bindsym $mod+o exec pkill -USR2 -n phraser + bindsym $mod+p exec pkill -USR1 -n phraser ``` `pkill` here simply delivers the signal—it does not terminate the process. @@ -216,7 +263,7 @@ Without these tools, Parler falls back to enigo which may have limited compatibi ### System Requirements/Recommendations -The following are recommendations for running Parler on your own machine. If you don't meet the system requirements, the performance of the application may be degraded. We are working on improving the performance across all kinds of computers and hardware. +The following are recommendations for running Phraser on your own machine. If you don't meet the system requirements, the performance of the application may be degraded. We are working on improving the performance across all kinds of computers and hardware. **For Whisper Models:** @@ -249,7 +296,7 @@ We're actively working on several features and improvements. Contributions and f **Opt-in Analytics:** -- Collect anonymous usage data to help improve Parler +- Collect anonymous usage data to help improve Phraser - Privacy-first approach with clear opt-in **Settings Refactoring:** @@ -266,11 +313,11 @@ We're actively working on several features and improvements. Contributions and f ### Manual Model Installation (For Proxy Users or Network Restrictions) -If you're behind a proxy, firewall, or in a restricted network environment where Parler cannot download models automatically, you can manually download and install them. The URLs are publicly accessible from any browser. +If you're behind a proxy, firewall, or in a restricted network environment where Phraser cannot download models automatically, you can manually download and install them. The URLs are publicly accessible from any browser. #### Step 1: Find Your App Data Directory -1. Open Parler settings +1. Open Phraser settings 2. Navigate to the **About** section 3. Copy the "App Data Directory" path shown there, or use the shortcuts: - **macOS**: `Cmd+Shift+D` to open debug menu @@ -278,9 +325,9 @@ If you're behind a proxy, firewall, or in a restricted network environment where The typical paths are: -- **macOS**: `~/Library/Application Support/com.pais.handy/` -- **Windows**: `C:\Users\{username}\AppData\Roaming\com.pais.handy\` -- **Linux**: `~/.config/com.pais.handy/` +- **macOS**: `~/Library/Application Support/com.newblacc.phraser/` +- **Windows**: `C:\Users\{username}\AppData\Roaming\com.newblacc.phraser\` +- **Linux**: `~/.config/com.newblacc.phraser/` #### Step 2: Create Models Directory @@ -288,10 +335,10 @@ Inside your app data directory, create a `models` folder if it doesn't already e ```bash # macOS/Linux -mkdir -p ~/Library/Application\ Support/com.pais.handy/models +mkdir -p ~/Library/Application\ Support/com.newblacc.phraser/models # Windows (PowerShell) -New-Item -ItemType Directory -Force -Path "$env:APPDATA\com.pais.handy\models" +New-Item -ItemType Directory -Force -Path "$env:APPDATA\com.newblacc.phraser\models" ``` #### Step 3: Download Model Files @@ -348,24 +395,24 @@ Final structure should look like: - For Parakeet models, the extracted directory name **must** match exactly as shown above - Do not rename the `.bin` files for Whisper models—use the exact filenames from the download URLs -- After placing the files, restart Parler to detect the new models +- After placing the files, restart Phraser to detect the new models #### Step 5: Verify Installation -1. Restart Parler +1. Restart Phraser 2. Open Settings → Models 3. Your manually installed models should now appear as "Downloaded" 4. Select the model you want to use and test transcription ### Custom Whisper Models -Parler can auto-discover custom Whisper GGML models placed in the `models` directory. This is useful for users who want to use fine-tuned or community models not included in the default model list. +Phraser can auto-discover custom Whisper GGML models placed in the `models` directory. This is useful for users who want to use fine-tuned or community models not included in the default model list. **How to use:** 1. Obtain a Whisper model in GGML `.bin` format (e.g., from [Hugging Face](https://huggingface.co/models?search=whisper%20ggml)) 2. Place the `.bin` file in your `models` directory (see paths above) -3. Restart Parler to discover the new model +3. Restart Phraser to discover the new model 4. The model will appear in the "Custom Models" section of the Models settings page **Important:** @@ -376,18 +423,18 @@ Parler can auto-discover custom Whisper GGML models placed in the `models` direc ### How to Contribute -1. **Check existing issues** at [github.com/Melvynx/Parler/issues](https://github.com/Melvynx/Parler/issues) +1. **Check existing issues** at [github.com/newblacc/Phraser/issues](https://github.com/newblacc/Phraser/issues) 2. **Fork the repository** and create a feature branch 3. **Test thoroughly** on your target platform 4. **Submit a pull request** with clear description of changes -5. **Join the discussion** - reach out at [contact@handy.computer](mailto:contact@handy.computer) +5. **Join the discussion** on [GitHub Issues](https://github.com/newblacc/Phraser/issues) The goal is to create both a useful tool and a foundation for others to build upon—a well-patterned, simple codebase that serves the community. ## Sponsors
- We're grateful for the support of our sponsors who help make Parler possible: + We're grateful for the support of our sponsors who help make Phraser possible:

Wordcab @@ -397,15 +444,15 @@ The goal is to create both a useful tool and a foundation for others to build up Epicenter        - + Bolt AI
## Related Projects -- **[Parler CLI](https://github.com/cjpais/handy-cli)** - The original Python command-line version -- **[handy.computer](https://handy.computer)** - Project website with demos and documentation +- **[Parler](https://github.com/Melvynx/Parler)** - The direct upstream fork Phraser is based on +- **[Handy](https://github.com/cjpais/Handy)** - The original project by cjpais ## License @@ -417,8 +464,8 @@ MIT License - see [LICENSE](LICENSE) file for details. - **whisper.cpp and ggml** for amazing cross-platform whisper inference/acceleration - **Silero** for great lightweight VAD - **Tauri** team for the excellent Rust-based app framework -- **Community contributors** helping make Parler better +- **Community contributors** helping make Phraser better --- -_"Your search for the right speech-to-text tool can end here—not because Parler is perfect, but because you can make it perfect for you."_ +_"Your search for the right speech-to-text tool can end here—not because Phraser is perfect, but because you can make it perfect for you."_ diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 000000000..12403d396 --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,150 @@ +# Releasing Phraser + +How to create and publish a new release of Phraser. + +## Prerequisites + +### Required: Tauri Updater Signing Key + +The auto-updater needs a signing key so the app can verify updates are authentic. + +Generate one (do this once): + +```bash +bun tauri signer generate -w ~/.tauri/phraser.key +``` + +This creates two files: + +- `~/.tauri/phraser.key` — the private key (add to GitHub Secrets) +- `~/.tauri/phraser.key.pub` — the public key (embedded in the app) + +Then add these secrets to your GitHub repo (**Settings → Secrets and variables → Actions → New repository secret**): + +| Secret | Value | +| ------------------------------------ | ------------------------------------ | +| `TAURI_SIGNING_PRIVATE_KEY` | Contents of `~/.tauri/phraser.key` | +| `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` | Password you chose during generation | + +> **Important:** Add secrets in the **repository** settings (not your GitHub account settings). Navigate to your repo → Settings → Secrets and variables → Actions. + +### Apple Code Signing (Currently Disabled) + +Apple code signing is **disabled** in the release workflow (`sign-binaries: false` in `release.yml`). Without an Apple Developer certificate ($99/year), macOS users will see a Gatekeeper warning on first launch. They can bypass it by right-clicking the app and selecting "Open." + +The build workflow in `.github/workflows/build.yml` conditionally skips all Apple signing steps when `sign-binaries` is `false`, so no Apple secrets are needed. + +If you want to enable code signing later: + +1. Enroll at [developer.apple.com](https://developer.apple.com/account) ($99/year) +2. Create a **Developer ID Application** certificate +3. Export it as `.p12` from Keychain Access +4. Base64 encode it: `base64 -i certificate.p12 | pbcopy` +5. Add these secrets to GitHub: + +| Secret | Value | +| ---------------------------- | ------------------------------------------------------------------ | +| `APPLE_CERTIFICATE` | Base64-encoded `.p12` file | +| `APPLE_CERTIFICATE_PASSWORD` | Password set during `.p12` export | +| `KEYCHAIN_PASSWORD` | Any random string (used internally by CI) | +| `APPLE_ID` | Your Apple Developer account email | +| `APPLE_ID_PASSWORD` | App-specific password (generate at appleid.apple.com) | +| `APPLE_TEAM_ID` | 10-character team ID from developer.apple.com → Membership details | + +6. Change `sign-binaries: false` to `sign-binaries: true` in `.github/workflows/release.yml` + +## Creating a Release + +### 1. Bump the Version + +Update the version in `src-tauri/tauri.conf.json`: + +```json +"version": "0.8.0" +``` + +Commit and push: + +```bash +git add src-tauri/tauri.conf.json +git commit -m "chore: bump version to 0.8.0" +git push origin main +``` + +> **Tip:** Run `bun run format` before committing to avoid pre-commit hook failures from Prettier. + +### 2. Trigger the Release Workflow + +1. Go to your repo on GitHub +2. Navigate to **Actions → Release** +3. Click **Run workflow** (select the `main` branch) + +The workflow will: + +- Read the version from `tauri.conf.json` +- Create a **draft** GitHub Release tagged `v0.8.0` +- Build macOS binaries (Apple Silicon + Intel) +- Build Linux binaries (x86_64 + ARM64: .deb, .rpm, .AppImage) +- Upload all artifacts to the draft release + +### 3. Review and Publish + +1. Go to **Releases** on GitHub +2. Open the draft release +3. Review the auto-generated release notes +4. Edit if needed, then click **Publish release** + +### If a Release Fails + +1. Go to **Releases** on GitHub +2. Delete the failed **draft** release +3. Fix the issue, push the fix +4. Re-trigger the workflow from **Actions → Release → Run workflow** + +## What Gets Built + +| Platform | Targets | Artifacts | +| ------------- | ----------------------------- | --------------------------- | +| macOS | `aarch64-apple-darwin` (ARM) | `.dmg` | +| macOS | `x86_64-apple-darwin` (Intel) | `.dmg` | +| Linux (22.04) | `x86_64-unknown-linux-gnu` | `.deb` | +| Linux (24.04) | `x86_64-unknown-linux-gnu` | `.AppImage`, `.rpm` | +| Linux (24.04) | `aarch64-unknown-linux-gnu` | `.AppImage`, `.deb`, `.rpm` | + +> **Note:** Windows is not currently in the release matrix. To add it, update `.github/workflows/release.yml`. + +## Current Signing Configuration + +| Setting | Value | Notes | +| --------------- | ------- | ---------------------------------------------------------------- | +| `sign-binaries` | `false` | Apple code signing disabled — no Apple Developer Program secrets | +| Tauri updater | ✅ Set | `TAURI_SIGNING_PRIVATE_KEY` + password added to GitHub Secrets | +| Gatekeeper | Manual | Users right-click → Open to bypass on first launch | + +## Landing Page + +The project website is served via GitHub Pages from the `docs/` folder on the `main` branch. Any changes pushed to `docs/` will automatically deploy to `celstnblacc.github.io/Phraser`. + +To set up GitHub Pages: + +1. Go to repo **Settings → Pages** +2. Under "Source", select **Deploy from a branch** +3. Set branch to `main` and folder to `/docs` +4. Save + +## Troubleshooting + +**Build fails on macOS with signing errors (`SecKeychainItemImport`):** +This happens when `sign-binaries: true` but Apple signing secrets are not configured. Fix: set `sign-binaries: false` in `.github/workflows/release.yml`. The build steps in `build.yml` are conditional on `inputs.sign-binaries`, so setting it to `false` skips all Apple signing. + +**Pre-commit hook fails:** +Run `bun run format` before committing to fix Prettier formatting issues. + +**Duplicate test files with spaces in filename:** +If you see errors like `invalid character ' ' in crate name`, check for duplicated files in `src-tauri/tests/` (e.g. `branding_consistency 2.rs`). Delete the duplicates. + +**Version mismatch:** +The version is read from `src-tauri/tauri.conf.json` — make sure it's updated there, not in `package.json` (which may have a different version). + +**GitHub Secrets not found:** +Add secrets in the **repository** settings (Settings → Secrets and variables → Actions), not in your GitHub account settings. diff --git a/bun.lock b/bun.lock index 09e4bf42d..5f85d7207 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { - "lockfileVersion": 0, + "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "handy-app", @@ -34,6 +35,9 @@ "devDependencies": { "@playwright/test": "^1.58.0", "@tauri-apps/cli": "^2.9.1", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@types/node": "^24.9.1", "@types/react": "^18.3.26", "@types/react-dom": "^18.3.7", @@ -41,15 +45,20 @@ "@typescript-eslint/eslint-plugin": "^8.49.0", "@typescript-eslint/parser": "^8.49.0", "@vitejs/plugin-react": "^4.7.0", + "@vitest/coverage-v8": "^4.0.18", "eslint": "^9.39.1", "eslint-plugin-i18next": "^6.1.3", + "happy-dom": "^20.8.3", "prettier": "^3.6.2", "typescript": "~5.6.3", "vite": "^6.4.1", + "vitest": "^4.0.18", }, }, }, "packages": { + "@adobe/css-tools": ["@adobe/css-tools@4.4.4", "", {}, "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg=="], + "@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], "@babel/compat-data": ["@babel/compat-data@7.29.0", "", {}, "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg=="], @@ -90,6 +99,8 @@ "@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], + "@bcoe/v8-coverage": ["@bcoe/v8-coverage@1.0.2", "", {}, "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA=="], + "@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="], "@emotion/babel-plugin": ["@emotion/babel-plugin@11.13.5", "", { "dependencies": { "@babel/helper-module-imports": "^7.16.7", "@babel/runtime": "^7.18.3", "@emotion/hash": "^0.9.2", "@emotion/memoize": "^0.9.0", "@emotion/serialize": "^1.3.3", "babel-plugin-macros": "^3.1.0", "convert-source-map": "^1.5.0", "escape-string-regexp": "^4.0.0", "find-root": "^1.1.0", "source-map": "^0.5.7", "stylis": "4.2.0" } }, "sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ=="], @@ -330,6 +341,8 @@ "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.59.0", "", { "os": "win32", "cpu": "x64" }, "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA=="], + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + "@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="], "@tailwindcss/node": ["@tailwindcss/node@4.2.1", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.19.0", "jiti": "^2.6.1", "lightningcss": "1.31.1", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.2.1" } }, "sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg=="], @@ -410,6 +423,16 @@ "@tauri-apps/plugin-updater": ["@tauri-apps/plugin-updater@2.9.0", "", { "dependencies": { "@tauri-apps/api": "^2.6.0" } }, "sha512-j++sgY8XpeDvzImTrzWA08OqqGqgkNyxczLD7FjNJJx/uXxMZFz5nDcfkyoI/rCjYuj2101Tci/r/HFmOmoxCg=="], + "@testing-library/dom": ["@testing-library/dom@10.4.1", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "picocolors": "1.1.1", "pretty-format": "^27.0.2" } }, "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg=="], + + "@testing-library/jest-dom": ["@testing-library/jest-dom@6.9.1", "", { "dependencies": { "@adobe/css-tools": "^4.4.0", "aria-query": "^5.0.0", "css.escape": "^1.5.1", "dom-accessibility-api": "^0.6.3", "picocolors": "^1.1.1", "redent": "^3.0.0" } }, "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA=="], + + "@testing-library/react": ["@testing-library/react@16.3.2", "", { "dependencies": { "@babel/runtime": "^7.12.5" }, "peerDependencies": { "@testing-library/dom": "^10.0.0", "@types/react": "^18.0.0 || ^19.0.0", "@types/react-dom": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g=="], + + "@testing-library/user-event": ["@testing-library/user-event@14.6.1", "", { "peerDependencies": { "@testing-library/dom": ">=7.21.4" } }, "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw=="], + + "@types/aria-query": ["@types/aria-query@5.0.4", "", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="], + "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], "@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="], @@ -418,6 +441,10 @@ "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="], + "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], + + "@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="], + "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], @@ -436,6 +463,10 @@ "@types/react-transition-group": ["@types/react-transition-group@4.4.12", "", { "peerDependencies": { "@types/react": "*" } }, "sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w=="], + "@types/whatwg-mimetype": ["@types/whatwg-mimetype@3.0.2", "", {}, "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA=="], + + "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], + "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.56.1", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.56.1", "@typescript-eslint/type-utils": "8.56.1", "@typescript-eslint/utils": "8.56.1", "@typescript-eslint/visitor-keys": "8.56.1", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.56.1", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-Jz9ZztpB37dNC+HU2HI28Bs9QXpzCz+y/twHOwhyrIRdbuVDxSytJNDl6z/aAKlaRIwC7y8wJdkBv7FxYGgi0A=="], "@typescript-eslint/parser": ["@typescript-eslint/parser@8.56.1", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.56.1", "@typescript-eslint/types": "8.56.1", "@typescript-eslint/typescript-estree": "8.56.1", "@typescript-eslint/visitor-keys": "8.56.1", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg=="], @@ -458,16 +489,40 @@ "@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="], + "@vitest/coverage-v8": ["@vitest/coverage-v8@4.0.18", "", { "dependencies": { "@bcoe/v8-coverage": "^1.0.2", "@vitest/utils": "4.0.18", "ast-v8-to-istanbul": "^0.3.10", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", "istanbul-reports": "^3.2.0", "magicast": "^0.5.1", "obug": "^2.1.1", "std-env": "^3.10.0", "tinyrainbow": "^3.0.3" }, "peerDependencies": { "@vitest/browser": "4.0.18", "vitest": "4.0.18" }, "optionalPeers": ["@vitest/browser"] }, "sha512-7i+N2i0+ME+2JFZhfuz7Tg/FqKtilHjGyGvoHYQ6iLV0zahbsJ9sljC9OcFcPDbhYKCet+sG8SsVqlyGvPflZg=="], + + "@vitest/expect": ["@vitest/expect@4.0.18", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.0.18", "@vitest/utils": "4.0.18", "chai": "^6.2.1", "tinyrainbow": "^3.0.3" } }, "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ=="], + + "@vitest/mocker": ["@vitest/mocker@4.0.18", "", { "dependencies": { "@vitest/spy": "4.0.18", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0-0" }, "optionalPeers": ["msw", "vite"] }, "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ=="], + + "@vitest/pretty-format": ["@vitest/pretty-format@4.0.18", "", { "dependencies": { "tinyrainbow": "^3.0.3" } }, "sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw=="], + + "@vitest/runner": ["@vitest/runner@4.0.18", "", { "dependencies": { "@vitest/utils": "4.0.18", "pathe": "^2.0.3" } }, "sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw=="], + + "@vitest/snapshot": ["@vitest/snapshot@4.0.18", "", { "dependencies": { "@vitest/pretty-format": "4.0.18", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA=="], + + "@vitest/spy": ["@vitest/spy@4.0.18", "", {}, "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw=="], + + "@vitest/utils": ["@vitest/utils@4.0.18", "", { "dependencies": { "@vitest/pretty-format": "4.0.18", "tinyrainbow": "^3.0.3" } }, "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA=="], + "acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], "ajv": ["ajv@6.14.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw=="], + "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + "aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="], + + "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], + + "ast-v8-to-istanbul": ["ast-v8-to-istanbul@0.3.12", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.31", "estree-walker": "^3.0.3", "js-tokens": "^10.0.0" } }, "sha512-BRRC8VRZY2R4Z4lFIL35MwNXmwVqBityvOIwETtsCSwvjl0IdgFsy9NhdaA6j74nUdtJJlIypeRhpDam19Wq3g=="], + "babel-plugin-macros": ["babel-plugin-macros@3.1.0", "", { "dependencies": { "@babel/runtime": "^7.12.5", "cosmiconfig": "^7.0.0", "resolve": "^1.19.0" } }, "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg=="], "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], @@ -482,6 +537,8 @@ "caniuse-lite": ["caniuse-lite@1.0.30001774", "", {}, "sha512-DDdwPGz99nmIEv216hKSgLD+D4ikHQHjBC/seF98N9CPqRX4M5mSxT9eTV6oyisnJcuzxtZy4n17yKKQYmYQOA=="], + "chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="], + "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="], @@ -498,22 +555,32 @@ "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + "css.escape": ["css.escape@1.5.1", "", {}, "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg=="], + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], "deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="], + "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + "dom-accessibility-api": ["dom-accessibility-api@0.6.3", "", {}, "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w=="], + "dom-helpers": ["dom-helpers@5.2.1", "", { "dependencies": { "@babel/runtime": "^7.8.7", "csstype": "^3.0.2" } }, "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA=="], "electron-to-chromium": ["electron-to-chromium@1.5.302", "", {}, "sha512-sM6HAN2LyK82IyPBpznDRqlTQAtuSaO+ShzFiWTvoMJLHyZ+Y39r8VMfHzwbU8MVBzQ4Wdn85+wlZl2TLGIlwg=="], "enhanced-resolve": ["enhanced-resolve@5.19.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg=="], + "entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], + "error-ex": ["error-ex@1.3.4", "", { "dependencies": { "is-arrayish": "^0.2.1" } }, "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ=="], + "es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="], + "esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], @@ -536,8 +603,12 @@ "estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="], + "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], + "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], + "expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="], + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], "fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="], @@ -570,12 +641,16 @@ "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + "happy-dom": ["happy-dom@20.8.3", "", { "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", "@types/ws": "^8.18.1", "entities": "^7.0.1", "whatwg-mimetype": "^3.0.0", "ws": "^8.18.3" } }, "sha512-lMHQRRwIPyJ70HV0kkFT7jH/gXzSI7yDkQFe07E2flwmNDFoWUTRMKpW2sglsnpeA7b6S2TJPp98EbQxai8eaQ=="], + "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], "hoist-non-react-statics": ["hoist-non-react-statics@3.3.2", "", { "dependencies": { "react-is": "^16.7.0" } }, "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw=="], + "html-escaper": ["html-escaper@2.0.2", "", {}, "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg=="], + "html-parse-stringify": ["html-parse-stringify@3.0.1", "", { "dependencies": { "void-elements": "3.1.0" } }, "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg=="], "i18next": ["i18next@25.8.13", "", { "dependencies": { "@babel/runtime": "^7.28.4" }, "peerDependencies": { "typescript": "^5" }, "optionalPeers": ["typescript"] }, "sha512-E0vzjBY1yM+nsFrtgkjLhST2NBkirkvOVoQa0MSldhsuZ3jUge7ZNpuwG0Cfc74zwo5ZwRzg3uOgT+McBn32iA=="], @@ -588,6 +663,8 @@ "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], + "indent-string": ["indent-string@4.0.0", "", {}, "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg=="], + "is-arrayish": ["is-arrayish@0.2.1", "", {}, "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg=="], "is-core-module": ["is-core-module@2.16.1", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="], @@ -598,9 +675,15 @@ "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + "istanbul-lib-coverage": ["istanbul-lib-coverage@3.2.2", "", {}, "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg=="], + + "istanbul-lib-report": ["istanbul-lib-report@3.0.1", "", { "dependencies": { "istanbul-lib-coverage": "^3.0.0", "make-dir": "^4.0.0", "supports-color": "^7.1.0" } }, "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw=="], + + "istanbul-reports": ["istanbul-reports@3.2.0", "", { "dependencies": { "html-escaper": "^2.0.0", "istanbul-lib-report": "^3.0.0" } }, "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA=="], + "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], - "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + "js-tokens": ["js-tokens@10.0.0", "", {}, "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q=="], "js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], @@ -658,10 +741,18 @@ "lucide-react": ["lucide-react@0.542.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-w3hD8/SQB7+lzU2r4VdFyzzOzKnUjTZIF/MQJGSSvni7Llewni4vuViRppfRAa2guOsY5k4jZyxw/i9DQHv+dw=="], + "lz-string": ["lz-string@1.5.0", "", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="], + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + "magicast": ["magicast@0.5.2", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "source-map-js": "^1.2.1" } }, "sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ=="], + + "make-dir": ["make-dir@4.0.0", "", { "dependencies": { "semver": "^7.5.3" } }, "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw=="], + "memoize-one": ["memoize-one@6.0.0", "", {}, "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw=="], + "min-indent": ["min-indent@1.0.1", "", {}, "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg=="], + "minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], @@ -676,6 +767,8 @@ "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], + "obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="], + "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], "p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], @@ -694,6 +787,8 @@ "path-type": ["path-type@4.0.0", "", {}, "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw=="], + "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], @@ -708,6 +803,8 @@ "prettier": ["prettier@3.8.1", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg=="], + "pretty-format": ["pretty-format@27.5.1", "", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="], + "prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="], "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], @@ -726,6 +823,8 @@ "react-transition-group": ["react-transition-group@4.4.5", "", { "dependencies": { "@babel/runtime": "^7.5.5", "dom-helpers": "^5.0.1", "loose-envify": "^1.4.0", "prop-types": "^15.6.2" }, "peerDependencies": { "react": ">=16.6.0", "react-dom": ">=16.6.0" } }, "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g=="], + "redent": ["redent@3.0.0", "", { "dependencies": { "indent-string": "^4.0.0", "strip-indent": "^3.0.0" } }, "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg=="], + "requireindex": ["requireindex@1.1.0", "", {}, "sha512-LBnkqsDE7BZKvqylbmn7lTIVdpx4K/QCduRATpO5R+wtPmky/a8pN1bO2D6wXppn1497AJF9mNjqAXr6bdl9jg=="], "resolve": ["resolve@1.22.11", "", { "dependencies": { "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ=="], @@ -744,12 +843,20 @@ "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], + "sonner": ["sonner@2.0.7", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w=="], "source-map": ["source-map@0.5.7", "", {}, "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ=="], "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], + + "std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="], + + "strip-indent": ["strip-indent@3.0.0", "", { "dependencies": { "min-indent": "^1.0.0" } }, "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ=="], + "strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="], "styled-jsx": ["styled-jsx@5.1.6", "", { "dependencies": { "client-only": "0.0.1" }, "peerDependencies": { "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" } }, "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA=="], @@ -766,8 +873,14 @@ "tauri-plugin-macos-permissions-api": ["tauri-plugin-macos-permissions-api@2.3.0", "", { "dependencies": { "@tauri-apps/api": "^2.5.0" } }, "sha512-pZp0jmDySysBqrGueknd1a7Rr4XEO9aXpMv9TNrT2PDHP0MSH20njieOagsFYJ5MCVb8A+wcaK0cIkjUC2dOww=="], + "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], + + "tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="], + "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], + "tinyrainbow": ["tinyrainbow@3.0.3", "", {}, "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q=="], + "ts-api-utils": ["ts-api-utils@2.4.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA=="], "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], @@ -788,12 +901,20 @@ "vite": ["vite@6.4.1", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g=="], + "vitest": ["vitest@4.0.18", "", { "dependencies": { "@vitest/expect": "4.0.18", "@vitest/mocker": "4.0.18", "@vitest/pretty-format": "4.0.18", "@vitest/runner": "4.0.18", "@vitest/snapshot": "4.0.18", "@vitest/spy": "4.0.18", "@vitest/utils": "4.0.18", "es-module-lexer": "^1.7.0", "expect-type": "^1.2.2", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^3.10.0", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.0.3", "vite": "^6.0.0 || ^7.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.0.18", "@vitest/browser-preview": "4.0.18", "@vitest/browser-webdriverio": "4.0.18", "@vitest/ui": "4.0.18", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ=="], + "void-elements": ["void-elements@3.1.0", "", {}, "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w=="], + "whatwg-mimetype": ["whatwg-mimetype@3.0.0", "", {}, "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q=="], + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], + "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], + "ws": ["ws@8.19.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg=="], + "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], "yaml": ["yaml@1.10.2", "", {}, "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg=="], @@ -804,6 +925,8 @@ "zustand": ["zustand@5.0.11", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-fdZY+dk7zn/vbWNCYmzZULHRrss0jx5pPFiOuMZ/5HJN6Yv3u+1Wswy/4MpZEkEGhtNH+pwxZB8OKgUBPzYAGg=="], + "@babel/code-frame/js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + "@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], @@ -850,16 +973,26 @@ "@tauri-apps/plugin-updater/@tauri-apps/api": ["@tauri-apps/api@2.10.1", "", {}, "sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw=="], + "@testing-library/dom/aria-query": ["aria-query@5.3.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A=="], + + "@testing-library/dom/dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="], + "@typescript-eslint/typescript-estree/minimatch": ["minimatch@10.2.4", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg=="], "@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], "eslint/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], + "loose-envify/js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + "next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="], "playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], + "pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], + + "pretty-format/react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="], + "tauri-plugin-macos-permissions-api/@tauri-apps/api": ["@tauri-apps/api@2.10.1", "", {}, "sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/core/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.1.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ=="], diff --git a/docs/7_LAYER_SECURITY_MODEL.md b/docs/7_LAYER_SECURITY_MODEL.md new file mode 100644 index 000000000..4a96261e8 --- /dev/null +++ b/docs/7_LAYER_SECURITY_MODEL.md @@ -0,0 +1,91 @@ +# Phraser 7-Layer Security Model + +This document adapts the 7-layer security pipeline model to this repository (`Phraser`), a Tauri desktop app with a Rust backend and React/Bun frontend. + +## Layer 1: Dependency Risk + +Goal: catch vulnerable packages and crates before release. + +Tools: + +- `bun audit` for JS/TS dependencies +- `cargo audit` for Rust dependencies + +## Layer 2: Secrets Management + +Goal: prevent committed credentials, keys, and tokens. + +Tools: + +- `gitleaks` in CI +- existing local pre-commit secret scan hooks + +Repo focus: + +- `.env`-style files +- Tauri config and update key material +- accidental API keys in logs/settings snapshots + +## Layer 3: Static Analysis (SAST) + +Goal: detect code-level security mistakes. + +Tools: + +- Rust: `cargo clippy -- -D warnings` +- Frontend: `bun run lint` +- Optional: RepoSec scan when installed locally/CI + +Repo focus: + +- path handling in history/audio file operations +- command boundaries between frontend and Tauri commands +- logging of sensitive settings + +## Layer 4: Human/AI-Assisted Review + +Goal: add reasoning-based review for security-sensitive changes. + +Recommended policy: + +- require explicit security review for changes touching: + - `src-tauri/src/settings.rs` + - `src-tauri/src/commands/*` + - `src-tauri/tauri.conf.json` + - auth/update/network-related code + +## Layer 5: Runtime/DAST Checks + +Goal: validate behavior under runtime conditions. + +For desktop apps, this is mostly integration validation: + +- run Playwright smoke tests where applicable +- verify IPC command behavior for invalid input +- verify updater and asset protocol restrictions + +## Layer 6: Supply Chain Integrity + +Goal: ensure deterministic and safe builds. + +Controls: + +- prefer lockfile-enforced installs in CI (`bun install --frozen-lockfile`) +- fail if lockfiles are modified unexpectedly by CI checks +- avoid unpinned mutable install behaviors in scripts/workflows + +## Layer 7: Observability & Incident Readiness + +Goal: detect and respond quickly when security issues occur. + +Controls: + +- security workflow artifacts retained in CI +- release note template includes security impact section +- incident checklist for key exposure or unsafe logging regressions + +## Recommended Maturity Path + +1. Baseline: run Layers 1, 2, 3 on each PR. +2. Harden: add Layer 6 lockfile enforcement and branch protections. +3. Operationalize: formalize Layers 4, 5, 7 with documented owner/on-call flow. diff --git a/docs/PIPELINE.md b/docs/PIPELINE.md new file mode 100644 index 000000000..f78294e6b --- /dev/null +++ b/docs/PIPELINE.md @@ -0,0 +1,48 @@ +# Security Pipeline Quick Reference (Phraser) + +## Purpose + +Run a repeatable security pipeline for this repo using Bun + Rust + Tauri tooling. + +## Layers and Commands + +L1 Dependency Risk: + +- `bun audit` +- `cd src-tauri && cargo audit` + +L2 Secrets: + +- `gitleaks detect --source . --no-git --redact` + +L3 SAST: + +- `bun run lint` +- `cd src-tauri && cargo clippy --all-targets --all-features -- -D warnings` + +L4 Human/AI Review: + +- required on security-sensitive file changes (settings/commands/tauri config) + +L5 Runtime Checks: + +- `bun run test:playwright` (when UI/runtime changes are relevant) + +L6 Supply Chain Integrity: + +- enforce `bun install --frozen-lockfile` in CI +- enforce stable lockfiles (`bun.lock`, `src-tauri/Cargo.lock`) + +L7 Observability: + +- keep CI artifacts and security scan outputs +- track security fixes in changelog/release notes + +## Local Entrypoints + +- `make security` runs L1+L2+L3+L6 checks +- `make help` lists all layer targets + +## CI Entrypoint + +- `.github/workflows/security.yml` diff --git a/docs/QUICKSTART.md b/docs/QUICKSTART.md new file mode 100644 index 000000000..f41fe3d98 --- /dev/null +++ b/docs/QUICKSTART.md @@ -0,0 +1,169 @@ +# Phraser — Quick Start + +Get from zero to a running voice terminal. + +--- + +## Prerequisites + +Install these before anything else: + +```bash +# Rust (latest stable) +curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh + +# Bun +curl -fsSL https://bun.sh/install | bash + +# Homebrew (macOS) +/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" + +# Claude Code (required for the claude pane in the dev layout) +# See: https://claude.ai/code for the latest install instructions +npm install -g @anthropic-ai/claude-code +``` + +--- + +## 1. Clone & install dependencies + +```bash +git clone https://github.com/celstnblacc/Phraser.git +cd Phraser +bun install +``` + +--- + +## 2. Download the VAD model (required) + +```bash +mkdir -p src-tauri/resources/models +curl -o src-tauri/resources/models/silero_vad_v4.onnx \ + https://blob.handy.computer/silero_vad_v4.onnx +``` + +--- + +## 3. Run or build + +**Dev mode** (faster, hot reload): + +```bash +bun run tauri dev + +# If cmake error on macOS: +CMAKE_POLICY_VERSION_MINIMUM=3.5 bun run tauri dev +``` + +**Build .app bundle** (production): + +```bash +bun run app:create +# Output: src-tauri/target/release/bundle/macos/Phraser.app +open src-tauri/target/release/bundle/macos/Phraser.app +``` + +--- + +## 4. First launch checklist + +When Phraser opens for the first time: + +- [ ] Grant **microphone** permission when prompted +- [ ] Grant **accessibility** permission (System Settings → Privacy → Accessibility) +- [ ] Go to **Settings → Models** → download a model (Parakeet V3 recommended) +- [ ] Set your **shortcut** in Settings → Bindings (default: `Option+Space`) +- [ ] Test: focus any text field, hold `Option+Space`, speak, release + +--- + +## 5. Voice terminal stack (optional) + +Set up the full offline voice-controlled terminal: + +**5a. Install tools:** + +```bash +brew install zellij yazi zoxide lazygit ollama +``` + +**5b. Start Ollama and pull the local LLM:** + +```bash +# Start Ollama server (run once, keep it running) +ollama serve &>/dev/null & +sleep 2 +ollama pull qwen2.5:0.5b +``` + +**5c. Configure your shell (`~/.zshrc`):** + +```bash +echo 'eval "$(zoxide init zsh)"' >> ~/.zshrc +echo 'alias dev="zellij --layout dev"' >> ~/.zshrc + +# cd-follow wrapper for Yazi +cat >> ~/.zshrc << 'EOF' +function y() { + local tmp="$(mktemp -t "yazi-cwd")" + yazi "$@" --cwd-file="$tmp" + if cwd="$(cat -- "$tmp")" && [ -n "$cwd" ] && [ "$cwd" != "$PWD" ]; then + builtin cd -- "$cwd" + fi + rm -f -- "$tmp" +} +EOF + +source ~/.zshrc +``` + +Then launch your full workspace: + +```bash +dev +``` + +> See [VOICE_TERMINAL_STACK.md](./VOICE_TERMINAL_STACK.md) for full architecture. +> See [VOICE_TERMINAL_BEGINNER_GUIDE.md](./VOICE_TERMINAL_BEGINNER_GUIDE.md) for day-to-day usage. + +--- + +## 6. Verify everything works + +```bash +bun run lint # frontend lint +bun run format:check # formatting +(cd src-tauri && cargo test) # rust tests (125 tests) +``` + +--- + +## Shortcuts reference + +| Shortcut | Action | +| -------------------- | --------------------------- | +| `Option+Space` | Record / transcribe | +| `Option+Shift+Space` | Record + post-process | +| `Escape` | Cancel recording | +| `Cmd+Shift+D` | Debug mode | +| `Option+Space` + `1` | Voice navigate (zoxide) | +| `Option+Space` + `2` | Voice open Yazi | +| `Option+Space` + `3` | Voice bash command (Ollama) | + +--- + +## Troubleshooting + +| Problem | Fix | +| --------------------- | -------------------------------------------------------- | +| App won't paste | Check Accessibility permission in System Settings | +| No transcription | Check microphone permission, confirm model is downloaded | +| cmake error on dev | Prefix with `CMAKE_POLICY_VERSION_MINIMUM=3.5` | +| Ollama not running | Run `ollama serve` in a background terminal | +| `dev` alias not found | Run `source ~/.zshrc` first | + +--- + +_Last updated: 2026-03-05_ +_Related: [VOICE_TERMINAL_STACK.md](./VOICE_TERMINAL_STACK.md) · [VOICE_TERMINAL_BEGINNER_GUIDE.md](./VOICE_TERMINAL_BEGINNER_GUIDE.md)_ diff --git a/docs/VOICE_TERMINAL_BEGINNER_GUIDE.md b/docs/VOICE_TERMINAL_BEGINNER_GUIDE.md new file mode 100644 index 000000000..f05d22311 --- /dev/null +++ b/docs/VOICE_TERMINAL_BEGINNER_GUIDE.md @@ -0,0 +1,166 @@ +# Voice Terminal — Beginner Guide + +> How to use the full Ghostty + Zellij + Yazi + lazygit + Phraser stack day-to-day. + +--- + +## Step 1 — Open Ghostty and load your workspace + +```bash +source ~/.zshrc # first time only, to load new aliases +dev # launches everything +``` + +You'll see 4 panes appear: + +``` +┌─────────────────┬──────────────────────┐ +│ │ │ +│ Yazi │ Claude Code │ +│ (files) │ (AI coding) │ +│ │ │ +├─────────────────┼──────────────────────┤ +│ │ │ +│ lazygit │ shell ← you type │ +│ (git) │ here │ +│ │ │ +└─────────────────┴──────────────────────┘ +``` + +--- + +## Step 2 — Move between panes + +Zellij default shortcuts: + +| Action | Keys | +| ------------------------------- | ----------------------- | +| Move to pane left/right/up/down | `Ctrl+p` then arrow key | +| New tab | `Ctrl+t` then `n` | +| Close pane | `Ctrl+p` then `x` | +| Detach (keep running) | `Ctrl+o` then `d` | +| Re-attach later | `zellij attach` | + +--- + +## Step 3 — Navigate files with Yazi + +Click the Yazi pane or move to it. Basic keys: + +| Key | Action | +| --------------------- | -------------------------------- | +| `↑` `↓` or `j` `k` | move up/down | +| `→` or `l` or `Enter` | open folder or file | +| `←` or `h` | go back (parent folder) | +| `q` | quit (shell follows to last dir) | +| `Space` | select file | +| `y` | copy selected (inside Yazi) | +| `p` | paste | +| `d` | cut | +| `D` | delete | +| `/` | search by name | + +> **Note:** `y` in the shell opens Yazi. Inside Yazi, `y` copies a file. They are different contexts — don't confuse them. + +--- + +## Step 4 — Jump to directories with zoxide + +In your **shell pane**, type: + +```bash +z phraser # jumps to Phraser project +z src # jumps to most visited "src" dir +z downloads # jumps to Downloads +``` + +zoxide **learns over time** — the more you visit a folder, the smarter it gets at guessing which one you mean. + +--- + +## Step 5 — Git with lazygit + +Move to the lazygit pane. Basic keys: + +| Key | Action | +| ------- | ------------------ | +| `↑` `↓` | navigate | +| `Space` | stage/unstage file | +| `c` | commit | +| `p` | push | +| `P` | pull | +| `b` | branches | +| `z` | undo last commit | +| `q` | quit | + +--- + +## Step 6 — Voice commands with Phraser + +Make sure your **shell pane is focused**, then: + +| You want to | Do this | +| ------------------ | ---------------------------------------------------------- | +| Jump to a dir | Hold `Option+Space` + press `1` → speak dir name → release | +| Open Yazi | Hold `Option+Space` + press `2` → release | +| Run a bash command | Hold `Option+Space` + press `3` → speak intent → release | +| Just dictate text | Hold `Option+Space` → speak → release (no number key) | + +> ⚠️ **Action 3 executes immediately.** The LLM-generated bash command runs without confirmation. Only use Action 3 for safe, read-only operations until you're confident in the output. Say something destructive and it will execute. + +**Example voice session:** + +``` +"go to phraser" [action 1] → z phraser → shell jumps there +"browse files" [action 2] → yazi → file browser opens +"find rust files" [action 3] → find . -name "*.rs" → executes +``` + +--- + +## Step 7 — Talk to Claude Code + +Move to the Claude Code pane, just speak or type naturally: + +``` +"explain this file" +"fix the bug in clipboard.rs" +"write tests for the history manager" +``` + +--- + +## Daily workflow + +``` +Open Ghostty + ↓ +type: dev + ↓ +┌── Yazi: browse your project +├── lazygit: stage & commit changes +├── Claude Code: ask for help / write code +└── shell: run commands by voice or typing +``` + +--- + +## Cheat sheet + +``` +dev → launch full workspace +z → jump to directory +y → open file browser (cd follows on quit) +lazygit → open git UI + +Voice shortcuts (Phraser): + Option+Space + 1 → navigate (zoxide) + Option+Space + 2 → browse files (yazi) + Option+Space + 3 → bash command (Ollama) + Option+Space → dictate text (raw) +``` + +--- + +_Last updated: 2026-03-05_ +_Related: [VOICE_TERMINAL_STACK.md](./VOICE_TERMINAL_STACK.md)_ diff --git a/docs/VOICE_TERMINAL_STACK.md b/docs/VOICE_TERMINAL_STACK.md new file mode 100644 index 000000000..75766d969 --- /dev/null +++ b/docs/VOICE_TERMINAL_STACK.md @@ -0,0 +1,277 @@ +# Voice-Controlled Terminal Stack + +> Fully offline, private voice navigation for Ghostty terminal. +> No cloud. No data sent. Everything runs locally. + +--- + +## Goal + +Speak to your terminal. Navigate directories, browse files, run git commands, +and interact with Claude Code — all by voice, all offline. + +--- + +## Stack Overview + +``` +┌─────────────────────────────────────────────┐ +│ Ghostty │ Terminal emulator +│ ┌─────────────────────────────────────────┐ │ +│ │ Zellij │ │ Multiplexer / layout +│ │ ┌──────────┐ ┌──────────────────────┐ │ │ +│ │ │ Yazi │ │ Claude Code │ │ │ +│ │ │ files │ │ AI coding │ │ │ +│ │ └──────────┘ └──────────────────────┘ │ │ +│ │ ┌──────────┐ ┌──────────────────────┐ │ │ +│ │ │ lazygit │ │ zsh + zoxide │ │ │ +│ │ │ git │ │ shell + smart cd │ │ │ +│ │ └──────────┘ └──────────────────────┘ │ │ +│ └─────────────────────────────────────────┘ │ +└─────────────────────────────────────────────┘ + ▲ + │ voice paste (system-level) + │ + [ Phraser ] ← Whisper (offline STT) + │ + [ Ollama ] ← Local LLM (complex queries only) +``` + +--- + +## Tools + +| Tool | Role | Replaces | +| --------------------------------------------------- | ------------------------------ | -------------------- | +| [Ghostty](https://ghostty.org/) | Terminal emulator | iTerm2, Alacritty | +| [Zellij](https://zellij.dev/) | Multiplexer, splits, sessions | tmux | +| [Yazi](https://yazi-rs.github.io/) | TUI file manager | `ls` + `cd` + Finder | +| [lazygit](https://github.com/jesseduffield/lazygit) | Git TUI | raw `git` commands | +| [Claude Code](https://claude.ai/code) | AI coding agent | — | +| [zoxide](https://github.com/ajeetdsouza/zoxide) | Smart `cd` with memory | `cd` | +| [Phraser](https://github.com/celstnblacc/Phraser) | Voice → text (offline Whisper) | — | +| [Ollama](https://ollama.com/) | Local LLM for NL→bash | cloud LLMs | + +--- + +## Voice Layer Architecture + +Phraser runs at the system level and pastes into whichever Ghostty pane is focused. +Three action keys handle the three main use cases: + +``` +Voice input + │ + ├─ Action 1 → zoxide navigation → z → shell pane + ├─ Action 2 → yazi open → yazi → file pane + ├─ Action 3 → bash command (Ollama) → full NL→bash → shell pane + └─ default → raw transcription → paste as-is → any pane +``` + +### Voice command map + +| You say | Action key | Output | Executes | +| ----------------------- | ---------- | --------------------- | ------------------ | +| _"go to phraser"_ | `1` | `z phraser` | jumps to dir | +| _"go up"_ | `1` | `z ..` | goes up one level | +| _"browse files"_ | `2` | `yazi` | opens file manager | +| _"find all rust files"_ | `3` | `find . -name "*.rs"` | Ollama generated | +| _"show disk usage"_ | `3` | `du -sh .` | Ollama generated | +| _"git status"_ | default | `git status` | raw paste | + +--- + +## Implementation Plan + +### Phase 1 — Core tools setup ✅ + +- [x] Install Ghostty → https://ghostty.org/download +- [x] Install Zellij → `brew install zellij` +- [x] Install Yazi → `brew install yazi` +- [x] Install lazygit → `brew install lazygit` +- [x] Install zoxide → `brew install zoxide` +- [x] Install Ollama → `brew install ollama` +- [x] Pull local model → `ollama pull qwen2.5:0.5b` +- [x] Init zoxide in zsh → added to `~/.zshrc`: + ```zsh + eval "$(zoxide init zsh)" + ``` + +### Phase 2 — Zellij layout ✅ + +Create `~/.config/zellij/layouts/dev.kdl`: + +```kdl +layout { + pane size=1 borderless=true { + plugin location="zellij:tab-bar" + } + pane split_direction="vertical" { + pane split_direction="horizontal" size="30%" { + pane name="files" command="yazi" size="65%" + pane name="git" command="lazygit" + } + pane split_direction="horizontal" { + pane name="claude" command="sh" args=["-c", "command -v claude >/dev/null 2>&1 && claude || echo 'Claude Code not installed. Run: npm i -g @anthropic-ai/claude-code'"] + pane name="shell" focus=true + } + } + pane size=2 borderless=true { + plugin location="zellij:status-bar" + } +} +``` + +Launch with: + +```bash +zellij --layout dev +``` + +Or add alias to `~/.zshrc`: + +```zsh +alias dev="zellij --layout dev" +``` + +### Phase 3 — Phraser action keys ✅ + +Configure in Phraser **Settings → Post-Processing → Actions**: + +All three actions use the Custom provider (Ollama) at `http://localhost:11434/v1` with `qwen2.5:0.5b`. + +**Action 1 — zoxide navigation** + +``` +Key: 1 +Name: Navigate +Provider: Custom → http://localhost:11434/v1 +Model: qwen2.5:0.5b +Prompt: Prepend 'z ' to the user input and output only that. Nothing else. No explanation. + Example: input 'phraser source' → output 'z phraser source' + Example: input 'go up' → output 'z ..' +Auto-submit: ON +``` + +**Action 2 — Open Yazi** + +``` +Key: 2 +Name: Browse Files +Provider: Custom → http://localhost:11434/v1 +Model: qwen2.5:0.5b +Prompt: Output only the word: yazi +Auto-submit: ON +``` + +**Action 3 — NL to bash (Ollama)** + +> ⚠️ Auto-submit is ON. The LLM-generated command executes immediately. Do not use this +> for destructive operations without reviewing the output first. Disable auto-submit if +> you want a confirmation step before execution. + +``` +Key: 3 +Name: Bash Command +Provider: Custom → http://localhost:11434/v1 +Model: qwen2.5:0.5b +Prompt: Convert the spoken text to a single bash command for macOS zsh. + Output ONLY the command. No explanation. No backticks. No markdown. +Auto-submit: ON +``` + +### Phase 4 — Yazi + Ghostty integration ✅ + +Yazi has native Ghostty image preview support. +Add to `~/.config/yazi/yazi.toml`: + +```toml +[manager] +ratio = [1, 2, 4] +sort_by = "modified" +sort_reverse = true +show_hidden = false +show_symlink = true + +[preview] +image_protocol = "iterm2" +image_filter = "lanczos3" +image_quality = 90 +tab_size = 2 +max_width = 600 +max_height = 900 +``` + +Add shell wrapper to `~/.zshrc` so `cd` follows when you quit Yazi: + +```zsh +function y() { + local tmp="$(mktemp -t "yazi-cwd")" + yazi "$@" --cwd-file="$tmp" + if cwd="$(cat -- "$tmp")" && [ -n "$cwd" ] && [ "$cwd" != "$PWD" ]; then + builtin cd -- "$cwd" + fi + rm -f -- "$tmp" +} +``` + +Now `y` opens Yazi and when you quit, your shell follows to the last dir you browsed. + +--- + +## Workflow Example + +``` +1. Open Ghostty +2. Type: dev → Zellij loads full layout + ├── top-left: Yazi (file browser) + ├── bot-left: lazygit (git) + ├── top-right: Claude Code (AI) + └── bot-right: shell (focused) + +3. Voice: "go to phraser src" + Action 1 + → z phraser src → shell jumps to dir + +4. Voice: "browse files" + Action 2 + → yazi opens in file pane + +5. Voice: "find all typescript files modified today" + Action 3 + → Ollama → find . -name "*.ts" -mtime -1 → executes + +6. Voice: "implement the voice nav zsh plugin" (default, no action key) + → pastes into Claude Code pane as natural language request +``` + +--- + +## Privacy & Offline Guarantee + +| Component | Data stays local? | +| ----------- | ----------------------------------------- | +| Ghostty | Yes — local app | +| Zellij | Yes — local app | +| Yazi | Yes — local app | +| lazygit | Yes — local app | +| zoxide | Yes — local db in `~/.local/share/zoxide` | +| Phraser | Yes — Whisper runs on-device | +| Ollama | Yes — model runs on-device | +| Claude Code | **No** — sends code to Anthropic API | + +> Claude Code is the only component that touches the network. +> All voice processing, navigation, and bash generation is 100% local. + +--- + +## References + +- Ghostty docs: https://ghostty.org/docs +- Zellij docs: https://zellij.dev/documentation/ +- Yazi docs: https://yazi-rs.github.io/docs/ +- lazygit repo: https://github.com/jesseduffield/lazygit +- zoxide repo: https://github.com/ajeetdsouza/zoxide +- Ollama: https://ollama.com/ +- Ghostty + Yazi guide: https://yingqijing.medium.com/ghostty-and-yazi-the-best-terminal-tools-baf5b90c76bf + +--- + +_Last updated: 2026-03-05_ diff --git a/docs/icon.png b/docs/icon.png new file mode 100644 index 000000000..3f436b03a Binary files /dev/null and b/docs/icon.png differ diff --git a/docs/index.html b/docs/index.html new file mode 100644 index 000000000..1ded1f67d --- /dev/null +++ b/docs/index.html @@ -0,0 +1,400 @@ + + + + + + Phraser — Offline Speech-to-Text for Your Desktop + + + + + + +
+
+

Speak. Transcribe. Done.

+

+ A free, open source speech-to-text app that runs entirely on your + computer. No cloud, no subscription, no data leaving your machine. +

+ +

+ macOS (Apple Silicon & Intel) · Linux coming soon +

+
+
+ +
+
+

How It Works

+
+
+
1
+

Press

+

Hit a keyboard shortcut to start recording

+
+
+
2
+

Speak

+

Say what you want to type

+
+
+
3
+

Release

+

Phraser processes your speech locally

+
+
+
4
+

Done

+

Text appears in whatever app you're using

+
+
+
+
+ +
+
+

Why Phraser

+
+
+

Completely Private

+

+ Everything runs on your machine. Your voice never leaves your + computer — no cloud, no accounts, no tracking. +

+
+
+

Free & Open Source

+

+ MIT licensed. Fork it, extend it, make it yours. Accessibility + tools shouldn't be behind a paywall. +

+
+
+

Multiple Models

+

+ Choose from Whisper (Small to Large) with GPU acceleration, or + Parakeet V3 for fast CPU-only transcription. +

+
+
+

Smart Switching

+

+ Automatically uses a faster model for short recordings and a more + accurate one for longer speech. +

+
+
+
+
+ + + + diff --git a/index.html b/index.html index ec95d3aee..76220c2dc 100644 --- a/index.html +++ b/index.html @@ -3,7 +3,7 @@ - Parler + Phraser diff --git a/package.json b/package.json index 61e6e6741..dd5ee3a37 100644 --- a/package.json +++ b/package.json @@ -1,15 +1,16 @@ { - "name": "parler-app", + "name": "phraser-app", "private": true, "version": "0.7.12", "type": "module", "scripts": { "dev": "vite", "build": "tsc && vite build", + "app:create": "bash scripts/build-app.sh", "preview": "vite preview", "tauri": "tauri", - "tauri:dev:parlerdev": "tauri dev --config src-tauri/tauri.dev.conf.json", - "tauri:build:parlerdev": "tauri build --config src-tauri/tauri.dev.conf.json", + "tauri:dev:phraserdev": "tauri dev --config src-tauri/tauri.dev.conf.json", + "tauri:build:phraserdev": "tauri build --config src-tauri/tauri.dev.conf.json", "lint": "eslint src", "lint:fix": "eslint src --fix", "format": "prettier --write . && cd src-tauri && cargo fmt", @@ -18,7 +19,9 @@ "format:backend": "cd src-tauri && cargo fmt", "test:playwright": "playwright test", "test:playwright:ui": "playwright test --ui", - "check:translations": "bun scripts/check-translations.ts" + "check:translations": "bun scripts/check-translations.ts", + "test:unit": "vitest run", + "test:unit:watch": "vitest" }, "dependencies": { "@tailwindcss/vite": "^4.1.16", @@ -51,6 +54,9 @@ "devDependencies": { "@playwright/test": "^1.58.0", "@tauri-apps/cli": "^2.9.1", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@types/node": "^24.9.1", "@types/react": "^18.3.26", "@types/react-dom": "^18.3.7", @@ -58,10 +64,13 @@ "@typescript-eslint/eslint-plugin": "^8.49.0", "@typescript-eslint/parser": "^8.49.0", "@vitejs/plugin-react": "^4.7.0", + "@vitest/coverage-v8": "^4.0.18", "eslint": "^9.39.1", "eslint-plugin-i18next": "^6.1.3", + "happy-dom": "^20.8.3", "prettier": "^3.6.2", "typescript": "~5.6.3", - "vite": "^6.4.1" + "vite": "^6.4.1", + "vitest": "^4.0.18" } } diff --git a/scripts/build-app 2.sh b/scripts/build-app 2.sh new file mode 100755 index 000000000..9eb463c23 --- /dev/null +++ b/scripts/build-app 2.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT_DIR" + +if ! command -v bun >/dev/null 2>&1; then + echo "Error: bun is not installed. Install it from https://bun.sh" >&2 + exit 1 +fi + +echo "Building desktop app bundle..." + +# Remove macOS extended attributes from bundled assets to avoid +# codesign failures like "resource fork, Finder information, or similar detritus not allowed". +if [ "$(uname -s)" = "Darwin" ] && command -v xattr >/dev/null 2>&1; then + xattr -cr "$ROOT_DIR/src-tauri/icons" 2>/dev/null || true + xattr -cr "$ROOT_DIR/src-tauri/resources" 2>/dev/null || true +fi + +TAURI_CONFIG="src-tauri/tauri.local.unsigned.conf.json" +bun run tauri build --config "$TAURI_CONFIG" "$@" + +APP_PATH="src-tauri/target/release/bundle/macos/Phraser.app" +if [ -d "$APP_PATH" ]; then + echo "" + echo "App bundle created:" + echo "$ROOT_DIR/$APP_PATH" +else + echo "" + echo "Build finished. Bundle path may vary by platform." + echo "Check: $ROOT_DIR/src-tauri/target/release/bundle/" +fi diff --git a/scripts/build-app.sh b/scripts/build-app.sh new file mode 100755 index 000000000..9eb463c23 --- /dev/null +++ b/scripts/build-app.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT_DIR" + +if ! command -v bun >/dev/null 2>&1; then + echo "Error: bun is not installed. Install it from https://bun.sh" >&2 + exit 1 +fi + +echo "Building desktop app bundle..." + +# Remove macOS extended attributes from bundled assets to avoid +# codesign failures like "resource fork, Finder information, or similar detritus not allowed". +if [ "$(uname -s)" = "Darwin" ] && command -v xattr >/dev/null 2>&1; then + xattr -cr "$ROOT_DIR/src-tauri/icons" 2>/dev/null || true + xattr -cr "$ROOT_DIR/src-tauri/resources" 2>/dev/null || true +fi + +TAURI_CONFIG="src-tauri/tauri.local.unsigned.conf.json" +bun run tauri build --config "$TAURI_CONFIG" "$@" + +APP_PATH="src-tauri/target/release/bundle/macos/Phraser.app" +if [ -d "$APP_PATH" ]; then + echo "" + echo "App bundle created:" + echo "$ROOT_DIR/$APP_PATH" +else + echo "" + echo "Build finished. Bundle path may vary by platform." + echo "Check: $ROOT_DIR/src-tauri/target/release/bundle/" +fi diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 36eaa0790..86f61e5b4 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -624,9 +624,9 @@ checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" [[package]] name = "bytes" -version = "1.10.1" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" dependencies = [ "serde", ] @@ -3525,9 +3525,9 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.1.0" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" +checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" [[package]] name = "num-derive" @@ -4137,66 +4137,6 @@ dependencies = [ "windows-link 0.2.1", ] -[[package]] -name = "parler" -version = "0.7.13" -dependencies = [ - "anyhow", - "base64 0.22.1", - "chrono", - "clap", - "cpal", - "enigo", - "env_filter", - "ferrous-opencc", - "flate2", - "futures-util", - "gtk", - "gtk-layer-shell", - "handy-keys", - "hound", - "log", - "natural", - "once_cell", - "rdev 0.5.0-2", - "regex", - "reqwest", - "rodio", - "rubato", - "rusqlite", - "rusqlite_migration", - "rustfft", - "serde", - "serde_json", - "signal-hook", - "specta", - "specta-typescript", - "strsim", - "tar", - "tauri", - "tauri-build", - "tauri-nspanel", - "tauri-plugin-autostart", - "tauri-plugin-clipboard-manager", - "tauri-plugin-dialog", - "tauri-plugin-fs", - "tauri-plugin-global-shortcut", - "tauri-plugin-log", - "tauri-plugin-macos-permissions", - "tauri-plugin-opener", - "tauri-plugin-os", - "tauri-plugin-process", - "tauri-plugin-single-instance", - "tauri-plugin-store", - "tauri-plugin-updater", - "tauri-specta", - "tempfile", - "tokio", - "transcribe-rs", - "vad-rs", - "windows 0.61.3", -] - [[package]] name = "paste" version = "1.0.15" @@ -4374,6 +4314,66 @@ dependencies = [ "siphasher 1.0.1", ] +[[package]] +name = "phraser" +version = "0.7.14" +dependencies = [ + "anyhow", + "base64 0.22.1", + "chrono", + "clap", + "cpal", + "enigo", + "env_filter", + "ferrous-opencc", + "flate2", + "futures-util", + "gtk", + "gtk-layer-shell", + "handy-keys", + "hound", + "log", + "natural", + "once_cell", + "rdev 0.5.0-2", + "regex", + "reqwest", + "rodio", + "rubato", + "rusqlite", + "rusqlite_migration", + "rustfft", + "serde", + "serde_json", + "signal-hook", + "specta", + "specta-typescript", + "strsim", + "tar", + "tauri", + "tauri-build", + "tauri-nspanel", + "tauri-plugin-autostart", + "tauri-plugin-clipboard-manager", + "tauri-plugin-dialog", + "tauri-plugin-fs", + "tauri-plugin-global-shortcut", + "tauri-plugin-log", + "tauri-plugin-macos-permissions", + "tauri-plugin-opener", + "tauri-plugin-os", + "tauri-plugin-process", + "tauri-plugin-single-instance", + "tauri-plugin-store", + "tauri-plugin-updater", + "tauri-specta", + "tempfile", + "tokio", + "transcribe-rs", + "vad-rs", + "windows 0.61.3", +] + [[package]] name = "pin-project-lite" version = "0.2.16" @@ -5074,9 +5074,9 @@ checksum = "3df6368f71f205ff9c33c076d170dd56ebf68e8161c733c0caa07a7a5509ed53" [[package]] name = "rkyv" -version = "0.7.45" +version = "0.7.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9008cd6385b9e161d8229e1f6549dd23c3d022f132a2ea37ac3a10ac4935779b" +checksum = "2297bf9c81a3f0dc96bc9521370b88f054168c29826a75e89c55ff196e7ed6a1" dependencies = [ "bitvec", "bytecheck", @@ -5092,9 +5092,9 @@ dependencies = [ [[package]] name = "rkyv_derive" -version = "0.7.45" +version = "0.7.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "503d1d27590a2b0a3a4ca4c94755aa2875657196ecbf401a42eff41d7de532c0" +checksum = "84d7b42d4b8d06048d3ac8db0eb31bcb942cbeb709f0b5f2b2ebde398d3038f5" dependencies = [ "proc-macro2", "quote", @@ -6794,9 +6794,9 @@ dependencies = [ [[package]] name = "time" -version = "0.3.44" +version = "0.3.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", "itoa", @@ -6804,22 +6804,22 @@ dependencies = [ "num-conv", "num_threads", "powerfmt", - "serde", + "serde_core", "time-core", "time-macros", ] [[package]] name = "time-core" -version = "0.1.6" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" [[package]] name = "time-macros" -version = "0.2.24" +version = "0.2.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30cfb0125f12d9c277f35663a0a33f8c30190f4e4574868a330595412d34ebf3" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" dependencies = [ "num-conv", "time-core", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 883c1cad5..cfb6ee257 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,11 +1,11 @@ [package] -name = "parler" +name = "phraser" version = "0.7.14" -description = "Parler" +description = "Phraser" authors = ["cjpais"] edition = "2021" license = "MIT" -default-run = "parler" +default-run = "phraser" [profile.dev] incremental = true # Compile your binary in smaller steps. @@ -16,7 +16,7 @@ incremental = true # Compile your binary in smaller steps. # The `_lib` suffix may seem redundant but it is necessary # to make the lib name unique and wouldn't conflict with the bin name. # This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519 -name = "parler_app_lib" +name = "phraser_app_lib" crate-type = ["staticlib", "cdylib", "rlib"] # [[bin]] diff --git a/src-tauri/icons/128x128.png b/src-tauri/icons/128x128.png index d07b41fa4..3f436b03a 100644 Binary files a/src-tauri/icons/128x128.png and b/src-tauri/icons/128x128.png differ diff --git a/src-tauri/icons/128x128@2x.png b/src-tauri/icons/128x128@2x.png index dc4a13a9f..cf5ad6c62 100644 Binary files a/src-tauri/icons/128x128@2x.png and b/src-tauri/icons/128x128@2x.png differ diff --git a/src-tauri/icons/32x32.png b/src-tauri/icons/32x32.png index 1299f709c..60f5177b4 100644 Binary files a/src-tauri/icons/32x32.png and b/src-tauri/icons/32x32.png differ diff --git a/src-tauri/icons/64x64.png b/src-tauri/icons/64x64.png index b03a6388e..c84429c72 100644 Binary files a/src-tauri/icons/64x64.png and b/src-tauri/icons/64x64.png differ diff --git a/src-tauri/icons/Square107x107Logo.png b/src-tauri/icons/Square107x107Logo.png index e62575557..ba56cfe41 100644 Binary files a/src-tauri/icons/Square107x107Logo.png and b/src-tauri/icons/Square107x107Logo.png differ diff --git a/src-tauri/icons/Square142x142Logo.png b/src-tauri/icons/Square142x142Logo.png index a4a9cbfe9..017f3584e 100644 Binary files a/src-tauri/icons/Square142x142Logo.png and b/src-tauri/icons/Square142x142Logo.png differ diff --git a/src-tauri/icons/Square150x150Logo.png b/src-tauri/icons/Square150x150Logo.png index 18b690fde..39e5fd300 100644 Binary files a/src-tauri/icons/Square150x150Logo.png and b/src-tauri/icons/Square150x150Logo.png differ diff --git a/src-tauri/icons/Square284x284Logo.png b/src-tauri/icons/Square284x284Logo.png index df8dfa5f5..e56020162 100644 Binary files a/src-tauri/icons/Square284x284Logo.png and b/src-tauri/icons/Square284x284Logo.png differ diff --git a/src-tauri/icons/Square30x30Logo.png b/src-tauri/icons/Square30x30Logo.png index 9fbfd2f52..9500d3a0b 100644 Binary files a/src-tauri/icons/Square30x30Logo.png and b/src-tauri/icons/Square30x30Logo.png differ diff --git a/src-tauri/icons/Square310x310Logo.png b/src-tauri/icons/Square310x310Logo.png index 9c2b3bf9f..ee4c1dfa9 100644 Binary files a/src-tauri/icons/Square310x310Logo.png and b/src-tauri/icons/Square310x310Logo.png differ diff --git a/src-tauri/icons/Square44x44Logo.png b/src-tauri/icons/Square44x44Logo.png index dad4e7fd2..d9134ab71 100644 Binary files a/src-tauri/icons/Square44x44Logo.png and b/src-tauri/icons/Square44x44Logo.png differ diff --git a/src-tauri/icons/Square71x71Logo.png b/src-tauri/icons/Square71x71Logo.png index f3f4c7874..08719d55c 100644 Binary files a/src-tauri/icons/Square71x71Logo.png and b/src-tauri/icons/Square71x71Logo.png differ diff --git a/src-tauri/icons/Square89x89Logo.png b/src-tauri/icons/Square89x89Logo.png index 1bc6dda06..edab68e22 100644 Binary files a/src-tauri/icons/Square89x89Logo.png and b/src-tauri/icons/Square89x89Logo.png differ diff --git a/src-tauri/icons/StoreLogo.png b/src-tauri/icons/StoreLogo.png index d50bc5a5e..80f21870c 100644 Binary files a/src-tauri/icons/StoreLogo.png and b/src-tauri/icons/StoreLogo.png differ diff --git a/src-tauri/icons/android/mipmap-anydpi-v26/ic_launcher 2.xml b/src-tauri/icons/android/mipmap-anydpi-v26/ic_launcher 2.xml new file mode 100644 index 000000000..2ffbf24b6 --- /dev/null +++ b/src-tauri/icons/android/mipmap-anydpi-v26/ic_launcher 2.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/src-tauri/icons/android/mipmap-anydpi-v26/ic_launcher.xml b/src-tauri/icons/android/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 000000000..2ffbf24b6 --- /dev/null +++ b/src-tauri/icons/android/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png b/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png index bf59e1ec1..863f3b474 100644 Binary files a/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png and b/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png differ diff --git a/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png b/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png index 87766d424..32c4215e9 100644 Binary files a/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png and b/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png b/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png index bf59e1ec1..e35025b8e 100644 Binary files a/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png and b/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png differ diff --git a/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png b/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png index 8abd65227..4de541f08 100644 Binary files a/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png and b/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png differ diff --git a/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png b/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png index 9597ae804..348f18dc5 100644 Binary files a/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png and b/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png b/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png index 8abd65227..32bd5d79b 100644 Binary files a/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png and b/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png differ diff --git a/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png b/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png index 49cacd507..c8bd39796 100644 Binary files a/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png and b/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png differ diff --git a/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png b/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png index a05f16c62..c541a11db 100644 Binary files a/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png and b/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png b/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png index 49cacd507..24b8478b1 100644 Binary files a/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png and b/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png b/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png index 595f19c2f..82a23a17e 100644 Binary files a/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png and b/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png differ diff --git a/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png b/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png index 26983c498..d6c31b5cf 100644 Binary files a/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png and b/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png b/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png index 595f19c2f..6cca07969 100644 Binary files a/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png and b/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png b/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png index 552687351..baa138c7f 100644 Binary files a/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png and b/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png b/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png index a66141ac8..4acb261b1 100644 Binary files a/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png and b/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png b/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png index 552687351..c81676ee2 100644 Binary files a/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png and b/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/src-tauri/icons/android/values/ic_launcher_background 2.xml b/src-tauri/icons/android/values/ic_launcher_background 2.xml new file mode 100644 index 000000000..ea9c223a6 --- /dev/null +++ b/src-tauri/icons/android/values/ic_launcher_background 2.xml @@ -0,0 +1,4 @@ + + + #fff + \ No newline at end of file diff --git a/src-tauri/icons/android/values/ic_launcher_background.xml b/src-tauri/icons/android/values/ic_launcher_background.xml new file mode 100644 index 000000000..ea9c223a6 --- /dev/null +++ b/src-tauri/icons/android/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #fff + \ No newline at end of file diff --git a/src-tauri/icons/icon.icns b/src-tauri/icons/icon.icns index cac9aa5e8..0ea5d88cc 100644 Binary files a/src-tauri/icons/icon.icns and b/src-tauri/icons/icon.icns differ diff --git a/src-tauri/icons/icon.ico b/src-tauri/icons/icon.ico index ccd1743cb..bd3e7cc63 100644 Binary files a/src-tauri/icons/icon.ico and b/src-tauri/icons/icon.ico differ diff --git a/src-tauri/icons/icon.png b/src-tauri/icons/icon.png index 14c820626..2099f261a 100644 Binary files a/src-tauri/icons/icon.png and b/src-tauri/icons/icon.png differ diff --git a/src-tauri/icons/ios/AppIcon-20x20@1x.png b/src-tauri/icons/ios/AppIcon-20x20@1x.png index 0d21a151f..eeb740010 100644 Binary files a/src-tauri/icons/ios/AppIcon-20x20@1x.png and b/src-tauri/icons/ios/AppIcon-20x20@1x.png differ diff --git a/src-tauri/icons/ios/AppIcon-20x20@2x-1.png b/src-tauri/icons/ios/AppIcon-20x20@2x-1.png index 91aba3c71..3ac019a2d 100644 Binary files a/src-tauri/icons/ios/AppIcon-20x20@2x-1.png and b/src-tauri/icons/ios/AppIcon-20x20@2x-1.png differ diff --git a/src-tauri/icons/ios/AppIcon-20x20@2x.png b/src-tauri/icons/ios/AppIcon-20x20@2x.png index 91aba3c71..3ac019a2d 100644 Binary files a/src-tauri/icons/ios/AppIcon-20x20@2x.png and b/src-tauri/icons/ios/AppIcon-20x20@2x.png differ diff --git a/src-tauri/icons/ios/AppIcon-20x20@3x.png b/src-tauri/icons/ios/AppIcon-20x20@3x.png index 75a3a544a..55389d724 100644 Binary files a/src-tauri/icons/ios/AppIcon-20x20@3x.png and b/src-tauri/icons/ios/AppIcon-20x20@3x.png differ diff --git a/src-tauri/icons/ios/AppIcon-29x29@1x.png b/src-tauri/icons/ios/AppIcon-29x29@1x.png index 78d805524..f4489b7aa 100644 Binary files a/src-tauri/icons/ios/AppIcon-29x29@1x.png and b/src-tauri/icons/ios/AppIcon-29x29@1x.png differ diff --git a/src-tauri/icons/ios/AppIcon-29x29@2x-1.png b/src-tauri/icons/ios/AppIcon-29x29@2x-1.png index 8029ecdbb..6656ff4a2 100644 Binary files a/src-tauri/icons/ios/AppIcon-29x29@2x-1.png and b/src-tauri/icons/ios/AppIcon-29x29@2x-1.png differ diff --git a/src-tauri/icons/ios/AppIcon-29x29@2x.png b/src-tauri/icons/ios/AppIcon-29x29@2x.png index 8029ecdbb..6656ff4a2 100644 Binary files a/src-tauri/icons/ios/AppIcon-29x29@2x.png and b/src-tauri/icons/ios/AppIcon-29x29@2x.png differ diff --git a/src-tauri/icons/ios/AppIcon-29x29@3x.png b/src-tauri/icons/ios/AppIcon-29x29@3x.png index e18d46f2f..d0b92c64f 100644 Binary files a/src-tauri/icons/ios/AppIcon-29x29@3x.png and b/src-tauri/icons/ios/AppIcon-29x29@3x.png differ diff --git a/src-tauri/icons/ios/AppIcon-40x40@1x.png b/src-tauri/icons/ios/AppIcon-40x40@1x.png index 91aba3c71..3ac019a2d 100644 Binary files a/src-tauri/icons/ios/AppIcon-40x40@1x.png and b/src-tauri/icons/ios/AppIcon-40x40@1x.png differ diff --git a/src-tauri/icons/ios/AppIcon-40x40@2x-1.png b/src-tauri/icons/ios/AppIcon-40x40@2x-1.png index 68f773fa4..e06e76249 100644 Binary files a/src-tauri/icons/ios/AppIcon-40x40@2x-1.png and b/src-tauri/icons/ios/AppIcon-40x40@2x-1.png differ diff --git a/src-tauri/icons/ios/AppIcon-40x40@2x.png b/src-tauri/icons/ios/AppIcon-40x40@2x.png index 68f773fa4..e06e76249 100644 Binary files a/src-tauri/icons/ios/AppIcon-40x40@2x.png and b/src-tauri/icons/ios/AppIcon-40x40@2x.png differ diff --git a/src-tauri/icons/ios/AppIcon-40x40@3x.png b/src-tauri/icons/ios/AppIcon-40x40@3x.png index afcaed58c..1d477a8cf 100644 Binary files a/src-tauri/icons/ios/AppIcon-40x40@3x.png and b/src-tauri/icons/ios/AppIcon-40x40@3x.png differ diff --git a/src-tauri/icons/ios/AppIcon-512@2x.png b/src-tauri/icons/ios/AppIcon-512@2x.png index 8ac533e36..7689867c3 100644 Binary files a/src-tauri/icons/ios/AppIcon-512@2x.png and b/src-tauri/icons/ios/AppIcon-512@2x.png differ diff --git a/src-tauri/icons/ios/AppIcon-60x60@2x.png b/src-tauri/icons/ios/AppIcon-60x60@2x.png index afcaed58c..1d477a8cf 100644 Binary files a/src-tauri/icons/ios/AppIcon-60x60@2x.png and b/src-tauri/icons/ios/AppIcon-60x60@2x.png differ diff --git a/src-tauri/icons/ios/AppIcon-60x60@3x.png b/src-tauri/icons/ios/AppIcon-60x60@3x.png index f614d00b9..fd1634cf0 100644 Binary files a/src-tauri/icons/ios/AppIcon-60x60@3x.png and b/src-tauri/icons/ios/AppIcon-60x60@3x.png differ diff --git a/src-tauri/icons/ios/AppIcon-76x76@1x.png b/src-tauri/icons/ios/AppIcon-76x76@1x.png index 5fd0658d4..b17f41c86 100644 Binary files a/src-tauri/icons/ios/AppIcon-76x76@1x.png and b/src-tauri/icons/ios/AppIcon-76x76@1x.png differ diff --git a/src-tauri/icons/ios/AppIcon-76x76@2x.png b/src-tauri/icons/ios/AppIcon-76x76@2x.png index dc2bbe7f3..333ffd72f 100644 Binary files a/src-tauri/icons/ios/AppIcon-76x76@2x.png and b/src-tauri/icons/ios/AppIcon-76x76@2x.png differ diff --git a/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png b/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png index 8db3d2fa6..29e8b668d 100644 Binary files a/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png and b/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png differ diff --git a/src-tauri/icons/newblacc-icon 2.svg b/src-tauri/icons/newblacc-icon 2.svg new file mode 100644 index 000000000..8b00b42dd --- /dev/null +++ b/src-tauri/icons/newblacc-icon 2.svg @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Aa + diff --git a/src-tauri/icons/newblacc-icon.svg b/src-tauri/icons/newblacc-icon.svg new file mode 100644 index 000000000..8b00b42dd --- /dev/null +++ b/src-tauri/icons/newblacc-icon.svg @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Aa + diff --git a/src-tauri/src/actions.rs b/src-tauri/src/actions.rs index 73956b487..0c942566f 100644 --- a/src-tauri/src/actions.rs +++ b/src-tauri/src/actions.rs @@ -4,7 +4,10 @@ use crate::audio_feedback::{play_feedback_sound, play_feedback_sound_blocking, S use crate::managers::audio::AudioRecordingManager; use crate::managers::history::HistoryManager; use crate::managers::transcription::TranscriptionManager; -use crate::settings::{get_settings, AppSettings, APPLE_INTELLIGENCE_PROVIDER_ID}; +use crate::settings::{ + get_settings, AppSettings, PostProcessAction, APPLE_INTELLIGENCE_PROVIDER_ID, + LANG_SIMPLIFIED_CHINESE, LANG_TRADITIONAL_CHINESE, +}; use crate::shortcut; use crate::tray::{change_tray_icon, TrayIconState}; use crate::utils::{ @@ -22,6 +25,80 @@ use tauri::Manager; pub struct ActiveActionState(pub Mutex>); +/// Audio sample rate used by the transcription pipeline. +const SAMPLE_RATE_HZ: f32 = 16_000.0; + +/// Stores the bundle identifier of the application that was frontmost when +/// recording started. Before pasting we re-activate this app so the text +/// ends up in the correct window (important for Electron apps like Claude +/// Desktop that can lose focus during the transcription pipeline). +#[cfg(target_os = "macos")] +static FRONTMOST_APP_BUNDLE_ID: Lazy>> = Lazy::new(|| Mutex::new(None)); + +#[cfg(any(target_os = "macos", test))] +fn is_phraser_bundle_id(bundle_id: &str) -> bool { + matches!( + bundle_id, + "com.newblacc.phraser" | "com.newblacc.parler" | "com.melvynx.parler" | "computer.handy" + ) +} + +/// Capture the currently frontmost application (macOS only). +#[cfg(target_os = "macos")] +fn save_frontmost_app() { + let output = std::process::Command::new("osascript") + .args([ + "-e", + r#"tell application "System Events" to get bundle identifier of first process whose frontmost is true"#, + ]) + .output(); + + if let Ok(out) = output { + let bundle_id = String::from_utf8_lossy(&out.stdout).trim().to_string(); + if !bundle_id.is_empty() { + if is_phraser_bundle_id(&bundle_id) { + debug!( + "Skipping frontmost app save because foreground app is Phraser: {}", + bundle_id + ); + return; + } + debug!("Saved frontmost app: {}", bundle_id); + if let Ok(mut guard) = FRONTMOST_APP_BUNDLE_ID.lock() { + *guard = Some(bundle_id); + } + } + } +} + +/// Re-activate the previously frontmost application before pasting (macOS only). +/// This ensures the paste keystroke targets the correct app, even if the overlay +/// or transcription pipeline accidentally brought Phraser to the foreground. +#[cfg(target_os = "macos")] +fn restore_frontmost_app() { + let bundle_id = FRONTMOST_APP_BUNDLE_ID + .lock() + .ok() + .and_then(|mut guard| guard.take()); + + if let Some(bid) = bundle_id { + if is_phraser_bundle_id(&bid) { + debug!( + "Skipping frontmost app restore for Phraser bundle id: {}", + bid + ); + return; + } + debug!("Restoring frontmost app: {}", bid); + let script = format!(r#"tell application id "{}" to activate"#, bid); + let _ = std::process::Command::new("osascript") + .args(["-e", &script]) + .output(); + // Give the target app a moment to become frontmost + std::thread::sleep(std::time::Duration::from_millis(100)); + } +} + /// Drop guard that notifies the [`TranscriptionCoordinator`] when the /// transcription pipeline finishes — whether it completes normally or panics. struct FinishGuard(AppHandle); @@ -47,6 +124,49 @@ struct TranscribeAction { /// Field name for structured output JSON schema const TRANSCRIPTION_FIELD: &str = "transcription"; +/// Result of the text post-processing pipeline. +struct ProcessedTextResult { + /// The final text to paste (may be post-processed, Chinese-converted, or raw transcription). + final_text: String, + /// The post-processed or Chinese-converted text, if any transformation was applied. + post_processed_text: Option, + /// The prompt template used for LLM processing, if any. + post_process_prompt: Option, +} + +/// Call Apple Intelligence for text processing. +/// Returns `None` if Apple Intelligence is unavailable, unsupported, or returns an empty result. +#[cfg(all(target_os = "macos", target_arch = "aarch64"))] +fn call_apple_intelligence(system_prompt: &str, user_content: &str, model: &str) -> Option { + if !apple_intelligence::check_apple_intelligence_availability() { + debug!("Apple Intelligence selected but not currently available on this device"); + return None; + } + let token_limit = model.trim().parse::().unwrap_or(0); + match apple_intelligence::process_text_with_system_prompt( + system_prompt, + user_content, + token_limit, + ) { + Ok(result) if !result.trim().is_empty() => { + let result = strip_invisible_chars(&result); + debug!( + "Apple Intelligence processing succeeded. Output length: {} chars", + result.len() + ); + Some(result) + } + Ok(_) => { + debug!("Apple Intelligence returned an empty response"); + None + } + Err(err) => { + error!("Apple Intelligence processing failed: {}", err); + None + } + } +} + /// Strip invisible Unicode characters that some LLMs may insert fn strip_invisible_chars(s: &str) -> String { s.replace(['\u{200B}', '\u{200C}', '\u{200D}', '\u{FEFF}'], "") @@ -129,39 +249,7 @@ async fn post_process_transcription(settings: &AppSettings, transcription: &str) // Handle Apple Intelligence separately since it uses native Swift APIs if provider.id == APPLE_INTELLIGENCE_PROVIDER_ID { #[cfg(all(target_os = "macos", target_arch = "aarch64"))] - { - if !apple_intelligence::check_apple_intelligence_availability() { - debug!( - "Apple Intelligence selected but not currently available on this device" - ); - return None; - } - - let token_limit = model.trim().parse::().unwrap_or(0); - return match apple_intelligence::process_text_with_system_prompt( - &system_prompt, - &user_content, - token_limit, - ) { - Ok(result) => { - if result.trim().is_empty() { - debug!("Apple Intelligence returned an empty response"); - None - } else { - let result = strip_invisible_chars(&result); - debug!( - "Apple Intelligence post-processing succeeded. Output length: {} chars", - result.len() - ); - Some(result) - } - } - Err(err) => { - error!("Apple Intelligence post-processing failed: {}", err); - None - } - }; - } + return call_apple_intelligence(&system_prompt, &user_content, &model); #[cfg(not(all(target_os = "macos", target_arch = "aarch64")))] { @@ -213,11 +301,11 @@ async fn post_process_transcription(settings: &AppSettings, transcription: &str) } } Err(e) => { - error!( - "Failed to parse structured output JSON: {}. Returning raw content.", - e + warn!( + "Failed to parse structured output JSON for provider '{}': {}. Falling back to legacy mode.", + provider.id, e ); - return Some(strip_invisible_chars(&content)); + // Fall through to legacy mode below } } } @@ -316,35 +404,7 @@ async fn process_action( // Handle Apple Intelligence via native Swift APIs if provider.id == APPLE_INTELLIGENCE_PROVIDER_ID { #[cfg(all(target_os = "macos", target_arch = "aarch64"))] - { - if !apple_intelligence::check_apple_intelligence_availability() { - debug!("Apple Intelligence selected but not available for action processing"); - return None; - } - let token_limit = model.trim().parse::().unwrap_or(0); - return match apple_intelligence::process_text_with_system_prompt( - &full_prompt, - transcription, - token_limit, - ) { - Ok(result) if !result.trim().is_empty() => { - let result = strip_invisible_chars(&result); - debug!( - "Apple Intelligence action processing succeeded. Output length: {} chars", - result.len() - ); - Some(result) - } - Ok(_) => { - debug!("Apple Intelligence action returned empty result"); - None - } - Err(err) => { - error!("Apple Intelligence action processing failed: {}", err); - None - } - }; - } + return call_apple_intelligence(&full_prompt, transcription, &model); #[cfg(not(all(target_os = "macos", target_arch = "aarch64")))] { @@ -369,13 +429,12 @@ async fn process_action( let system_prompt = "You are a text processing assistant. Output ONLY the final processed text. Do not add any explanation, commentary, preamble, or formatting such as markdown code blocks. Just output the raw result text, nothing else.".to_string(); - match crate::llm_client::send_chat_completion_with_schema( + match crate::llm_client::send_chat_completion_with_system( &provider, api_key, &model, full_prompt, - Some(system_prompt), - None, + system_prompt, ) .await { @@ -407,8 +466,8 @@ async fn maybe_convert_chinese_variant( transcription: &str, ) -> Option { // Check if language is set to Simplified or Traditional Chinese - let is_simplified = settings.selected_language == "zh-Hans"; - let is_traditional = settings.selected_language == "zh-Hant"; + let is_simplified = settings.selected_language == LANG_SIMPLIFIED_CHINESE; + let is_traditional = settings.selected_language == LANG_TRADITIONAL_CHINESE; if !is_simplified && !is_traditional { debug!("selected_language is not Simplified or Traditional Chinese; skipping translation"); @@ -446,11 +505,173 @@ async fn maybe_convert_chinese_variant( } } +/// Switch to the long-audio model if the recording duration exceeds the configured threshold. +/// Returns `Some(original_model_id)` if the switch succeeded (caller must restore afterward), +/// or `None` if no switch was needed or the switch failed. +fn maybe_switch_model_for_long_audio( + tm: &TranscriptionManager, + settings: &AppSettings, + sample_count: usize, +) -> Option { + let original_model = tm.get_current_model(); + let duration_seconds = sample_count as f32 / SAMPLE_RATE_HZ; + + let long_model_id = settings.long_audio_model.as_ref()?; + + if duration_seconds <= settings.long_audio_threshold_seconds + || original_model.as_deref() == Some(long_model_id.as_str()) + { + return None; + } + + debug!( + "Audio duration {:.1}s exceeds threshold {:.1}s, switching to long audio model: {}", + duration_seconds, settings.long_audio_threshold_seconds, long_model_id + ); + match tm.load_model(long_model_id) { + Ok(()) => original_model, + Err(e) => { + warn!( + "Failed to load long audio model '{}': {}, using current model", + long_model_id, e + ); + None + } + } +} + +/// Apply Chinese conversion, action/post-process routing, and collect the prompt used. +async fn build_processed_text( + settings: &AppSettings, + transcription: &str, + selected_action: Option, + post_process: bool, +) -> ProcessedTextResult { + let mut final_text = transcription.to_string(); + let mut post_processed_text: Option = None; + let mut post_process_prompt: Option = None; + + if let Some(converted) = maybe_convert_chinese_variant(settings, transcription).await { + final_text = converted; + } + + let processed = if let Some(ref action) = selected_action { + process_action( + settings, + &final_text, + &action.prompt, + action.model.as_deref(), + action.provider_id.as_deref(), + ) + .await + } else if post_process { + post_process_transcription(settings, &final_text).await + } else { + None + }; + + if let Some(processed_text) = processed { + final_text = processed_text.clone(); + post_processed_text = Some(processed_text); + if let Some(action) = selected_action { + post_process_prompt = Some(action.prompt); + } else if let Some(prompt_id) = &settings.post_process_selected_prompt_id { + if let Some(prompt) = settings + .post_process_prompts + .iter() + .find(|p| &p.id == prompt_id) + { + post_process_prompt = Some(prompt.prompt.clone()); + } + } + } else if final_text != transcription { + // Chinese conversion applied but no LLM post-processing + post_processed_text = Some(final_text.clone()); + } + + ProcessedTextResult { + final_text, + post_processed_text, + post_process_prompt, + } +} + +/// Spawn an async task to persist the transcription entry to history. +/// Fire-and-forget: drop the JoinHandle so history I/O never blocks transcription output. +fn spawn_save_transcription( + hm: Arc, + samples: Vec, + transcription: String, + post_processed_text: Option, + post_process_prompt: Option, + action_key: Option, +) { + let _ = tauri::async_runtime::spawn(async move { + if let Err(e) = hm + .save_transcription( + samples, + transcription, + post_processed_text, + post_process_prompt, + action_key, + ) + .await + { + error!("Failed to save transcription to history: {}", e); + } + }); +} + +/// Paste the final text on the main thread, then hide the overlay and reset the tray icon. +fn paste_transcription_on_main_thread(ah: AppHandle, final_text: String) { + let ah_clone = ah.clone(); + let paste_time = Instant::now(); + ah.run_on_main_thread(move || { + #[cfg(target_os = "macos")] + restore_frontmost_app(); + + match utils::paste(final_text, ah_clone.clone()) { + Ok(()) => debug!("Text pasted successfully in {:?}", paste_time.elapsed()), + Err(e) => error!("Failed to paste transcription: {}", e), + } + utils::hide_recording_overlay(&ah_clone); + change_tray_icon(&ah_clone, TrayIconState::Idle); + }) + .unwrap_or_else(|e| { + error!("Failed to run paste on main thread: {:?}", e); + utils::hide_recording_overlay(&ah); + change_tray_icon(&ah, TrayIconState::Idle); + }); +} + +/// Restore the model that was active before long-audio switching. +/// `original_model` is `Some(id)` only when a switch actually occurred; `None` is a no-op. +fn restore_model_after_long_audio(tm: &TranscriptionManager, original_model: Option) { + if let Some(orig_id) = original_model { + debug!("Restoring original model: {}", orig_id); + if let Err(e) = tm.load_model(&orig_id) { + warn!("Failed to restore original model '{}': {}", orig_id, e); + } + } +} + +/// Hide the recording overlay and reset the tray icon to idle. +fn reset_transcribe_ui(ah: &AppHandle) { + utils::hide_recording_overlay(ah); + change_tray_icon(ah, TrayIconState::Idle); +} + impl ShortcutAction for TranscribeAction { fn start(&self, app: &AppHandle, binding_id: &str, _shortcut_str: &str) { let start_time = Instant::now(); debug!("TranscribeAction::start called for binding: {}", binding_id); + // Save the frontmost app so we can re-activate it before pasting. + // This must happen before any overlay or window operations that could + // change the frontmost app. + #[cfg(target_os = "macos")] + save_frontmost_app(); + // Load model in the background let tm = app.state::>(); tm.initiate_model_load(); @@ -508,8 +729,7 @@ impl ShortcutAction for TranscribeAction { if recording_started { // Dynamically register the cancel shortcut in a separate task to avoid deadlock shortcut::register_cancel_shortcut(app); - // Register action shortcuts (digit keys 1-9) for configured actions - shortcut::register_action_shortcuts(app); + // Action shortcuts (Ctrl+1…9) are always-on global shortcuts registered at startup. } debug!( @@ -519,9 +739,8 @@ impl ShortcutAction for TranscribeAction { } fn stop(&self, app: &AppHandle, binding_id: &str, _shortcut_str: &str) { - // Unregister the cancel shortcut and action shortcuts when transcription stops shortcut::unregister_cancel_shortcut(app); - shortcut::unregister_action_shortcuts(app); + // Action shortcuts stay registered (always-on); no unregister needed here. let stop_time = Instant::now(); debug!("TranscribeAction::stop called for binding: {}", binding_id); @@ -536,11 +755,9 @@ impl ShortcutAction for TranscribeAction { // Unmute before playing audio feedback so the stop sound is audible rm.remove_mute(); - - // Play audio feedback for recording stop play_feedback_sound(app, SoundType::Stop); - let binding_id = binding_id.to_string(); // Clone binding_id for the async task + let binding_id = binding_id.to_string(); let post_process = self.post_process; // Read and clear the selected action before spawning the async task @@ -556,7 +773,6 @@ impl ShortcutAction for TranscribeAction { tauri::async_runtime::spawn(async move { let _guard = FinishGuard(ah.clone()); - let binding_id = binding_id.clone(); // Clone for the inner async task debug!( "Starting async transcription task for binding: {}, action: {:?}", binding_id, selected_action_key @@ -565,177 +781,78 @@ impl ShortcutAction for TranscribeAction { let stop_recording_time = Instant::now(); if let Some(samples) = rm.stop_recording(&binding_id) { debug!( - "Recording stopped and samples retrieved in {:?}, sample count: {}", + "Recording stopped in {:?}, sample count: {}", stop_recording_time.elapsed(), samples.len() ); - let duration_seconds = samples.len() as f32 / 16000.0; - let settings_for_model = get_settings(&ah); - let original_model = tm.get_current_model(); - let mut switched_model = false; - - if let Some(ref long_model_id) = settings_for_model.long_audio_model { - if duration_seconds > settings_for_model.long_audio_threshold_seconds - && original_model.as_deref() != Some(long_model_id.as_str()) - { - debug!( - "Audio duration {:.1}s exceeds threshold {:.1}s, switching to long audio model: {}", - duration_seconds, - settings_for_model.long_audio_threshold_seconds, - long_model_id - ); - if let Err(e) = tm.load_model(long_model_id) { - warn!( - "Failed to load long audio model '{}': {}, using current model", - long_model_id, e - ); - } else { - switched_model = true; - } - } - } + // Single settings snapshot for the entire pipeline — avoids TOCTOU + // between model selection and text processing. + let settings = get_settings(&ah); + let original_model = + maybe_switch_model_for_long_audio(&tm, &settings, samples.len()); + // TODO: Change TranscriptionManager::transcribe() to take &[f32] so this + // clone can be deferred to the success-and-non-empty branch. Currently + // unavoidable because transcribe() takes ownership of the audio buffer. + let samples_for_history = samples.clone(); let transcription_time = Instant::now(); - let samples_clone = samples.clone(); // Clone for history saving match tm.transcribe(samples) { - Ok(transcription) => { + Ok(transcription) if !transcription.is_empty() => { debug!( "Transcription completed in {:?}: '{}'", transcription_time.elapsed(), transcription ); - if !transcription.is_empty() { - let settings = get_settings(&ah); - let mut final_text = transcription.clone(); - let mut post_processed_text: Option = None; - let mut post_process_prompt: Option = None; - - // First, check if Chinese variant conversion is needed - if let Some(converted_text) = - maybe_convert_chinese_variant(&settings, &transcription).await - { - final_text = converted_text; - } - - let selected_action = selected_action_key.and_then(|key| { - settings - .post_process_actions - .iter() - .find(|a| a.key == key) - .cloned() - }); - - if selected_action.is_some() || post_process { - show_processing_overlay(&ah); - } - - // Action processing takes priority over default post-processing - let processed = if let Some(ref action) = selected_action { - process_action( - &settings, - &final_text, - &action.prompt, - action.model.as_deref(), - action.provider_id.as_deref(), - ) - .await - } else if post_process { - post_process_transcription(&settings, &final_text).await - } else { - None - }; - - if let Some(processed_text) = processed { - post_processed_text = Some(processed_text.clone()); - final_text = processed_text; - - if let Some(action) = selected_action { - post_process_prompt = Some(action.prompt); - } else if let Some(prompt_id) = - &settings.post_process_selected_prompt_id - { - if let Some(prompt) = settings - .post_process_prompts - .iter() - .find(|p| &p.id == prompt_id) - { - post_process_prompt = Some(prompt.prompt.clone()); - } - } - } else if final_text != transcription { - // Chinese conversion was applied but no LLM post-processing - post_processed_text = Some(final_text.clone()); - } - - // Save to history with post-processed text and prompt - let hm_clone = Arc::clone(&hm); - let transcription_for_history = transcription.clone(); - let action_key_for_history = if post_processed_text.is_some() { - selected_action_key - } else { - None - }; - tauri::async_runtime::spawn(async move { - if let Err(e) = hm_clone - .save_transcription( - samples_clone, - transcription_for_history, - post_processed_text, - post_process_prompt, - action_key_for_history, - ) - .await - { - error!("Failed to save transcription to history: {}", e); - } - }); - - // Paste the final text (either processed or original) - let ah_clone = ah.clone(); - let paste_time = Instant::now(); - ah.run_on_main_thread(move || { - match utils::paste(final_text, ah_clone.clone()) { - Ok(()) => debug!( - "Text pasted successfully in {:?}", - paste_time.elapsed() - ), - Err(e) => error!("Failed to paste transcription: {}", e), - } - // Hide the overlay after transcription is complete - utils::hide_recording_overlay(&ah_clone); - change_tray_icon(&ah_clone, TrayIconState::Idle); - }) - .unwrap_or_else(|e| { - error!("Failed to run paste on main thread: {:?}", e); - utils::hide_recording_overlay(&ah); - change_tray_icon(&ah, TrayIconState::Idle); - }); - } else { - utils::hide_recording_overlay(&ah); - change_tray_icon(&ah, TrayIconState::Idle); + let selected_action = selected_action_key.and_then(|key| { + settings + .post_process_actions + .iter() + .find(|a| a.key == key) + .cloned() + }); + + if selected_action.is_some() || post_process { + show_processing_overlay(&ah); } + + let result = build_processed_text( + &settings, + &transcription, + selected_action, + post_process, + ) + .await; + + let action_key_for_history = if result.post_processed_text.is_some() { + selected_action_key + } else { + None + }; + spawn_save_transcription( + Arc::clone(&hm), + samples_for_history, + transcription, + result.post_processed_text, + result.post_process_prompt, + action_key_for_history, + ); + paste_transcription_on_main_thread(ah.clone(), result.final_text); + } + Ok(_) => { + debug!("Transcription returned empty result"); + reset_transcribe_ui(&ah); } Err(err) => { - debug!("Global Shortcut Transcription error: {}", err); - utils::hide_recording_overlay(&ah); - change_tray_icon(&ah, TrayIconState::Idle); + error!("Transcription failed: {}", err); + reset_transcribe_ui(&ah); } } - // Restore original model if we switched for long audio - if switched_model { - if let Some(ref orig_id) = original_model { - debug!("Restoring original model: {}", orig_id); - if let Err(e) = tm.load_model(orig_id) { - warn!("Failed to restore original model '{}': {}", orig_id, e); - } - } - } + restore_model_after_long_audio(&tm, original_model); } else { debug!("No samples retrieved from recording stop"); - utils::hide_recording_overlay(&ah); - change_tray_icon(&ah, TrayIconState::Idle); + reset_transcribe_ui(&ah); } }); @@ -765,7 +882,7 @@ struct TestAction; impl ShortcutAction for TestAction { fn start(&self, app: &AppHandle, binding_id: &str, shortcut_str: &str) { log::info!( - "Shortcut ID '{}': Started - {} (App: {})", // Changed "Pressed" to "Started" for consistency + "Shortcut ID '{}': Started - {} (App: {})", binding_id, shortcut_str, app.package_info().name @@ -774,7 +891,7 @@ impl ShortcutAction for TestAction { fn stop(&self, app: &AppHandle, binding_id: &str, shortcut_str: &str) { log::info!( - "Shortcut ID '{}': Stopped - {} (App: {})", // Changed "Released" to "Stopped" for consistency + "Shortcut ID '{}': Stopped - {} (App: {})", binding_id, shortcut_str, app.package_info().name @@ -782,6 +899,42 @@ impl ShortcutAction for TestAction { } } +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn new_phraser_bundle_id_recognized() { + assert!(is_phraser_bundle_id("com.newblacc.phraser")); + } + + #[test] + fn legacy_parler_bundle_ids_still_recognized() { + assert!(is_phraser_bundle_id("com.newblacc.parler")); + assert!(is_phraser_bundle_id("com.melvynx.parler")); + } + + #[test] + fn handy_bundle_id_recognized() { + assert!(is_phraser_bundle_id("computer.handy")); + } + + #[test] + fn unrelated_bundle_id_rejected() { + assert!(!is_phraser_bundle_id("com.apple.safari")); + assert!(!is_phraser_bundle_id("com.newblacc.other")); + assert!(!is_phraser_bundle_id("")); + } + + #[test] + fn action_map_has_expected_keys() { + assert!(ACTION_MAP.contains_key("transcribe")); + assert!(ACTION_MAP.contains_key("transcribe_with_post_process")); + assert!(ACTION_MAP.contains_key("cancel")); + assert!(ACTION_MAP.contains_key("test")); + } +} + // Static Action Map pub static ACTION_MAP: Lazy>> = Lazy::new(|| { let mut map = HashMap::new(); diff --git a/src-tauri/src/audio_toolkit/audio/resampler.rs b/src-tauri/src/audio_toolkit/audio/resampler.rs index 149d99ba9..b2806e016 100644 --- a/src-tauri/src/audio_toolkit/audio/resampler.rs +++ b/src-tauri/src/audio_toolkit/audio/resampler.rs @@ -47,15 +47,12 @@ impl FrameResampler { src = &src[take..]; if self.in_buf.len() == self.chunk_in { - // let start = std::time::Instant::now(); if let Ok(out) = self .resampler .as_mut() .unwrap() .process(&[&self.in_buf[..]], None) { - // let duration = start.elapsed(); - // log::debug!("Resampler took: {:?}", duration); self.emit_frames(&out[0], &mut emit); } self.in_buf.clear(); @@ -97,3 +94,100 @@ impl FrameResampler { } } } + +#[cfg(test)] +mod tests { + use super::*; + + /// 30ms at 16kHz = 480 samples + const FRAME_SAMPLES_16K_30MS: usize = 480; + + #[test] + fn passthrough_when_same_sample_rate() { + let mut resampler = FrameResampler::new(16000, 16000, Duration::from_millis(30)); + let input: Vec = (0..FRAME_SAMPLES_16K_30MS) + .map(|i| i as f32 / FRAME_SAMPLES_16K_30MS as f32) + .collect(); + + let mut frames = Vec::new(); + resampler.push(&input, |f| frames.push(f.to_vec())); + + assert_eq!(frames.len(), 1); + assert_eq!(frames[0].len(), FRAME_SAMPLES_16K_30MS); + assert_eq!(frames[0], input); + } + + #[test] + fn passthrough_emits_multiple_frames() { + let mut resampler = FrameResampler::new(16000, 16000, Duration::from_millis(30)); + let input: Vec = vec![0.5; FRAME_SAMPLES_16K_30MS * 3]; + + let mut frames = Vec::new(); + resampler.push(&input, |f| frames.push(f.to_vec())); + + assert_eq!(frames.len(), 3); + for frame in &frames { + assert_eq!(frame.len(), FRAME_SAMPLES_16K_30MS); + } + } + + #[test] + fn passthrough_partial_frame_buffered() { + let mut resampler = FrameResampler::new(16000, 16000, Duration::from_millis(30)); + let input: Vec = vec![0.1; FRAME_SAMPLES_16K_30MS / 2]; + + let mut frames = Vec::new(); + resampler.push(&input, |f| frames.push(f.to_vec())); + + assert_eq!(frames.len(), 0); + } + + #[test] + fn finish_emits_padded_remaining() { + let mut resampler = FrameResampler::new(16000, 16000, Duration::from_millis(30)); + let partial_len = FRAME_SAMPLES_16K_30MS / 2; + let input: Vec = vec![0.7; partial_len]; + + let mut frames = Vec::new(); + resampler.push(&input, |f| frames.push(f.to_vec())); + assert_eq!(frames.len(), 0); + + resampler.finish(|f| frames.push(f.to_vec())); + assert_eq!(frames.len(), 1); + assert_eq!(frames[0].len(), FRAME_SAMPLES_16K_30MS); + for &s in &frames[0][..partial_len] { + assert!((s - 0.7).abs() < 1e-6); + } + for &s in &frames[0][partial_len..] { + assert!(s.abs() < 1e-6); + } + } + + #[test] + fn finish_noop_when_no_pending() { + let mut resampler = FrameResampler::new(16000, 16000, Duration::from_millis(30)); + let input: Vec = vec![0.5; FRAME_SAMPLES_16K_30MS]; + + let mut frames = Vec::new(); + resampler.push(&input, |f| frames.push(f.to_vec())); + assert_eq!(frames.len(), 1); + + resampler.finish(|f| frames.push(f.to_vec())); + assert_eq!(frames.len(), 1); + } + + #[test] + fn resampling_produces_output() { + let mut resampler = FrameResampler::new(48000, 16000, Duration::from_millis(30)); + let input: Vec = vec![0.0; 48000]; // 1 second at 48kHz + + let mut frames = Vec::new(); + resampler.push(&input, |f| frames.push(f.to_vec())); + resampler.finish(|f| frames.push(f.to_vec())); + + assert!(!frames.is_empty(), "Resampler should produce output frames"); + for frame in &frames { + assert_eq!(frame.len(), FRAME_SAMPLES_16K_30MS); + } + } +} diff --git a/src-tauri/src/audio_toolkit/audio/utils.rs b/src-tauri/src/audio_toolkit/audio/utils.rs index 087d2b0bc..546591cc8 100644 --- a/src-tauri/src/audio_toolkit/audio/utils.rs +++ b/src-tauri/src/audio_toolkit/audio/utils.rs @@ -31,7 +31,7 @@ pub async fn save_wav_file>(file_path: P, samples: &[f32]) -> Res // Convert f32 samples to i16 for WAV for sample in samples { - let sample_i16 = (sample * i16::MAX as f32) as i16; + let sample_i16 = (sample.clamp(-1.0, 1.0) * i16::MAX as f32) as i16; writer.write_sample(sample_i16)?; } @@ -39,3 +39,62 @@ pub async fn save_wav_file>(file_path: P, samples: &[f32]) -> Res debug!("Saved WAV file: {:?}", file_path.as_ref()); Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn wav_round_trip_preserves_samples() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("test.wav"); + + let original: Vec = vec![0.0, 0.5, -0.5, 0.25, -0.25, 1.0, -1.0]; + save_wav_file(&path, &original).await.unwrap(); + + let loaded = load_wav_file(&path).unwrap(); + assert_eq!(loaded.len(), original.len()); + + // Due to f32->i16->f32 conversion there will be quantization error + // i16 has ~15-bit precision, so error should be < 1/32768 ≈ 3e-5 + for (orig, loaded) in original.iter().zip(loaded.iter()) { + assert!( + (orig - loaded).abs() < 0.001, + "Sample mismatch: original={}, loaded={}", + orig, + loaded + ); + } + } + + #[tokio::test] + async fn wav_round_trip_empty_samples() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("empty.wav"); + + save_wav_file(&path, &[]).await.unwrap(); + let loaded = load_wav_file(&path).unwrap(); + assert!(loaded.is_empty()); + } + + #[tokio::test] + async fn wav_round_trip_silence() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("silence.wav"); + + let silence: Vec = vec![0.0; 16000]; // 1 second of silence + save_wav_file(&path, &silence).await.unwrap(); + + let loaded = load_wav_file(&path).unwrap(); + assert_eq!(loaded.len(), 16000); + for sample in &loaded { + assert!((sample.abs()) < 1e-5); + } + } + + #[test] + fn load_nonexistent_file_returns_error() { + let result = load_wav_file("/nonexistent/path/file.wav"); + assert!(result.is_err()); + } +} diff --git a/src-tauri/src/audio_toolkit/bin/cli.rs b/src-tauri/src/audio_toolkit/bin/cli.rs index 0a68ce1a6..ab765792f 100644 --- a/src-tauri/src/audio_toolkit/bin/cli.rs +++ b/src-tauri/src/audio_toolkit/bin/cli.rs @@ -1,7 +1,7 @@ use hound::WavWriter; use std::io::{self, Write}; -use parler_app_lib::audio_toolkit::{ +use phraser_app_lib::audio_toolkit::{ audio::{list_input_devices, CpalDeviceInfo}, vad::SmoothedVad, AudioRecorder, SileroVad, diff --git a/src-tauri/src/audio_toolkit/vad/smoothed.rs b/src-tauri/src/audio_toolkit/vad/smoothed.rs index c0e461627..a3c1e136a 100644 --- a/src-tauri/src/audio_toolkit/vad/smoothed.rs +++ b/src-tauri/src/audio_toolkit/vad/smoothed.rs @@ -103,3 +103,149 @@ impl VoiceActivityDetector for SmoothedVad { self.temp_out.clear(); } } + +#[cfg(test)] +mod tests { + use super::*; + + /// A mock VAD that returns a pre-programmed sequence of speech/noise decisions. + struct MockVad { + responses: Vec, + index: usize, + } + + impl MockVad { + fn new(responses: Vec) -> Self { + Self { + responses, + index: 0, + } + } + } + + impl VoiceActivityDetector for MockVad { + fn push_frame<'a>(&'a mut self, frame: &'a [f32]) -> Result> { + let is_speech = self.responses.get(self.index).copied().unwrap_or(false); + self.index += 1; + if is_speech { + Ok(VadFrame::Speech(frame)) + } else { + Ok(VadFrame::Noise) + } + } + } + + fn make_frame(value: f32) -> Vec { + vec![value; 480] // 30ms at 16kHz + } + + #[test] + fn silence_returns_noise() { + // All frames are silence + let mock = MockVad::new(vec![false, false, false]); + let mut vad = SmoothedVad::new(Box::new(mock), 2, 2, 2); + + for _ in 0..3 { + let frame = make_frame(0.0); + let result = vad.push_frame(&frame).unwrap(); + assert!(!result.is_speech()); + } + } + + #[test] + fn onset_requires_consecutive_frames() { + // onset_frames = 3, so we need 3 consecutive voice frames before speech is declared + let mock = MockVad::new(vec![true, true, true, true]); + let mut vad = SmoothedVad::new(Box::new(mock), 0, 2, 3); + + let frame = make_frame(0.5); + + // Frame 1: voice detected but onset counter only at 1 + assert!(!vad.push_frame(&frame).unwrap().is_speech()); + // Frame 2: onset counter at 2 + assert!(!vad.push_frame(&frame).unwrap().is_speech()); + // Frame 3: onset counter reaches 3, speech starts + assert!(vad.push_frame(&frame).unwrap().is_speech()); + // Frame 4: ongoing speech + assert!(vad.push_frame(&frame).unwrap().is_speech()); + } + + #[test] + fn onset_resets_on_silence() { + // Voice, voice, silence, voice — onset should reset + let mock = MockVad::new(vec![true, true, false, true, true, true]); + let mut vad = SmoothedVad::new(Box::new(mock), 0, 0, 3); + + let frame = make_frame(0.5); + + assert!(!vad.push_frame(&frame).unwrap().is_speech()); // onset 1 + assert!(!vad.push_frame(&frame).unwrap().is_speech()); // onset 2 + assert!(!vad.push_frame(&frame).unwrap().is_speech()); // silence, resets onset + assert!(!vad.push_frame(&frame).unwrap().is_speech()); // onset 1 again + assert!(!vad.push_frame(&frame).unwrap().is_speech()); // onset 2 + assert!(vad.push_frame(&frame).unwrap().is_speech()); // onset 3 = speech! + } + + #[test] + fn hangover_keeps_speech_during_silence_gap() { + // Speech starts, then 2 silence frames, hangover=3 should keep speech + let mock = MockVad::new(vec![true, true, false, false, false, false]); + let mut vad = SmoothedVad::new(Box::new(mock), 0, 3, 2); + + let frame = make_frame(0.5); + + assert!(!vad.push_frame(&frame).unwrap().is_speech()); // onset 1 + assert!(vad.push_frame(&frame).unwrap().is_speech()); // onset 2 = speech + // Now silence, but hangover should keep us in speech for 3 frames + assert!(vad.push_frame(&frame).unwrap().is_speech()); // hangover 2 + assert!(vad.push_frame(&frame).unwrap().is_speech()); // hangover 1 + assert!(vad.push_frame(&frame).unwrap().is_speech()); // hangover 0 + // Now hangover exhausted + assert!(!vad.push_frame(&frame).unwrap().is_speech()); + } + + #[test] + fn prefill_includes_buffered_frames() { + // prefill=2: when speech triggers, we should get pre-roll frames + let mock = MockVad::new(vec![false, false, true, true]); + let mut vad = SmoothedVad::new(Box::new(mock), 2, 0, 2); + + let silence_frame = make_frame(0.0); + let voice_frame = make_frame(0.8); + + // Two silence frames get buffered + vad.push_frame(&silence_frame).unwrap(); + vad.push_frame(&silence_frame).unwrap(); + + // First voice frame: onset 1 + assert!(!vad.push_frame(&voice_frame).unwrap().is_speech()); + + // Second voice frame: onset 2 = speech with prefill + let result = vad.push_frame(&voice_frame).unwrap(); + assert!(result.is_speech()); + if let VadFrame::Speech(data) = result { + // Should contain prefill + onset frames + // prefill has 2 silence + 1 onset voice + current = 4 frames buffered, + // but prefill_frames + 1 max in buffer = 3 frames + assert!(data.len() >= voice_frame.len()); + } + } + + #[test] + fn reset_clears_state() { + let mock = MockVad::new(vec![true, true, true, false]); + let mut vad = SmoothedVad::new(Box::new(mock), 2, 2, 1); + + let frame = make_frame(0.5); + vad.push_frame(&frame).unwrap(); // triggers speech (onset=1) + + vad.reset(); + + // After reset, internal state is cleared — we can't push more + // because the mock is exhausted, but the state should be clean + assert!(!vad.in_speech); + assert_eq!(vad.onset_counter, 0); + assert_eq!(vad.hangover_counter, 0); + assert!(vad.frame_buffer.is_empty()); + } +} diff --git a/src-tauri/src/cli.rs b/src-tauri/src/cli.rs index bb0a5782c..b7b109ea5 100644 --- a/src-tauri/src/cli.rs +++ b/src-tauri/src/cli.rs @@ -1,7 +1,7 @@ use clap::Parser; #[derive(Parser, Debug, Clone, Default)] -#[command(name = "parler", about = "Parler - Speech to Text")] +#[command(name = "phraser", about = "Phraser - Speech to Text")] pub struct CliArgs { /// Start with the main window hidden #[arg(long)] @@ -27,3 +27,53 @@ pub struct CliArgs { #[arg(long)] pub debug: bool, } + +#[cfg(test)] +mod tests { + use super::*; + use clap::CommandFactory; + + #[test] + fn command_name_is_phraser() { + let cmd = CliArgs::command(); + assert_eq!(cmd.get_name(), "phraser"); + } + + #[test] + fn about_contains_phraser() { + let cmd = CliArgs::command(); + let about = cmd.get_about().map(|a| a.to_string()).unwrap_or_default(); + assert!( + about.contains("Phraser"), + "CLI about text should contain 'Phraser', got: {}", + about + ); + } + + #[test] + fn default_has_all_flags_false() { + let args = CliArgs::default(); + assert!(!args.start_hidden); + assert!(!args.no_tray); + assert!(!args.toggle_transcription); + assert!(!args.toggle_post_process); + assert!(!args.cancel); + assert!(!args.debug); + } + + #[test] + fn parses_toggle_transcription() { + let args = CliArgs::parse_from(["phraser", "--toggle-transcription"]); + assert!(args.toggle_transcription); + assert!(!args.toggle_post_process); + } + + #[test] + fn parses_multiple_flags() { + let args = CliArgs::parse_from(["phraser", "--start-hidden", "--no-tray", "--debug"]); + assert!(args.start_hidden); + assert!(args.no_tray); + assert!(args.debug); + assert!(!args.toggle_transcription); + } +} diff --git a/src-tauri/src/clipboard.rs b/src-tauri/src/clipboard.rs index 57972cb02..0f2df7b2f 100644 --- a/src-tauri/src/clipboard.rs +++ b/src-tauri/src/clipboard.rs @@ -5,6 +5,8 @@ use crate::settings::{get_settings, AutoSubmitKey, ClipboardHandling, PasteMetho use enigo::{Direction, Enigo, Key, Keyboard}; use log::info; use std::process::Command; +#[cfg(target_os = "linux")] +use std::sync::OnceLock; use std::time::Duration; use tauri::{AppHandle, Manager}; use tauri_plugin_clipboard_manager::ClipboardExt; @@ -12,8 +14,36 @@ use tauri_plugin_clipboard_manager::ClipboardExt; #[cfg(target_os = "linux")] use crate::utils::{is_kde_wayland, is_wayland}; -/// Pastes text using the clipboard: saves current content, writes text, sends paste keystroke, restores clipboard. -fn paste_via_clipboard( +/// Time (ms) to wait after sending the paste keystroke before restoring the clipboard. +/// Electron-based apps (Claude Desktop, VS Code, Slack) process paste asynchronously, +/// so they need more time than native apps to read clipboard contents. +const ELECTRON_CLIPBOARD_SETTLE_MS: u64 = 250; + +/// Time (ms) to wait after AppleScript paste before restoring the clipboard. +const APPLESCRIPT_CLIPBOARD_SETTLE_MS: u64 = 300; + +/// Release common modifier keys before paste/submit. +/// This avoids shortcut-modifier bleed-through (e.g. Option still held from Option+Space). +fn release_modifier_keys(enigo: &mut Enigo) { + let _ = enigo.key(Key::Shift, Direction::Release); + let _ = enigo.key(Key::Alt, Direction::Release); + let _ = enigo.key(Key::Control, Direction::Release); + let _ = enigo.key(Key::Meta, Direction::Release); +} + +/// Restore the original clipboard content. Called unconditionally — on both success and error paths. +fn restore_clipboard(app_handle: &AppHandle, content: &str) { + #[cfg(target_os = "linux")] + if is_wayland() && is_wl_copy_available() { + let _ = write_clipboard_via_wl_copy(content); + return; + } + let _ = app_handle.clipboard().write_text(content); +} + +/// Inner implementation: writes text to clipboard and sends the paste keystroke. +/// Does NOT restore the clipboard — `paste_via_clipboard` handles that unconditionally. +fn do_paste_via_clipboard( enigo: &mut Enigo, text: &str, app_handle: &AppHandle, @@ -21,10 +51,9 @@ fn paste_via_clipboard( paste_delay_ms: u64, ) -> Result<(), String> { let clipboard = app_handle.clipboard(); - let clipboard_content = clipboard.read_text().unwrap_or_default(); - // Write text to clipboard first - // On Wayland, prefer wl-copy for better compatibility (especially with umlauts) + // Write text to clipboard first. + // On Wayland, prefer wl-copy for better compatibility (especially with umlauts). #[cfg(target_os = "linux")] let write_result = if is_wayland() && is_wl_copy_available() { info!("Using wl-copy for clipboard write on Wayland"); @@ -61,23 +90,30 @@ fn paste_via_clipboard( } } - std::thread::sleep(std::time::Duration::from_millis(50)); - - // Restore original clipboard content - // On Wayland, prefer wl-copy for better compatibility - #[cfg(target_os = "linux")] - if is_wayland() && is_wl_copy_available() { - let _ = write_clipboard_via_wl_copy(&clipboard_content); - } else { - let _ = clipboard.write_text(&clipboard_content); - } - - #[cfg(not(target_os = "linux"))] - let _ = clipboard.write_text(&clipboard_content); + // Allow enough time for the target app to process the paste event and read + // the clipboard. Electron-based apps (e.g. Claude Desktop, VS Code, Slack) + // process paste asynchronously through their event loop. + std::thread::sleep(Duration::from_millis(ELECTRON_CLIPBOARD_SETTLE_MS)); Ok(()) } +/// Pastes text using the clipboard: saves current content, writes text, sends paste keystroke, +/// then restores the original clipboard content regardless of whether the paste succeeded. +fn paste_via_clipboard( + enigo: &mut Enigo, + text: &str, + app_handle: &AppHandle, + paste_method: &PasteMethod, + paste_delay_ms: u64, +) -> Result<(), String> { + let clipboard_content = app_handle.clipboard().read_text().unwrap_or_default(); + let result = do_paste_via_clipboard(enigo, text, app_handle, paste_method, paste_delay_ms); + // Always restore original clipboard content, even if paste failed. + restore_clipboard(app_handle, &clipboard_content); + result +} + /// Attempts to send a key combination using Linux-native tools. /// Returns `Ok(true)` if a native tool handled it, `Ok(false)` to fall back to enigo. #[cfg(target_os = "linux")] @@ -221,63 +257,51 @@ pub fn get_available_typing_tools() -> Vec { tools } -/// Check if wtype is available (Wayland text input tool) +/// Check if a CLI tool is available on PATH. Result is cached for the process lifetime +/// — tool availability is static; re-probing on every paste wastes subprocess spawns. #[cfg(target_os = "linux")] -fn is_wtype_available() -> bool { +fn probe_tool(name: &str) -> bool { Command::new("which") - .arg("wtype") + .arg(name) .output() .map(|output| output.status.success()) .unwrap_or(false) } -/// Check if dotool is available (another Wayland text input tool) +#[cfg(target_os = "linux")] +fn is_wtype_available() -> bool { + static CACHE: OnceLock = OnceLock::new(); + *CACHE.get_or_init(|| probe_tool("wtype")) +} + #[cfg(target_os = "linux")] fn is_dotool_available() -> bool { - Command::new("which") - .arg("dotool") - .output() - .map(|output| output.status.success()) - .unwrap_or(false) + static CACHE: OnceLock = OnceLock::new(); + *CACHE.get_or_init(|| probe_tool("dotool")) } -/// Check if ydotool is available (uinput-based, works on both Wayland and X11) #[cfg(target_os = "linux")] fn is_ydotool_available() -> bool { - Command::new("which") - .arg("ydotool") - .output() - .map(|output| output.status.success()) - .unwrap_or(false) + static CACHE: OnceLock = OnceLock::new(); + *CACHE.get_or_init(|| probe_tool("ydotool")) } #[cfg(target_os = "linux")] fn is_xdotool_available() -> bool { - Command::new("which") - .arg("xdotool") - .output() - .map(|output| output.status.success()) - .unwrap_or(false) + static CACHE: OnceLock = OnceLock::new(); + *CACHE.get_or_init(|| probe_tool("xdotool")) } -/// Check if kwtype is available (KDE Wayland virtual keyboard input tool) #[cfg(target_os = "linux")] fn is_kwtype_available() -> bool { - Command::new("which") - .arg("kwtype") - .output() - .map(|output| output.status.success()) - .unwrap_or(false) + static CACHE: OnceLock = OnceLock::new(); + *CACHE.get_or_init(|| probe_tool("kwtype")) } -/// Check if wl-copy is available (Wayland clipboard tool) #[cfg(target_os = "linux")] fn is_wl_copy_available() -> bool { - Command::new("which") - .arg("wl-copy") - .output() - .map(|output| output.status.success()) - .unwrap_or(false) + static CACHE: OnceLock = OnceLock::new(); + *CACHE.get_or_init(|| probe_tool("wl-copy")) } /// Type text directly via wtype on Wayland. @@ -328,8 +352,10 @@ fn type_text_via_dotool(text: &str) -> Result<(), String> { .map_err(|e| format!("Failed to spawn dotool: {}", e))?; if let Some(mut stdin) = child.stdin.take() { - // dotool uses "type " command - writeln!(stdin, "type {}", text) + // dotool's "type" command is line-oriented: \n terminates the command. + // \r can also produce unexpected output. Replace both with a space. + let safe_text = text.replace(['\n', '\r'], " "); + writeln!(stdin, "type {}", safe_text) .map_err(|e| format!("Failed to write to dotool stdin: {}", e))?; } @@ -427,21 +453,32 @@ fn send_key_combo_via_wtype(paste_method: &PasteMethod) -> Result<(), String> { /// Send a key combination (e.g., Ctrl+V) via dotool. #[cfg(target_os = "linux")] fn send_key_combo_via_dotool(paste_method: &PasteMethod) -> Result<(), String> { - let command; - match paste_method { - PasteMethod::CtrlV => command = "echo key ctrl+v | dotool", - PasteMethod::ShiftInsert => command = "echo key shift+insert | dotool", - PasteMethod::CtrlShiftV => command = "echo key ctrl+shift+v | dotool", - _ => return Err("Unsupported paste method".into()), - } + use std::io::Write; use std::process::Stdio; - let status = Command::new("sh") - .arg("-c") - .arg(command) + + let key_cmd = match paste_method { + PasteMethod::CtrlV => "key ctrl+v", + PasteMethod::ShiftInsert => "key shift+insert", + PasteMethod::CtrlShiftV => "key ctrl+shift+v", + _ => return Err("Unsupported paste method".into()), + }; + + let mut child = Command::new("dotool") + .stdin(Stdio::piped()) .stdout(Stdio::null()) .stderr(Stdio::null()) - .status() - .map_err(|e| format!("Failed to execute dotool: {}", e))?; + .spawn() + .map_err(|e| format!("Failed to spawn dotool: {}", e))?; + + if let Some(mut stdin) = child.stdin.take() { + writeln!(stdin, "{}", key_cmd) + .map_err(|e| format!("Failed to write to dotool stdin: {}", e))?; + } + + let status = child + .wait() + .map_err(|e| format!("Failed to wait for dotool: {}", e))?; + if !status.success() { return Err("dotool failed".into()); } @@ -542,25 +579,21 @@ fn paste_direct( } fn send_return_key(enigo: &mut Enigo, key_type: AutoSubmitKey) -> Result<(), String> { + info!("Auto-submit using {:?}", key_type); + match key_type { AutoSubmitKey::Enter => { enigo - .key(Key::Return, Direction::Press) - .map_err(|e| format!("Failed to press Return key: {}", e))?; - enigo - .key(Key::Return, Direction::Release) - .map_err(|e| format!("Failed to release Return key: {}", e))?; + .key(Key::Return, Direction::Click) + .map_err(|e| format!("Failed to click Return key: {}", e))?; } AutoSubmitKey::CtrlEnter => { enigo .key(Key::Control, Direction::Press) .map_err(|e| format!("Failed to press Control key: {}", e))?; enigo - .key(Key::Return, Direction::Press) - .map_err(|e| format!("Failed to press Return key: {}", e))?; - enigo - .key(Key::Return, Direction::Release) - .map_err(|e| format!("Failed to release Return key: {}", e))?; + .key(Key::Return, Direction::Click) + .map_err(|e| format!("Failed to click Return key: {}", e))?; enigo .key(Key::Control, Direction::Release) .map_err(|e| format!("Failed to release Control key: {}", e))?; @@ -570,11 +603,8 @@ fn send_return_key(enigo: &mut Enigo, key_type: AutoSubmitKey) -> Result<(), Str .key(Key::Meta, Direction::Press) .map_err(|e| format!("Failed to press Meta/Cmd key: {}", e))?; enigo - .key(Key::Return, Direction::Press) - .map_err(|e| format!("Failed to press Return key: {}", e))?; - enigo - .key(Key::Return, Direction::Release) - .map_err(|e| format!("Failed to release Return key: {}", e))?; + .key(Key::Return, Direction::Click) + .map_err(|e| format!("Failed to click Return key: {}", e))?; enigo .key(Key::Meta, Direction::Release) .map_err(|e| format!("Failed to release Meta/Cmd key: {}", e))?; @@ -588,6 +618,90 @@ fn should_send_auto_submit(auto_submit: bool, paste_method: PasteMethod) -> bool auto_submit && paste_method != PasteMethod::None } +/// Inner implementation of AppleScript paste — writes text to clipboard and fires the keystroke. +/// Does NOT restore the clipboard — `paste_and_submit_via_applescript` handles that unconditionally. +#[cfg(target_os = "macos")] +fn do_paste_via_applescript( + text: &str, + app_handle: &AppHandle, + auto_submit: bool, + auto_submit_key: AutoSubmitKey, +) -> Result<(), String> { + // Write transcription to clipboard + app_handle + .clipboard() + .write_text(text) + .map_err(|e| format!("Failed to write to clipboard: {}", e))?; + + // Small delay so the pasteboard change propagates + std::thread::sleep(Duration::from_millis(50)); + + // Build an AppleScript that sends Cmd+V, waits, then optionally presses Return + let submit_part = if auto_submit { + let key_script = match auto_submit_key { + AutoSubmitKey::Enter => { + r#"delay 0.15 + keystroke return"# + } + AutoSubmitKey::CtrlEnter => { + r#"delay 0.15 + keystroke return using control down"# + } + AutoSubmitKey::CmdEnter => { + r#"delay 0.15 + keystroke return using command down"# + } + }; + key_script.to_string() + } else { + String::new() + }; + + let script = format!( + r#"tell application "System Events" + keystroke "v" using command down + {} + end tell"#, + submit_part + ); + + info!("Pasting via AppleScript (System Events)"); + + let output = Command::new("osascript") + .args(["-e", &script]) + .output() + .map_err(|e| format!("Failed to run osascript: {}", e))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(format!("AppleScript paste failed: {}", stderr)); + } + + // Wait for the target app to fully consume the clipboard before caller restores it. + std::thread::sleep(Duration::from_millis(APPLESCRIPT_CLIPBOARD_SETTLE_MS)); + + Ok(()) +} + +/// Paste and optionally auto-submit using AppleScript's System Events. +/// This is significantly more reliable for Electron apps (Claude Desktop, +/// VS Code, Slack, etc.) because it goes through the macOS accessibility +/// framework at a higher level than CGEvents / enigo. +/// Restores original clipboard content regardless of whether the paste succeeded. +#[cfg(target_os = "macos")] +fn paste_and_submit_via_applescript( + text: &str, + app_handle: &AppHandle, + auto_submit: bool, + auto_submit_key: AutoSubmitKey, +) -> Result<(), String> { + let clipboard_content = app_handle.clipboard().read_text().unwrap_or_default(); + let result = do_paste_via_applescript(text, app_handle, auto_submit, auto_submit_key); + // Always restore original clipboard content, even if the paste failed. + restore_clipboard(app_handle, &clipboard_content); + result +} + pub fn paste(text: String, app_handle: AppHandle) -> Result<(), String> { let settings = get_settings(&app_handle); let paste_method = settings.paste_method; @@ -605,6 +719,26 @@ pub fn paste(text: String, app_handle: AppHandle) -> Result<(), String> { paste_method, paste_delay_ms ); + // On macOS, use AppleScript for Cmd+V pastes — it is far more reliable + // for Electron apps (Claude Desktop, etc.) than CGEvent-based input. + #[cfg(target_os = "macos")] + if paste_method == PasteMethod::CtrlV { + let result = paste_and_submit_via_applescript( + &text, + &app_handle, + settings.auto_submit, + settings.auto_submit_key, + ); + + // After pasting, optionally copy to clipboard based on settings + if settings.clipboard_handling == ClipboardHandling::CopyToClipboard { + let clipboard = app_handle.clipboard(); + let _ = clipboard.write_text(&text); + } + + return result; + } + // Get the managed Enigo instance let enigo_state = app_handle .try_state::() @@ -614,6 +748,9 @@ pub fn paste(text: String, app_handle: AppHandle) -> Result<(), String> { .lock() .map_err(|e| format!("Failed to lock Enigo: {}", e))?; + // Prevent current shortcut modifiers from affecting paste/submit keystrokes. + release_modifier_keys(&mut enigo); + // Perform the paste operation match paste_method { PasteMethod::None => { @@ -647,7 +784,10 @@ pub fn paste(text: String, app_handle: AppHandle) -> Result<(), String> { } if should_send_auto_submit(settings.auto_submit, paste_method) { - std::thread::sleep(Duration::from_millis(50)); + // Wait for the target app to fully process the pasted text before + // sending the submit key. Electron-based apps (Claude Desktop, etc.) + // need extra time to render the pasted content in their input field. + std::thread::sleep(Duration::from_millis(150)); send_return_key(&mut enigo, settings.auto_submit_key)?; } @@ -684,4 +824,24 @@ mod tests { assert!(should_send_auto_submit(true, PasteMethod::CtrlShiftV)); assert!(should_send_auto_submit(true, PasteMethod::ShiftInsert)); } + + /// Dotool stdin injection fix: newlines in transcription text must be replaced with spaces + /// so they don't create a second command in dotool's line-oriented protocol. + #[test] + fn dotool_newline_in_text_is_replaced_with_space() { + let text = "hello\nworld"; + let safe_text = text.replace('\n', " "); + assert_eq!(safe_text, "hello world"); + assert!(!safe_text.contains('\n')); + } + + #[test] + fn dotool_carriage_return_is_also_replaced() { + // \r can cause unexpected output in dotool; both \n and \r must be sanitized. + let text = "line one\r\nline two\rline three"; + let safe = text.replace(['\n', '\r'], " "); + assert!(!safe.contains('\n')); + assert!(!safe.contains('\r')); + assert_eq!(safe, "line one line two line three"); + } } diff --git a/src-tauri/src/commands/history.rs b/src-tauri/src/commands/history.rs index c2edaf6e3..3abbb55bd 100644 --- a/src-tauri/src/commands/history.rs +++ b/src-tauri/src/commands/history.rs @@ -3,6 +3,27 @@ use crate::managers::transcription::TranscriptionManager; use std::sync::Arc; use tauri::{AppHandle, State}; +fn path_to_string(path: &std::path::Path) -> Result { + path.to_str() + .ok_or_else(|| "Invalid file path".to_string()) + .map(|s| s.to_string()) +} + +fn parse_recording_retention_period( + period: &str, +) -> Result { + use crate::settings::RecordingRetentionPeriod; + + match period { + "never" => Ok(RecordingRetentionPeriod::Never), + "preserve_limit" => Ok(RecordingRetentionPeriod::PreserveLimit), + "days3" => Ok(RecordingRetentionPeriod::Days3), + "weeks2" => Ok(RecordingRetentionPeriod::Weeks2), + "months3" => Ok(RecordingRetentionPeriod::Months3), + _ => Err(format!("Invalid retention period: {}", period)), + } +} + #[tauri::command] #[specta::specta] pub async fn get_history_entries( @@ -35,10 +56,10 @@ pub async fn get_audio_file_path( history_manager: State<'_, Arc>, file_name: String, ) -> Result { - let path = history_manager.get_audio_file_path(&file_name); - path.to_str() - .ok_or_else(|| "Invalid file path".to_string()) - .map(|s| s.to_string()) + let path = history_manager + .get_audio_file_path(&file_name) + .map_err(|e| e.to_string())?; + path_to_string(&path) } #[tauri::command] @@ -87,7 +108,9 @@ pub async fn reprocess_history_entry( .map_err(|e| e.to_string())? .ok_or_else(|| "History entry not found".to_string())?; - let audio_path = history_manager.get_audio_file_path(&entry.file_name); + let audio_path = history_manager + .get_audio_file_path(&entry.file_name) + .map_err(|e| e.to_string())?; if !audio_path.exists() { return Err("Audio file not found".to_string()); } @@ -124,16 +147,7 @@ pub async fn update_recording_retention_period( history_manager: State<'_, Arc>, period: String, ) -> Result<(), String> { - use crate::settings::RecordingRetentionPeriod; - - let retention_period = match period.as_str() { - "never" => RecordingRetentionPeriod::Never, - "preserve_limit" => RecordingRetentionPeriod::PreserveLimit, - "days3" => RecordingRetentionPeriod::Days3, - "weeks2" => RecordingRetentionPeriod::Weeks2, - "months3" => RecordingRetentionPeriod::Months3, - _ => return Err(format!("Invalid retention period: {}", period)), - }; + let retention_period = parse_recording_retention_period(period.as_str())?; let mut settings = crate::settings::get_settings(&app); settings.recording_retention_period = retention_period; @@ -145,3 +159,57 @@ pub async fn update_recording_retention_period( Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_recording_retention_period_accepts_valid_values() { + assert!(matches!( + parse_recording_retention_period("never"), + Ok(crate::settings::RecordingRetentionPeriod::Never) + )); + assert!(matches!( + parse_recording_retention_period("preserve_limit"), + Ok(crate::settings::RecordingRetentionPeriod::PreserveLimit) + )); + assert!(matches!( + parse_recording_retention_period("days3"), + Ok(crate::settings::RecordingRetentionPeriod::Days3) + )); + assert!(matches!( + parse_recording_retention_period("weeks2"), + Ok(crate::settings::RecordingRetentionPeriod::Weeks2) + )); + assert!(matches!( + parse_recording_retention_period("months3"), + Ok(crate::settings::RecordingRetentionPeriod::Months3) + )); + } + + #[test] + fn parse_recording_retention_period_rejects_invalid_value() { + assert_eq!( + parse_recording_retention_period("invalid"), + Err("Invalid retention period: invalid".to_string()) + ); + } + + #[test] + fn path_to_string_returns_string_for_valid_utf8_path() { + let path = std::path::Path::new("/tmp/file.wav"); + assert_eq!(path_to_string(path), Ok("/tmp/file.wav".to_string())); + } + + #[cfg(unix)] + #[test] + fn path_to_string_rejects_non_utf8_path() { + use std::ffi::OsString; + use std::os::unix::ffi::OsStringExt; + + let non_utf8 = OsString::from_vec(vec![0x66, 0x6f, 0x80]); + let path = std::path::PathBuf::from(non_utf8); + assert_eq!(path_to_string(&path), Err("Invalid file path".to_string())); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 7ea3cfd86..f80656bb4 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -385,7 +385,7 @@ pub fn run(cli_args: CliArgs) { }), // File logs respect the user's settings (stored in FILE_LOG_LEVEL atomic) Target::new(TargetKind::LogDir { - file_name: Some("parler".into()), + file_name: Some("phraser".into()), }) .filter(|metadata| { let file_level = FILE_LOG_LEVEL.load(Ordering::Relaxed); diff --git a/src-tauri/src/llm_client.rs b/src-tauri/src/llm_client.rs index 01d150433..2950b37e8 100644 --- a/src-tauri/src/llm_client.rs +++ b/src-tauri/src/llm_client.rs @@ -1,8 +1,14 @@ -use crate::settings::PostProcessProvider; +use crate::settings::{PostProcessProvider, PROVIDER_ID_ANTHROPIC, PROVIDER_ID_GEMINI}; use log::debug; use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION, CONTENT_TYPE, REFERER, USER_AGENT}; use serde::{Deserialize, Serialize}; use serde_json::Value; +use std::time::Duration; + +const REQUEST_TIMEOUT_SECS: u64 = 30; +const PHRASER_USER_AGENT: &str = "Phraser/1.0 (+https://github.com/newblacc/Phraser)"; +const PHRASER_REFERER: &str = "https://github.com/newblacc/Phraser"; +const ANTHROPIC_API_VERSION: &str = "2023-06-01"; #[derive(Debug, Serialize)] struct ChatMessage { @@ -51,27 +57,23 @@ struct ChatMessageResponse { fn build_headers(provider: &PostProcessProvider, api_key: &str) -> Result { let mut headers = HeaderMap::new(); - // Common headers headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json")); - headers.insert( - REFERER, - HeaderValue::from_static("https://github.com/cjpais/Handy"), - ); - headers.insert( - USER_AGENT, - HeaderValue::from_static("Handy/1.0 (+https://github.com/cjpais/Handy)"), - ); - headers.insert("X-Title", HeaderValue::from_static("Handy")); + headers.insert(REFERER, HeaderValue::from_static(PHRASER_REFERER)); + headers.insert(USER_AGENT, HeaderValue::from_static(PHRASER_USER_AGENT)); + headers.insert("X-Title", HeaderValue::from_static("Phraser")); // Provider-specific auth headers if !api_key.is_empty() { - if provider.id == "anthropic" { + if provider.id == PROVIDER_ID_ANTHROPIC { headers.insert( "x-api-key", HeaderValue::from_str(api_key) .map_err(|e| format!("Invalid API key header value: {}", e))?, ); - headers.insert("anthropic-version", HeaderValue::from_static("2023-06-01")); + headers.insert( + "anthropic-version", + HeaderValue::from_static(ANTHROPIC_API_VERSION), + ); } else { headers.insert( AUTHORIZATION, @@ -84,10 +86,15 @@ fn build_headers(provider: &PostProcessProvider, api_key: &str) -> Result
reqwest::ClientBuilder { + reqwest::Client::builder().timeout(Duration::from_secs(REQUEST_TIMEOUT_SECS)) +} + /// Create an HTTP client with provider-specific headers fn create_client(provider: &PostProcessProvider, api_key: &str) -> Result { let headers = build_headers(provider, api_key)?; - reqwest::Client::builder() + create_base_client() .default_headers(headers) .build() .map_err(|e| format!("Failed to build HTTP client: {}", e)) @@ -105,6 +112,27 @@ pub async fn send_chat_completion( send_chat_completion_with_schema(provider, api_key, model, prompt, None, None).await } +/// Send a chat completion with a system prompt but no structured output schema. +/// Use this instead of `send_chat_completion_with_schema(..., None)` to make +/// intent explicit at the call site. +pub async fn send_chat_completion_with_system( + provider: &PostProcessProvider, + api_key: String, + model: &str, + user_content: String, + system_prompt: String, +) -> Result, String> { + send_chat_completion_with_schema( + provider, + api_key, + model, + user_content, + Some(system_prompt), + None, + ) + .await +} + /// Send a chat completion request with structured output support /// When json_schema is provided, uses structured outputs mode /// system_prompt is used as the system message when provided @@ -117,7 +145,7 @@ pub async fn send_chat_completion_with_schema( json_schema: Option, ) -> Result, String> { // Route Gemini requests to the dedicated Gemini client - if provider.id == "gemini" { + if provider.id == PROVIDER_ID_GEMINI { let sys = system_prompt.unwrap_or_default(); match crate::gemini_client::generate_text(&api_key, model, &sys, &user_content).await { Ok(text) if !text.is_empty() => return Ok(Some(text)), @@ -196,6 +224,55 @@ pub async fn send_chat_completion_with_schema( .and_then(|choice| choice.message.content.clone())) } +async fn fetch_gemini_models(api_key: &str) -> Result, String> { + let url = "https://generativelanguage.googleapis.com/v1beta/models"; + + let client = create_base_client() + .build() + .map_err(|e| format!("Failed to build Gemini HTTP client: {}", e))?; + + let response = client + .get(url) + .header("x-goog-api-key", api_key) + .header(USER_AGENT, PHRASER_USER_AGENT) + .header(REFERER, PHRASER_REFERER) + .send() + .await + .map_err(|e| format!("Failed to fetch Gemini models: {}", e))?; + + let status = response.status(); + if !status.is_success() { + let error_text = response + .text() + .await + .unwrap_or_else(|_| "Unknown error".to_string()); + return Err(format!( + "Gemini model list request failed ({}): {}", + status, error_text + )); + } + + let parsed: serde_json::Value = response + .json() + .await + .map_err(|e| format!("Failed to parse Gemini response: {}", e))?; + + let mut models = Vec::new(); + if let Some(data) = parsed.get("models").and_then(|d| d.as_array()) { + for entry in data { + if let Some(name) = entry.get("name").and_then(|n| n.as_str()) { + // Gemini returns "models/gemini-2.5-flash" - strip the prefix + let model_id = name.strip_prefix("models/").unwrap_or(name); + if model_id.contains("gemini") { + models.push(model_id.to_string()); + } + } + } + } + + Ok(models) +} + /// Fetch available models from an OpenAI-compatible API /// Returns a list of model IDs pub async fn fetch_models( @@ -203,7 +280,7 @@ pub async fn fetch_models( api_key: String, ) -> Result, String> { // Gemini uses a different API format for listing models - if provider.id == "gemini" { + if provider.id == PROVIDER_ID_GEMINI { return fetch_gemini_models(&api_key).await; } @@ -261,46 +338,172 @@ pub async fn fetch_models( Ok(models) } -async fn fetch_gemini_models(api_key: &str) -> Result, String> { - let url = "https://generativelanguage.googleapis.com/v1beta/models"; +#[cfg(test)] +mod tests { + use super::*; + use crate::settings::PROVIDER_ID_OPENAI; + + fn make_provider(id: &str, base_url: &str) -> PostProcessProvider { + PostProcessProvider { + id: id.to_string(), + label: id.to_string(), + base_url: base_url.to_string(), + allow_base_url_edit: false, + requires_api_key: true, + models_endpoint: None, + supports_structured_output: false, + } + } - let client = reqwest::Client::new(); - let response = client - .get(url) - .header("x-goog-api-key", api_key) - .send() - .await - .map_err(|e| format!("Failed to fetch Gemini models: {}", e))?; + #[test] + fn build_headers_common_fields() { + let provider = make_provider("openai", "https://api.openai.com/v1"); + let headers = build_headers(&provider, "sk-test-key").unwrap(); - let status = response.status(); - if !status.is_success() { - let error_text = response - .text() - .await - .unwrap_or_else(|_| "Unknown error".to_string()); - return Err(format!( - "Gemini model list request failed ({}): {}", - status, error_text - )); + assert_eq!(headers.get(CONTENT_TYPE).unwrap(), "application/json"); + assert!(headers.get(USER_AGENT).is_some()); + assert!(headers.get(REFERER).is_some()); } - let parsed: serde_json::Value = response - .json() - .await - .map_err(|e| format!("Failed to parse Gemini response: {}", e))?; + #[test] + fn build_headers_bearer_auth_for_openai() { + let provider = make_provider("openai", "https://api.openai.com/v1"); + let headers = build_headers(&provider, "sk-test-key").unwrap(); - let mut models = Vec::new(); - if let Some(data) = parsed.get("models").and_then(|d| d.as_array()) { - for entry in data { - if let Some(name) = entry.get("name").and_then(|n| n.as_str()) { - // Gemini returns "models/gemini-2.5-flash" - strip the prefix - let model_id = name.strip_prefix("models/").unwrap_or(name); - if model_id.contains("gemini") { - models.push(model_id.to_string()); - } + assert_eq!(headers.get(AUTHORIZATION).unwrap(), "Bearer sk-test-key"); + } + + #[test] + fn build_headers_anthropic_uses_x_api_key() { + let provider = make_provider(PROVIDER_ID_ANTHROPIC, "https://api.anthropic.com/v1"); + let headers = build_headers(&provider, "sk-ant-test").unwrap(); + + assert_eq!(headers.get("x-api-key").unwrap(), "sk-ant-test"); + assert_eq!(headers.get("anthropic-version").unwrap(), "2023-06-01"); + // Should NOT have Bearer auth + assert!(headers.get(AUTHORIZATION).is_none()); + } + + #[test] + fn build_headers_empty_api_key_no_auth() { + let provider = make_provider("openai", "https://api.openai.com/v1"); + let headers = build_headers(&provider, "").unwrap(); + + assert!(headers.get(AUTHORIZATION).is_none()); + } + + #[test] + fn chat_completion_request_serializes() { + let request = ChatCompletionRequest { + model: "gpt-4".to_string(), + messages: vec![ + ChatMessage { + role: "system".to_string(), + content: "You are helpful.".to_string(), + }, + ChatMessage { + role: "user".to_string(), + content: "Hello".to_string(), + }, + ], + response_format: None, + }; + + let json = serde_json::to_string(&request).unwrap(); + assert!(json.contains("gpt-4")); + assert!(json.contains("system")); + assert!(json.contains("user")); + // response_format should be absent (skip_serializing_if) + assert!(!json.contains("response_format")); + } + + #[test] + fn chat_completion_request_with_schema_serializes() { + let schema = serde_json::json!({ + "type": "object", + "properties": { + "text": { "type": "string" } } - } + }); + + let request = ChatCompletionRequest { + model: "gpt-4".to_string(), + messages: vec![ChatMessage { + role: "user".to_string(), + content: "test".to_string(), + }], + response_format: Some(ResponseFormat { + format_type: "json_schema".to_string(), + json_schema: JsonSchema { + name: "test_output".to_string(), + strict: true, + schema, + }, + }), + }; + + let json = serde_json::to_string(&request).unwrap(); + assert!(json.contains("json_schema")); + assert!(json.contains("test_output")); + assert!(json.contains("strict")); } - Ok(models) + #[test] + fn chat_completion_response_deserializes() { + let json = r#"{ + "choices": [{ + "message": { + "content": "Hello, world!" + } + }] + }"#; + + let response: ChatCompletionResponse = serde_json::from_str(json).unwrap(); + assert_eq!(response.choices.len(), 1); + assert_eq!( + response.choices[0].message.content.as_deref(), + Some("Hello, world!") + ); + } + + #[test] + fn chat_completion_response_no_content() { + let json = r#"{ + "choices": [{ + "message": { + "content": null + } + }] + }"#; + + let response: ChatCompletionResponse = serde_json::from_str(json).unwrap(); + assert!(response.choices[0].message.content.is_none()); + } + + #[test] + fn chat_completion_response_empty_choices() { + let json = r#"{"choices": []}"#; + let response: ChatCompletionResponse = serde_json::from_str(json).unwrap(); + assert!(response.choices.is_empty()); + } + + #[test] + fn referer_header_matches_constant() { + let provider = make_provider(PROVIDER_ID_OPENAI, "https://api.openai.com/v1"); + let headers = build_headers(&provider, "key").unwrap(); + assert_eq!( + headers.get(REFERER).unwrap().to_str().unwrap(), + PHRASER_REFERER + ); + } + + #[test] + fn user_agent_header_matches_constant() { + let provider = make_provider(PROVIDER_ID_OPENAI, "https://api.openai.com/v1"); + let headers = build_headers(&provider, "key").unwrap(); + assert_eq!( + headers.get(USER_AGENT).unwrap().to_str().unwrap(), + PHRASER_USER_AGENT + ); + } } diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 1c678901b..7660aff45 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -2,7 +2,7 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] use clap::Parser; -use parler_app_lib::CliArgs; +use phraser_app_lib::CliArgs; fn main() { let cli_args = CliArgs::parse(); @@ -14,5 +14,5 @@ fn main() { std::env::set_var("WEBKIT_DISABLE_DMABUF_RENDERER", "1"); } - parler_app_lib::run(cli_args) + phraser_app_lib::run(cli_args) } diff --git a/src-tauri/src/managers/history.rs b/src-tauri/src/managers/history.rs index 7e5464f1b..6d65a5d59 100644 --- a/src-tauri/src/managers/history.rs +++ b/src-tauri/src/managers/history.rs @@ -53,6 +53,34 @@ pub struct HistoryManager { db_path: PathBuf, } +fn is_valid_audio_file_name(file_name: &str) -> bool { + !file_name.is_empty() + && !file_name.contains("..") + && !file_name.contains('/') + && !file_name.contains('\\') +} + +fn get_audio_file_path_from_dir( + recordings_dir: &std::path::Path, + file_name: &str, +) -> Result { + if !is_valid_audio_file_name(file_name) { + anyhow::bail!("Invalid file name"); + } + + Ok(recordings_dir.join(file_name)) +} + +fn format_timestamp_title(timestamp: i64) -> String { + if let Some(utc_datetime) = DateTime::from_timestamp(timestamp, 0) { + // Convert UTC to local timezone + let local_datetime = utc_datetime.with_timezone(&Local); + local_datetime.format("%B %e, %Y - %l:%M%p").to_string() + } else { + format!("Recording {}", timestamp) + } +} + impl HistoryManager { pub fn new(app_handle: &AppHandle) -> Result { // Create recordings directory in app data dir @@ -448,8 +476,8 @@ impl HistoryManager { Ok(()) } - pub fn get_audio_file_path(&self, file_name: &str) -> PathBuf { - self.recordings_dir.join(file_name) + pub fn get_audio_file_path(&self, file_name: &str) -> Result { + get_audio_file_path_from_dir(&self.recordings_dir, file_name) } pub fn update_transcription_text(&self, id: i64, new_text: &str) -> Result<()> { @@ -502,7 +530,7 @@ impl HistoryManager { // Get the entry to find the file name if let Some(entry) = self.get_entry_by_id(id).await? { // Delete the audio file first - let file_path = self.get_audio_file_path(&entry.file_name); + let file_path = self.get_audio_file_path(&entry.file_name)?; if file_path.exists() { if let Err(e) = fs::remove_file(&file_path) { error!("Failed to delete audio file {}: {}", entry.file_name, e); @@ -528,13 +556,7 @@ impl HistoryManager { } fn format_timestamp_title(&self, timestamp: i64) -> String { - if let Some(utc_datetime) = DateTime::from_timestamp(timestamp, 0) { - // Convert UTC to local timezone - let local_datetime = utc_datetime.with_timezone(&Local); - local_datetime.format("%B %e, %Y - %l:%M%p").to_string() - } else { - format!("Recording {}", timestamp) - } + format_timestamp_title(timestamp) } } @@ -601,4 +623,53 @@ mod tests { assert_eq!(entry.transcription_text, "second"); assert_eq!(entry.post_processed_text.as_deref(), Some("processed")); } + + #[test] + fn validate_audio_file_name_accepts_normal_name() { + assert!(is_valid_audio_file_name("handy-123.wav")); + } + + #[test] + fn validate_audio_file_name_rejects_parent_traversal() { + assert!(!is_valid_audio_file_name("../secret.wav")); + assert!(!is_valid_audio_file_name("..\\secret.wav")); + } + + #[test] + fn validate_audio_file_name_rejects_path_separators() { + assert!(!is_valid_audio_file_name("folder/file.wav")); + assert!(!is_valid_audio_file_name("folder\\file.wav")); + } + + #[test] + fn validate_audio_file_name_rejects_empty_name() { + assert!(!is_valid_audio_file_name("")); + } + + #[test] + fn get_audio_file_path_from_dir_returns_joined_path_for_valid_filename() { + let base = std::path::Path::new("/tmp/recordings"); + let path = get_audio_file_path_from_dir(base, "handy-123.wav").expect("valid path"); + assert_eq!(path, base.join("handy-123.wav")); + } + + #[test] + fn get_audio_file_path_from_dir_rejects_invalid_filename() { + let base = std::path::Path::new("/tmp/recordings"); + let err = get_audio_file_path_from_dir(base, "../secret.wav").expect_err("invalid name"); + assert_eq!(err.to_string(), "Invalid file name"); + } + + #[test] + fn format_timestamp_title_uses_fallback_when_timestamp_is_invalid() { + let title = format_timestamp_title(i64::MAX); + assert_eq!(title, format!("Recording {}", i64::MAX)); + } + + #[test] + fn format_timestamp_title_formats_valid_timestamp() { + let title = format_timestamp_title(1_700_000_000); + assert!(title.contains("2023") || title.contains("2024")); + assert!(title.contains(":")); + } } diff --git a/src-tauri/src/managers/transcription.rs b/src-tauri/src/managers/transcription.rs index 72b015128..a681b10da 100644 --- a/src-tauri/src/managers/transcription.rs +++ b/src-tauri/src/managers/transcription.rs @@ -1,6 +1,8 @@ use crate::audio_toolkit::{apply_custom_words, filter_transcription_output}; use crate::managers::model::{EngineType, ModelManager}; -use crate::settings::{get_settings, ModelUnloadTimeout}; +use crate::settings::{ + get_settings, ModelUnloadTimeout, LANG_SIMPLIFIED_CHINESE, LANG_TRADITIONAL_CHINESE, +}; use anyhow::Result; use log::{debug, error, info, warn}; use serde::Serialize; @@ -441,7 +443,7 @@ impl TranscriptionManager { Ordering::Relaxed, ); - let st = std::time::Instant::now(); + let start_time = std::time::Instant::now(); debug!("Audio vector length: {}", audio.len()); @@ -500,10 +502,9 @@ impl TranscriptionManager { }; let final_result = filter_transcription_output(&corrected); - let et = std::time::Instant::now(); info!( "Gemini transcription completed in {}ms", - (et - st).as_millis() + start_time.elapsed().as_millis() ); self.maybe_unload_immediately("gemini transcription"); @@ -539,8 +540,9 @@ impl TranscriptionManager { let whisper_language = if settings.selected_language == "auto" { None } else { - let normalized = if settings.selected_language == "zh-Hans" - || settings.selected_language == "zh-Hant" + let normalized = if settings.selected_language + == LANG_SIMPLIFIED_CHINESE + || settings.selected_language == LANG_TRADITIONAL_CHINESE { "zh".to_string() } else { @@ -580,7 +582,9 @@ impl TranscriptionManager { }), LoadedEngine::SenseVoice(sense_voice_engine) => { let language = match settings.selected_language.as_str() { - "zh" | "zh-Hans" | "zh-Hant" => SenseVoiceLanguage::Chinese, + "zh" | LANG_SIMPLIFIED_CHINESE | LANG_TRADITIONAL_CHINESE => { + SenseVoiceLanguage::Chinese + } "en" => SenseVoiceLanguage::English, "ja" => SenseVoiceLanguage::Japanese, "ko" => SenseVoiceLanguage::Korean, @@ -667,7 +671,6 @@ impl TranscriptionManager { // Filter out filler words and hallucinations let filtered_result = filter_transcription_output(&corrected_result); - let et = std::time::Instant::now(); let translation_note = if settings.translate_to_english { " (translated)" } else { @@ -675,21 +678,19 @@ impl TranscriptionManager { }; info!( "Transcription completed in {}ms{}", - (et - st).as_millis(), + start_time.elapsed().as_millis(), translation_note ); - let final_result = filtered_result; - - if final_result.is_empty() { + if filtered_result.is_empty() { info!("Transcription result is empty"); } else { - info!("Transcription result: {}", final_result); + info!("Transcription result: {}", filtered_result); } self.maybe_unload_immediately("transcription"); - Ok(final_result) + Ok(filtered_result) } } diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index 02e441ce6..c7fd8b68e 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -1,4 +1,4 @@ -use log::{debug, warn}; +use log::{debug, error, warn}; use serde::de::{self, Visitor}; use serde::{Deserialize, Deserializer, Serialize}; use specta::Type; @@ -9,6 +9,31 @@ use tauri_plugin_store::StoreExt; pub const APPLE_INTELLIGENCE_PROVIDER_ID: &str = "apple_intelligence"; pub const APPLE_INTELLIGENCE_DEFAULT_MODEL_ID: &str = "Apple Intelligence"; +/// BCP 47 tag for Simplified Chinese (used in language selection and transcription). +pub const LANG_SIMPLIFIED_CHINESE: &str = "zh-Hans"; + +/// BCP 47 tag for Traditional Chinese (used in language selection and transcription). +pub const LANG_TRADITIONAL_CHINESE: &str = "zh-Hant"; + +/// Provider IDs — single source of truth; used in routing logic across llm_client, actions, and +/// transcription. Any rename must happen here only. +pub const PROVIDER_ID_ANTHROPIC: &str = "anthropic"; +pub const PROVIDER_ID_GEMINI: &str = "gemini"; +#[allow(dead_code)] +pub const PROVIDER_ID_OPENAI: &str = "openai"; +#[allow(dead_code)] +pub const PROVIDER_ID_OPENROUTER: &str = "openrouter"; +#[allow(dead_code)] +pub const PROVIDER_ID_GROQ: &str = "groq"; +#[allow(dead_code)] +pub const PROVIDER_ID_CEREBRAS: &str = "cerebras"; +#[allow(dead_code)] +pub const PROVIDER_ID_ZAI: &str = "zai"; +#[allow(dead_code)] +pub const PROVIDER_ID_OLLAMA: &str = "ollama"; +#[allow(dead_code)] +pub const PROVIDER_ID_CUSTOM: &str = "custom"; + #[derive(Serialize, Debug, Clone, Copy, PartialEq, Eq, Type)] #[serde(rename_all = "lowercase")] pub enum LogLevel { @@ -118,6 +143,8 @@ pub struct PostProcessProvider { pub base_url: String, #[serde(default)] pub allow_base_url_edit: bool, + #[serde(default = "default_true")] + pub requires_api_key: bool, #[serde(default)] pub models_endpoint: Option, #[serde(default)] @@ -294,7 +321,6 @@ impl Default for TypingTool { } } -/* still handy for composing the initial JSON in the store ------------- */ #[derive(Serialize, Deserialize, Debug, Clone, Type)] pub struct AppSettings { pub bindings: HashMap, @@ -445,7 +471,7 @@ fn default_paste_delay_ms() -> u64 { } fn default_auto_submit() -> bool { - false + true } fn default_history_limit() -> usize { @@ -482,6 +508,10 @@ fn default_post_process_provider_id() -> String { "openai".to_string() } +fn default_true() -> bool { + true +} + fn default_post_process_providers() -> Vec { let mut providers = vec![ PostProcessProvider { @@ -489,6 +519,7 @@ fn default_post_process_providers() -> Vec { label: "OpenAI".to_string(), base_url: "https://api.openai.com/v1".to_string(), allow_base_url_edit: false, + requires_api_key: true, models_endpoint: Some("/models".to_string()), supports_structured_output: true, }, @@ -497,6 +528,7 @@ fn default_post_process_providers() -> Vec { label: "Z.AI".to_string(), base_url: "https://api.z.ai/api/paas/v4".to_string(), allow_base_url_edit: false, + requires_api_key: true, models_endpoint: Some("/models".to_string()), supports_structured_output: true, }, @@ -505,6 +537,7 @@ fn default_post_process_providers() -> Vec { label: "OpenRouter".to_string(), base_url: "https://openrouter.ai/api/v1".to_string(), allow_base_url_edit: false, + requires_api_key: true, models_endpoint: Some("/models".to_string()), supports_structured_output: true, }, @@ -513,6 +546,7 @@ fn default_post_process_providers() -> Vec { label: "Anthropic".to_string(), base_url: "https://api.anthropic.com/v1".to_string(), allow_base_url_edit: false, + requires_api_key: true, models_endpoint: Some("/models".to_string()), supports_structured_output: false, }, @@ -521,6 +555,7 @@ fn default_post_process_providers() -> Vec { label: "Groq".to_string(), base_url: "https://api.groq.com/openai/v1".to_string(), allow_base_url_edit: false, + requires_api_key: true, models_endpoint: Some("/models".to_string()), supports_structured_output: false, }, @@ -529,6 +564,7 @@ fn default_post_process_providers() -> Vec { label: "Cerebras".to_string(), base_url: "https://api.cerebras.ai/v1".to_string(), allow_base_url_edit: false, + requires_api_key: true, models_endpoint: Some("/models".to_string()), supports_structured_output: true, }, @@ -545,6 +581,7 @@ fn default_post_process_providers() -> Vec { label: "Apple Intelligence".to_string(), base_url: "apple-intelligence://local".to_string(), allow_base_url_edit: false, + requires_api_key: false, models_endpoint: None, supports_structured_output: true, }); @@ -555,16 +592,28 @@ fn default_post_process_providers() -> Vec { label: "Gemini".to_string(), base_url: "https://generativelanguage.googleapis.com/v1beta".to_string(), allow_base_url_edit: false, + requires_api_key: true, models_endpoint: None, supports_structured_output: false, }); + providers.push(PostProcessProvider { + id: "ollama".to_string(), + label: "Ollama".to_string(), + base_url: "http://localhost:11434/v1".to_string(), + allow_base_url_edit: true, + requires_api_key: false, + models_endpoint: Some("/models".to_string()), + supports_structured_output: false, + }); + // Custom provider always comes last providers.push(PostProcessProvider { id: "custom".to_string(), label: "Custom".to_string(), base_url: "http://localhost:11434/v1".to_string(), allow_base_url_edit: true, + requires_api_key: false, models_endpoint: Some("/models".to_string()), supports_structured_output: false, }); @@ -628,17 +677,15 @@ fn ensure_post_process_defaults(settings: &mut AppSettings) -> bool { .find(|p| p.id == provider.id) { Some(existing) => { - // Sync supports_structured_output field for existing providers (migration) + // Sync immutable fields from the canonical default (migration). if existing.supports_structured_output != provider.supports_structured_output { - debug!( - "Updating supports_structured_output for provider '{}' from {} to {}", - provider.id, - existing.supports_structured_output, - provider.supports_structured_output - ); existing.supports_structured_output = provider.supports_structured_output; changed = true; } + if existing.requires_api_key != provider.requires_api_key { + existing.requires_api_key = provider.requires_api_key; + changed = true; + } } None => { // Provider doesn't exist, add it @@ -804,87 +851,83 @@ impl AppSettings { } } -pub fn load_or_create_app_settings(app: &AppHandle) -> AppSettings { - // Initialize store - let store = app - .store(SETTINGS_STORE_PATH) - .expect("Failed to initialize store"); +/// Serialize settings to JSON and persist via the store. +/// `AppSettings` derives `Serialize`, so failure here is a programming error — log it loudly. +fn persist_settings(store: &tauri_plugin_store::Store, settings: &AppSettings) { + match serde_json::to_value(settings) { + Ok(v) => store.set("settings", v), + Err(e) => error!( + "BUG: Failed to serialize AppSettings — settings not saved: {}", + e + ), + } +} - let mut settings = if let Some(settings_value) = store.get("settings") { - // Parse the entire settings object - match serde_json::from_value::(settings_value) { - Ok(mut settings) => { - debug!("Found existing settings: {:?}", settings); - let default_settings = get_default_settings(); - let mut updated = false; - - // Merge default bindings into existing settings - for (key, value) in default_settings.bindings { - if !settings.bindings.contains_key(&key) { - debug!("Adding missing binding: {}", key); - settings.bindings.insert(key, value); - updated = true; +/// Load or create settings from the store, merge missing bindings, and apply post-process +/// defaults. Used at startup (`load_or_create_app_settings`) and on every read (`get_settings`). +fn load_settings_from_store( + store: &tauri_plugin_store::Store, + fill_missing_bindings: bool, +) -> AppSettings { + let mut settings = if let Some(value) = store.get("settings") { + match serde_json::from_value::(value) { + Ok(mut s) => { + if fill_missing_bindings { + let defaults = get_default_settings(); + let mut updated = false; + for (key, value) in defaults.bindings { + if !s.bindings.contains_key(&key) { + debug!("Adding missing binding: {}", key); + s.bindings.insert(key, value); + updated = true; + } + } + if updated { + debug!("Settings updated with new bindings"); + persist_settings(store, &s); } } - - if updated { - debug!("Settings updated with new bindings"); - store.set("settings", serde_json::to_value(&settings).unwrap()); - } - - settings + s } Err(e) => { - warn!("Failed to parse settings: {}", e); - // Fall back to default settings if parsing fails - let default_settings = get_default_settings(); - store.set("settings", serde_json::to_value(&default_settings).unwrap()); - default_settings + warn!("Failed to parse settings: {}. Falling back to defaults.", e); + let defaults = get_default_settings(); + persist_settings(store, &defaults); + defaults } } } else { - let default_settings = get_default_settings(); - store.set("settings", serde_json::to_value(&default_settings).unwrap()); - default_settings + let defaults = get_default_settings(); + persist_settings(store, &defaults); + defaults }; if ensure_post_process_defaults(&mut settings) { - store.set("settings", serde_json::to_value(&settings).unwrap()); + persist_settings(store, &settings); } settings } -pub fn get_settings(app: &AppHandle) -> AppSettings { +pub fn load_or_create_app_settings(app: &AppHandle) -> AppSettings { let store = app .store(SETTINGS_STORE_PATH) .expect("Failed to initialize store"); + load_settings_from_store(&store, true) +} - let mut settings = if let Some(settings_value) = store.get("settings") { - serde_json::from_value::(settings_value).unwrap_or_else(|_| { - let default_settings = get_default_settings(); - store.set("settings", serde_json::to_value(&default_settings).unwrap()); - default_settings - }) - } else { - let default_settings = get_default_settings(); - store.set("settings", serde_json::to_value(&default_settings).unwrap()); - default_settings - }; - - if ensure_post_process_defaults(&mut settings) { - store.set("settings", serde_json::to_value(&settings).unwrap()); - } - - settings +pub fn get_settings(app: &AppHandle) -> AppSettings { + let store = app + .store(SETTINGS_STORE_PATH) + .expect("Failed to initialize store"); + load_settings_from_store(&store, false) } pub fn write_settings(app: &AppHandle, settings: AppSettings) { let store = app .store(SETTINGS_STORE_PATH) .expect("Failed to initialize store"); - - store.set("settings", serde_json::to_value(&settings).unwrap()); + persist_settings(&store, &settings); } pub fn get_bindings(app: &AppHandle) -> HashMap { @@ -893,12 +936,9 @@ pub fn get_bindings(app: &AppHandle) -> HashMap { settings.bindings } -pub fn get_stored_binding(app: &AppHandle, id: &str) -> ShortcutBinding { +pub fn get_stored_binding(app: &AppHandle, id: &str) -> Option { let bindings = get_bindings(app); - - let binding = bindings.get(id).unwrap().clone(); - - binding + bindings.get(id).cloned() } pub fn get_history_limit(app: &AppHandle) -> usize { @@ -916,9 +956,311 @@ mod tests { use super::*; #[test] - fn default_settings_disable_auto_submit() { + fn default_settings_enable_auto_submit() { let settings = get_default_settings(); - assert!(!settings.auto_submit); + assert!(settings.auto_submit); assert_eq!(settings.auto_submit_key, AutoSubmitKey::Enter); } + + #[test] + fn default_post_process_maps_cover_all_providers() { + let providers = default_post_process_providers(); + let api_keys = default_post_process_api_keys(); + let models = default_post_process_models(); + + for provider in providers { + assert!(api_keys.contains_key(&provider.id)); + assert!(models.contains_key(&provider.id)); + assert_eq!( + models.get(&provider.id), + Some(&default_model_for_provider(&provider.id)) + ); + } + } + + #[test] + fn ensure_post_process_defaults_adds_missing_values() { + let mut settings = get_default_settings(); + settings.post_process_providers.clear(); + settings.post_process_api_keys.clear(); + settings.post_process_models.clear(); + + let changed = ensure_post_process_defaults(&mut settings); + assert!(changed); + + for provider in default_post_process_providers() { + assert!(settings + .post_process_providers + .iter() + .any(|p| p.id == provider.id)); + assert!(settings.post_process_api_keys.contains_key(&provider.id)); + assert!(settings.post_process_models.contains_key(&provider.id)); + } + } + + #[test] + fn ensure_post_process_defaults_repairs_structured_output_flag() { + let mut settings = get_default_settings(); + let provider = default_post_process_providers() + .into_iter() + .find(|p| p.id == "openai") + .expect("openai provider exists"); + + let existing = settings + .post_process_providers + .iter_mut() + .find(|p| p.id == "openai") + .expect("openai exists in settings"); + existing.supports_structured_output = !provider.supports_structured_output; + + let changed = ensure_post_process_defaults(&mut settings); + assert!(changed); + let updated = settings + .post_process_providers + .iter() + .find(|p| p.id == "openai") + .expect("openai exists in settings"); + assert_eq!( + updated.supports_structured_output, + provider.supports_structured_output + ); + } + + #[test] + fn ensure_post_process_defaults_is_noop_when_settings_are_current() { + let mut settings = get_default_settings(); + let changed = ensure_post_process_defaults(&mut settings); + assert!(!changed); + } + + // --- ModelUnloadTimeout tests --- + + #[test] + fn model_unload_timeout_never_returns_none() { + assert_eq!(ModelUnloadTimeout::Never.to_minutes(), None); + assert_eq!(ModelUnloadTimeout::Never.to_seconds(), None); + } + + #[test] + fn model_unload_timeout_immediately_returns_zero() { + assert_eq!(ModelUnloadTimeout::Immediately.to_minutes(), Some(0)); + assert_eq!(ModelUnloadTimeout::Immediately.to_seconds(), Some(0)); + } + + #[test] + fn model_unload_timeout_conversions() { + assert_eq!(ModelUnloadTimeout::Min2.to_minutes(), Some(2)); + assert_eq!(ModelUnloadTimeout::Min2.to_seconds(), Some(120)); + assert_eq!(ModelUnloadTimeout::Min5.to_minutes(), Some(5)); + assert_eq!(ModelUnloadTimeout::Min5.to_seconds(), Some(300)); + assert_eq!(ModelUnloadTimeout::Min10.to_seconds(), Some(600)); + assert_eq!(ModelUnloadTimeout::Min15.to_seconds(), Some(900)); + assert_eq!(ModelUnloadTimeout::Hour1.to_minutes(), Some(60)); + assert_eq!(ModelUnloadTimeout::Hour1.to_seconds(), Some(3600)); + } + + #[test] + fn model_unload_timeout_sec5_debug() { + assert_eq!(ModelUnloadTimeout::Sec5.to_minutes(), Some(0)); + assert_eq!(ModelUnloadTimeout::Sec5.to_seconds(), Some(5)); + } + + // --- SoundTheme tests --- + + #[test] + fn sound_theme_paths() { + assert_eq!( + SoundTheme::Marimba.to_start_path(), + "resources/marimba_start.wav" + ); + assert_eq!( + SoundTheme::Marimba.to_stop_path(), + "resources/marimba_stop.wav" + ); + assert_eq!(SoundTheme::Pop.to_start_path(), "resources/pop_start.wav"); + assert_eq!(SoundTheme::Pop.to_stop_path(), "resources/pop_stop.wav"); + assert_eq!( + SoundTheme::Custom.to_start_path(), + "resources/custom_start.wav" + ); + assert_eq!( + SoundTheme::Custom.to_stop_path(), + "resources/custom_stop.wav" + ); + } + + // --- LogLevel serde round-trip tests --- + + #[test] + fn log_level_deserializes_from_string() { + let json = r#""trace""#; + let level: LogLevel = serde_json::from_str(json).unwrap(); + assert_eq!(level, LogLevel::Trace); + } + + #[test] + fn log_level_deserializes_from_integer() { + // Old numeric format: 1 = Trace, 2 = Debug, etc. + let json = "1"; + let level: LogLevel = serde_json::from_str(json).unwrap(); + assert_eq!(level, LogLevel::Trace); + } + + #[test] + fn log_level_round_trip() { + for level in [ + LogLevel::Trace, + LogLevel::Debug, + LogLevel::Info, + LogLevel::Warn, + LogLevel::Error, + ] { + let json = serde_json::to_string(&level).unwrap(); + let deserialized: LogLevel = serde_json::from_str(&json).unwrap(); + assert_eq!(level, deserialized); + } + } + + // --- Default settings sanity --- + + #[test] + fn default_settings_has_bindings() { + let settings = get_default_settings(); + assert!(!settings.bindings.is_empty()); + assert!(settings.bindings.contains_key("transcribe")); + } + + #[test] + fn default_settings_overlay_position_is_valid() { + let settings = get_default_settings(); + let json = serde_json::to_value(&settings.overlay_position).unwrap(); + assert!( + json.is_string(), + "overlay_position should serialize to a string variant" + ); + } + + #[test] + fn default_settings_reasonable_history_limit() { + let settings = get_default_settings(); + assert!(settings.history_limit > 0); + assert!(settings.history_limit <= 10000); + } + + #[test] + fn default_settings_word_correction_threshold_in_range() { + let settings = get_default_settings(); + assert!(settings.word_correction_threshold >= 0.0); + assert!(settings.word_correction_threshold <= 1.0); + } + + #[test] + fn default_settings_serializes_to_json() { + let settings = get_default_settings(); + let json = serde_json::to_string(&settings); + assert!(json.is_ok(), "Default settings should serialize to JSON"); + } + + #[test] + fn default_settings_round_trip_json() { + let settings = get_default_settings(); + let json = serde_json::to_string(&settings).unwrap(); + let deserialized: AppSettings = serde_json::from_str(&json).unwrap(); + assert_eq!(settings.auto_submit, deserialized.auto_submit); + assert_eq!(settings.push_to_talk, deserialized.push_to_talk); + assert_eq!(settings.selected_language, deserialized.selected_language); + } + + // --- Enum serde tests --- + + #[test] + fn paste_method_serde_round_trip() { + for method in [ + PasteMethod::CtrlV, + PasteMethod::Direct, + PasteMethod::None, + PasteMethod::ShiftInsert, + PasteMethod::CtrlShiftV, + PasteMethod::ExternalScript, + ] { + let json = serde_json::to_string(&method).unwrap(); + let deserialized: PasteMethod = serde_json::from_str(&json).unwrap(); + assert_eq!(method, deserialized); + } + } + + #[test] + fn clipboard_handling_serde_round_trip() { + for handling in [ + ClipboardHandling::DontModify, + ClipboardHandling::CopyToClipboard, + ] { + let json = serde_json::to_string(&handling).unwrap(); + let deserialized: ClipboardHandling = serde_json::from_str(&json).unwrap(); + assert_eq!(handling, deserialized); + } + } + + #[test] + fn auto_submit_key_serde_round_trip() { + for key in [ + AutoSubmitKey::Enter, + AutoSubmitKey::CtrlEnter, + AutoSubmitKey::CmdEnter, + ] { + let json = serde_json::to_string(&key).unwrap(); + let deserialized: AutoSubmitKey = serde_json::from_str(&json).unwrap(); + assert_eq!(key, deserialized); + } + } + + #[test] + fn overlay_position_serde_round_trip() { + for pos in [ + OverlayPosition::None, + OverlayPosition::Top, + OverlayPosition::Bottom, + ] { + let json = serde_json::to_string(&pos).unwrap(); + let deserialized: OverlayPosition = serde_json::from_str(&json).unwrap(); + assert_eq!(pos, deserialized); + } + } + + #[test] + fn recording_retention_period_serde_round_trip() { + for period in [ + RecordingRetentionPeriod::Never, + RecordingRetentionPeriod::PreserveLimit, + RecordingRetentionPeriod::Days3, + RecordingRetentionPeriod::Weeks2, + RecordingRetentionPeriod::Months3, + ] { + let json = serde_json::to_string(&period).unwrap(); + let deserialized: RecordingRetentionPeriod = serde_json::from_str(&json).unwrap(); + assert_eq!(period, deserialized); + } + } + + // --- provider ID constants --- + + #[test] + fn provider_id_constants_match_default_providers() { + let providers = default_post_process_providers(); + let ids: Vec<&str> = providers.iter().map(|p| p.id.as_str()).collect(); + assert!(ids.contains(&PROVIDER_ID_OPENAI)); + assert!(ids.contains(&PROVIDER_ID_ANTHROPIC)); + assert!(ids.contains(&PROVIDER_ID_GEMINI)); + assert!(ids.contains(&PROVIDER_ID_OPENROUTER)); + assert!(ids.contains(&PROVIDER_ID_GROQ)); + assert!(ids.contains(&PROVIDER_ID_CEREBRAS)); + assert!(ids.contains(&PROVIDER_ID_ZAI)); + assert!(ids.contains(&PROVIDER_ID_CUSTOM)); + } + + #[test] + fn lang_constants_match_expected_bcp47_tags() { + assert_eq!(LANG_SIMPLIFIED_CHINESE, "zh-Hans"); + assert_eq!(LANG_TRADITIONAL_CHINESE, "zh-Hant"); + } } diff --git a/src-tauri/src/shortcut/handler.rs b/src-tauri/src/shortcut/handler.rs index fa6f17a02..0ec337940 100644 --- a/src-tauri/src/shortcut/handler.rs +++ b/src-tauri/src/shortcut/handler.rs @@ -46,12 +46,14 @@ pub fn handle_shortcut_event( return; } - // Action bindings (1-9): only fires when recording and key is pressed + // Action bindings (Ctrl+1…9): always delegate to start_with_action. + // The coordinator handles both cases: idle → start recording with action + // pre-selected; recording → stop and apply the action. if is_action_binding(binding_id) { if is_pressed { if let Some(key) = parse_action_key(binding_id) { if let Some(coordinator) = app.try_state::() { - coordinator.select_action(key); + coordinator.start_with_action(key, hotkey_string); } } } diff --git a/src-tauri/src/shortcut/handy_keys.rs b/src-tauri/src/shortcut/handy_keys.rs index 6f1b03a9a..c19360ee1 100644 --- a/src-tauri/src/shortcut/handy_keys.rs +++ b/src-tauri/src/shortcut/handy_keys.rs @@ -527,26 +527,6 @@ pub fn register_action_shortcut(app: &AppHandle, binding: ShortcutBinding) { } } -/// Unregister an action shortcut (called when recording stops) -pub fn unregister_action_shortcut(app: &AppHandle, binding: ShortcutBinding) { - #[cfg(target_os = "linux")] - { - let _ = (app, binding); - return; - } - - #[cfg(not(target_os = "linux"))] - { - let app_clone = app.clone(); - let binding_clone = binding; - tauri::async_runtime::spawn(async move { - if let Some(state) = app_clone.try_state::() { - let _ = state.unregister(&binding_clone); - } - }); - } -} - /// Register a shortcut pub fn register_shortcut(app: &AppHandle, binding: ShortcutBinding) -> Result<(), String> { let state = app diff --git a/src-tauri/src/shortcut/mod.rs b/src-tauri/src/shortcut/mod.rs index d212433ec..bcf9ffc11 100644 --- a/src-tauri/src/shortcut/mod.rs +++ b/src-tauri/src/shortcut/mod.rs @@ -52,6 +52,10 @@ pub fn init_shortcuts(app: &AppHandle) { } } } + + // Action shortcuts (Ctrl+1…9) are always-on global shortcuts so users can + // press Ctrl+N from any state to start recording with that action pre-selected. + register_action_shortcuts(app); } /// Register the cancel shortcut (called when recording starts) @@ -98,29 +102,6 @@ pub fn register_action_shortcuts(app: &AppHandle) { } } -/// Unregister all action shortcuts (called when recording stops) -pub fn unregister_action_shortcuts(app: &AppHandle) { - let settings = get_settings(app); - for key in 1..=9u8 { - let shortcut_str = format!("ctrl+{}", key); - let binding = ShortcutBinding { - id: format!("action_{}", key), - name: String::new(), - description: String::new(), - default_binding: shortcut_str.clone(), - current_binding: shortcut_str, - }; - match settings.keyboard_implementation { - KeyboardImplementation::Tauri => { - tauri_impl::unregister_action_shortcut(app, binding); - } - KeyboardImplementation::HandyKeys => { - handy_keys::unregister_action_shortcut(app, binding); - } - } - } -} - /// Register a shortcut using the appropriate implementation pub fn register_shortcut(app: &AppHandle, binding: ShortcutBinding) -> Result<(), String> { let settings = get_settings(app); @@ -251,7 +232,8 @@ pub fn change_binding( #[tauri::command] #[specta::specta] pub fn reset_binding(app: AppHandle, id: String) -> Result { - let binding = settings::get_stored_binding(&app, &id); + let binding = settings::get_stored_binding(&app, &id) + .ok_or_else(|| format!("Binding '{}' not found", id))?; change_binding(app, id, binding.default_binding) } diff --git a/src-tauri/src/shortcut/tauri_impl.rs b/src-tauri/src/shortcut/tauri_impl.rs index 0da239dd1..5b1da712d 100644 --- a/src-tauri/src/shortcut/tauri_impl.rs +++ b/src-tauri/src/shortcut/tauri_impl.rs @@ -213,20 +213,3 @@ pub fn register_action_shortcut(app: &AppHandle, binding: ShortcutBinding) { }); } } - -/// Unregister an action shortcut (called when recording stops) -pub fn unregister_action_shortcut(app: &AppHandle, binding: ShortcutBinding) { - #[cfg(target_os = "linux")] - { - let _ = (app, binding); - return; - } - - #[cfg(not(target_os = "linux"))] - { - let app_clone = app.clone(); - tauri::async_runtime::spawn(async move { - let _ = unregister_shortcut(&app_clone, binding); - }); - } -} diff --git a/src-tauri/src/transcription_coordinator.rs b/src-tauri/src/transcription_coordinator.rs index 07bcb6ce3..18668a4ec 100644 --- a/src-tauri/src/transcription_coordinator.rs +++ b/src-tauri/src/transcription_coordinator.rs @@ -26,6 +26,12 @@ enum Command { SelectAction { key: u8, }, + /// Start recording with action N pre-selected (or stop if already recording this action). + /// Used by action shortcuts (Ctrl+1…9) pressed while the app is idle. + StartWithAction { + key: u8, + hotkey_string: String, + }, } /// Pipeline lifecycle, owned exclusively by the coordinator thread. @@ -154,6 +160,49 @@ impl TranscriptionCoordinator { debug!("Action selection ignored: not in recording state"); } } + Command::StartWithAction { key, hotkey_string } => { + match stage { + Stage::Idle => { + // Start recording with the action pre-selected using the + // post-process binding so the pipeline applies the action. + start( + &app, + &mut stage, + "transcribe_with_post_process", + &hotkey_string, + ); + // Apply the action selection now that we're in Recording state. + if let Stage::Recording { + ref mut selected_action, + .. + } = stage + { + *selected_action = Some(key); + let settings = get_settings(&app); + if let Some(action) = settings + .post_process_actions + .iter() + .find(|a| a.key == key) + { + emit_action_selected(&app, key, &action.name); + } + debug!( + "Started recording with action {} pre-selected", + key + ); + } + } + Stage::Recording { ref binding_id, .. } => { + // Already recording — stop and let the pipeline apply the + // pre-selected action that was set when recording started. + let bid = binding_id.clone(); + stop(&app, &mut stage, &bid, &hotkey_string); + } + Stage::Processing => { + debug!("StartWithAction ignored: pipeline busy"); + } + } + } } } debug!("Transcription coordinator exited"); @@ -212,6 +261,81 @@ impl TranscriptionCoordinator { warn!("Transcription coordinator channel closed"); } } + + /// Start recording with action `key` pre-selected, or stop if already recording. + pub fn start_with_action(&self, key: u8, hotkey_string: &str) { + if self + .tx + .send(Command::StartWithAction { + key, + hotkey_string: hotkey_string.to_string(), + }) + .is_err() + { + warn!("Transcription coordinator channel closed"); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn is_transcribe_binding_accepts_transcribe() { + assert!(is_transcribe_binding("transcribe")); + } + + #[test] + fn is_transcribe_binding_accepts_post_process() { + assert!(is_transcribe_binding("transcribe_with_post_process")); + } + + #[test] + fn is_transcribe_binding_rejects_other() { + assert!(!is_transcribe_binding("cancel")); + assert!(!is_transcribe_binding("action_1")); + assert!(!is_transcribe_binding("")); + assert!(!is_transcribe_binding("transcribe_extra")); + } + + #[test] + fn is_action_binding_accepts_action_prefix() { + assert!(is_action_binding("action_1")); + assert!(is_action_binding("action_9")); + assert!(is_action_binding("action_foo")); + } + + #[test] + fn is_action_binding_rejects_non_action() { + assert!(!is_action_binding("transcribe")); + assert!(!is_action_binding("cancel")); + assert!(!is_action_binding("")); + assert!(!is_action_binding("Action_1")); // case-sensitive + } + + #[test] + fn parse_action_key_valid_digits() { + assert_eq!(parse_action_key("action_1"), Some(1)); + assert_eq!(parse_action_key("action_9"), Some(9)); + assert_eq!(parse_action_key("action_0"), Some(0)); + assert_eq!(parse_action_key("action_255"), Some(255)); + } + + #[test] + fn parse_action_key_invalid() { + assert_eq!(parse_action_key("action_"), None); // empty after prefix + assert_eq!(parse_action_key("action_abc"), None); // not a number + assert_eq!(parse_action_key("transcribe"), None); // no prefix + assert_eq!(parse_action_key(""), None); + } + + #[test] + fn parse_action_key_overflow() { + // u8 max is 255, 256 should fail + assert_eq!(parse_action_key("action_256"), None); + assert_eq!(parse_action_key("action_999"), None); + } } fn start(app: &AppHandle, stage: &mut Stage, binding_id: &str, hotkey_string: &str) { @@ -240,7 +364,13 @@ fn stop(app: &AppHandle, stage: &mut Stage, binding_id: &str, hotkey_string: &st } = &stage { if let Some(state) = app.try_state::() { - *state.0.lock().unwrap() = *selected_action; + match state.0.lock() { + Ok(mut guard) => *guard = *selected_action, + Err(poisoned) => { + error!("ActiveActionState mutex poisoned, recovering"); + *poisoned.into_inner() = *selected_action; + } + } } } diff --git a/src-tauri/src/tray.rs b/src-tauri/src/tray.rs index 6087758ec..a0e66156f 100644 --- a/src-tauri/src/tray.rs +++ b/src-tauri/src/tray.rs @@ -94,9 +94,9 @@ pub fn update_tray_menu(app: &AppHandle, state: &TrayIconState, locale: Option<& // Create common menu items let version_label = if cfg!(debug_assertions) { - format!("Parler v{} (Dev)", env!("CARGO_PKG_VERSION")) + format!("Phraser v{} (Dev)", env!("CARGO_PKG_VERSION")) } else { - format!("Parler v{}", env!("CARGO_PKG_VERSION")) + format!("Phraser v{}", env!("CARGO_PKG_VERSION")) }; let version_i = MenuItem::with_id(app, "version", &version_label, false, None::<&str>) .expect("failed to create version item"); diff --git a/src-tauri/src/tray_i18n.rs b/src-tauri/src/tray_i18n.rs index 846faba55..7dbbab95a 100644 --- a/src-tauri/src/tray_i18n.rs +++ b/src-tauri/src/tray_i18n.rs @@ -34,3 +34,82 @@ pub fn get_tray_translations(locale: Option) -> TrayStrings { .cloned() .expect("English translations must exist") } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn get_language_code_strips_region() { + assert_eq!(get_language_code("en-US"), "en"); + assert_eq!(get_language_code("fr-FR"), "fr"); + assert_eq!(get_language_code("zh-TW"), "zh"); + } + + #[test] + fn get_language_code_handles_underscore() { + assert_eq!(get_language_code("pt_BR"), "pt"); + } + + #[test] + fn get_language_code_returns_bare_code() { + assert_eq!(get_language_code("en"), "en"); + assert_eq!(get_language_code("de"), "de"); + } + + #[test] + fn get_language_code_empty_returns_empty() { + // Empty input returns "" — the caller (get_tray_translations) handles + // the fallback to English when the code misses in TRANSLATIONS. + let code = get_language_code(""); + assert_eq!(code, ""); + } + + #[test] + fn translations_none_returns_english() { + let strings = get_tray_translations(None); + // English quit should be "Quit" + assert_eq!(strings.quit, "Quit"); + } + + #[test] + fn translations_english_explicit() { + let strings = get_tray_translations(Some("en".to_string())); + assert_eq!(strings.quit, "Quit"); + assert!(!strings.settings.is_empty()); + } + + #[test] + fn translations_with_region_code() { + let strings = get_tray_translations(Some("en-US".to_string())); + assert_eq!(strings.quit, "Quit"); + } + + #[test] + fn translations_unknown_falls_back_to_english() { + let strings = get_tray_translations(Some("xx-XX".to_string())); + assert_eq!(strings.quit, "Quit"); + } + + #[test] + fn translations_french_has_content() { + let strings = get_tray_translations(Some("fr".to_string())); + assert!(!strings.quit.is_empty()); + assert!(!strings.settings.is_empty()); + } + + #[test] + fn translations_map_has_english() { + assert!(TRANSLATIONS.contains_key("en")); + } + + #[test] + fn translations_map_has_multiple_languages() { + // We expect at least 10 languages + assert!( + TRANSLATIONS.len() >= 10, + "Expected at least 10 translation languages, found {}", + TRANSLATIONS.len() + ); + } +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 175f1992a..23da0d452 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,8 +1,8 @@ { "$schema": "https://schema.tauri.app/config/2", - "productName": "Parler", - "version": "0.7.14", - "identifier": "com.melvynx.parler", + "productName": "Phraser", + "version": "0.7.15", + "identifier": "com.newblacc.phraser", "build": { "beforeDevCommand": "bun run dev", "devUrl": "http://localhost:1420", @@ -14,7 +14,7 @@ "windows": [ { "label": "main", - "title": "Parler", + "title": "Phraser", "width": 680, "height": 570, "minWidth": 680, @@ -25,12 +25,12 @@ } ], "security": { - "csp": null, + "csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'", "assetProtocol": { "enable": true, "scope": { - "allow": ["**"], - "requireLiteralLeadingDot": false + "allow": ["$APPDATA/recordings/**"], + "requireLiteralLeadingDot": true } } } @@ -77,7 +77,7 @@ "updater": { "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IERBQTU3OTUyOTRCQ0VFRTAKUldUZzdyeVVVbm1sMm44Vm0wWG5CL0FTMmdsNEx5Y1hwQVFmV1RNMzVMT3VHSnhITWhoYkcwYjkK", "endpoints": [ - "https://github.com/Melvynx/Parler/releases/latest/download/latest.json" + "https://github.com/newblacc/Phraser/releases/latest/download/latest.json" ] } } diff --git a/src-tauri/tauri.dev.conf.json b/src-tauri/tauri.dev.conf.json index 82f7289da..f74883518 100644 --- a/src-tauri/tauri.dev.conf.json +++ b/src-tauri/tauri.dev.conf.json @@ -1,12 +1,12 @@ { "$schema": "https://schema.tauri.app/config/2", - "productName": "ParlerDev", - "identifier": "com.melvynx.parler.dev", + "productName": "PhraserDev", + "identifier": "com.newblacc.phraser.dev", "app": { "windows": [ { "label": "main", - "title": "ParlerDev", + "title": "PhraserDev", "width": 680, "height": 570, "minWidth": 680, diff --git a/src-tauri/tests/branding_consistency.rs b/src-tauri/tests/branding_consistency.rs new file mode 100644 index 000000000..75a1596e6 --- /dev/null +++ b/src-tauri/tests/branding_consistency.rs @@ -0,0 +1,157 @@ +//! Tests that verify the "Phraser" branding is consistent across all config files. +//! These catch accidental regressions where someone re-introduces "Parler" in configs. + +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::OnceLock; + +fn crate_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) +} + +fn project_root() -> PathBuf { + crate_dir() + .parent() + .expect("src-tauri must be a subdirectory of the project root") + .to_path_buf() +} + +fn read_json(path: &Path) -> serde_json::Value { + let content = + fs::read_to_string(path).unwrap_or_else(|e| panic!("Failed to read {:?}: {}", path, e)); + serde_json::from_str(&content).unwrap_or_else(|e| panic!("Failed to parse {:?}: {}", path, e)) +} + +fn tauri_conf() -> &'static serde_json::Value { + static CONF: OnceLock = OnceLock::new(); + CONF.get_or_init(|| read_json(&crate_dir().join("tauri.conf.json"))) +} + +fn tauri_dev_conf() -> &'static serde_json::Value { + static CONF: OnceLock = OnceLock::new(); + CONF.get_or_init(|| read_json(&crate_dir().join("tauri.dev.conf.json"))) +} + +fn cargo_toml_content() -> &'static String { + static CONTENT: OnceLock = OnceLock::new(); + CONTENT.get_or_init(|| { + fs::read_to_string(crate_dir().join("Cargo.toml")).expect("Failed to read Cargo.toml") + }) +} + +#[test] +fn tauri_conf_product_name_is_phraser() { + assert_eq!(tauri_conf()["productName"].as_str().unwrap(), "Phraser"); +} + +#[test] +fn tauri_conf_identifier_is_phraser() { + assert_eq!( + tauri_conf()["identifier"].as_str().unwrap(), + "com.newblacc.phraser" + ); +} + +#[test] +fn tauri_conf_window_title_is_phraser() { + let title = tauri_conf()["app"]["windows"][0]["title"].as_str().unwrap(); + assert_eq!(title, "Phraser"); +} + +#[test] +fn tauri_conf_updater_endpoint_uses_phraser() { + let endpoint = tauri_conf()["plugins"]["updater"]["endpoints"][0] + .as_str() + .unwrap(); + assert!( + endpoint.contains("/Phraser/"), + "Updater endpoint should reference Phraser repo, got: {}", + endpoint + ); +} + +#[test] +fn tauri_dev_conf_product_name_is_phraser_dev() { + assert_eq!( + tauri_dev_conf()["productName"].as_str().unwrap(), + "PhraserDev" + ); +} + +#[test] +fn tauri_dev_conf_identifier_is_phraser_dev() { + assert_eq!( + tauri_dev_conf()["identifier"].as_str().unwrap(), + "com.newblacc.phraser.dev" + ); +} + +#[test] +fn cargo_toml_package_name_is_phraser() { + assert!( + cargo_toml_content().contains("name = \"phraser\""), + "Cargo.toml [package] name should be 'phraser'" + ); +} + +#[test] +fn cargo_toml_lib_name_is_phraser_app_lib() { + assert!( + cargo_toml_content().contains("name = \"phraser_app_lib\""), + "Cargo.toml [lib] name should be 'phraser_app_lib'" + ); +} + +#[test] +fn package_json_name_is_phraser() { + let conf = read_json(&project_root().join("package.json")); + assert_eq!(conf["name"].as_str().unwrap(), "phraser-app"); +} + +#[test] +fn index_html_title_is_phraser() { + let content = + fs::read_to_string(project_root().join("index.html")).expect("Failed to read index.html"); + assert!( + content.contains("Phraser"), + "index.html title should be 'Phraser'" + ); +} + +#[test] +fn no_stale_parler_in_tauri_conf() { + let content = fs::read_to_string(crate_dir().join("tauri.conf.json")) + .expect("Failed to read tauri.conf.json"); + assert!( + !content.contains("\"Parler\""), + "tauri.conf.json should not contain the old name 'Parler'" + ); + assert!( + !content.contains("com.newblacc.parler"), + "tauri.conf.json should not contain old bundle id 'com.newblacc.parler'" + ); +} + +#[test] +fn no_stale_parler_in_tauri_dev_conf() { + let content = fs::read_to_string(crate_dir().join("tauri.dev.conf.json")) + .expect("Failed to read tauri.dev.conf.json"); + assert!( + !content.contains("Parler"), + "tauri.dev.conf.json should not contain 'Parler'" + ); + assert!( + !content.contains("com.newblacc.parler"), + "tauri.dev.conf.json should not contain old bundle id" + ); +} + +#[test] +fn log_file_name_is_phraser() { + let content = + fs::read_to_string(crate_dir().join("src/lib.rs")).expect("Failed to read lib.rs"); + assert!( + content.contains("file_name: Some(\"phraser\""), + "lib.rs log file name should be 'phraser'" + ); +} diff --git a/src-tauri/tests/i18n_branding.rs b/src-tauri/tests/i18n_branding.rs new file mode 100644 index 000000000..4a8d96231 --- /dev/null +++ b/src-tauri/tests/i18n_branding.rs @@ -0,0 +1,70 @@ +//! Tests that verify "Parler" has been fully replaced with "Phraser" in all i18n files. +//! The only allowed exception is the French word "parler" (lowercase, meaning "to speak"). + +use std::fs; +use std::path::PathBuf; + +fn locales_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("src-tauri must be a subdirectory of the project root") + .join("src/i18n/locales") +} + +fn translation_files() -> Vec { + let base = locales_dir(); + let mut files = Vec::new(); + for entry in fs::read_dir(&base).expect("Failed to read i18n locales directory") { + let entry = entry.unwrap(); + let path = entry.path().join("translation.json"); + if path.exists() { + files.push(path); + } + } + files.sort(); + files +} + +#[test] +fn all_translation_files_exist() { + let files = translation_files(); + assert!( + files.len() >= 17, + "Expected at least 17 translation files, found {}", + files.len() + ); +} + +#[test] +fn no_capitalized_parler_in_translations() { + for path in translation_files() { + let content = fs::read_to_string(&path) + .unwrap_or_else(|e| panic!("Failed to read {:?}: {}", path, e)); + assert!( + !content.contains("\"Parler"), + "{:?} still contains the old app name 'Parler'", + path + ); + } +} + +#[test] +fn phraser_present_in_english_translation() { + let content = fs::read_to_string(locales_dir().join("en/translation.json")) + .expect("Failed to read English translation"); + assert!( + content.contains("Phraser"), + "English translation should contain 'Phraser'" + ); +} + +#[test] +fn french_parler_lowercase_is_allowed() { + let path = locales_dir().join("fr/translation.json"); + let content = fs::read_to_string(&path) + .expect("French translation file must exist at fr/translation.json"); + assert!( + !content.contains("\"Parler"), + "French translation should not contain the old app name 'Parler' (capitalized)" + ); +} diff --git a/src/App.tsx b/src/App.tsx index de2e252c9..508d89bd5 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -13,7 +13,7 @@ import Footer from "./components/footer"; import Onboarding, { AccessibilityOnboarding } from "./components/onboarding"; import { Sidebar, SidebarSection, SECTIONS_CONFIG } from "./components/Sidebar"; import { useSettings } from "./hooks/useSettings"; -import { useSettingsStore } from "./stores/settingsStore"; +import { useAudioDeviceStore } from "./stores/audioDeviceStore"; import { commands } from "@/bindings"; import { getLanguageDirection, initializeRTL } from "@/lib/utils/rtl"; @@ -37,10 +37,10 @@ function App() { useState("general"); const { settings, updateSetting } = useSettings(); const direction = getLanguageDirection(i18n.language); - const refreshAudioDevices = useSettingsStore( + const refreshAudioDevices = useAudioDeviceStore( (state) => state.refreshAudioDevices, ); - const refreshOutputDevices = useSettingsStore( + const refreshOutputDevices = useAudioDeviceStore( (state) => state.refreshOutputDevices, ); const hasCompletedPostOnboardingInit = useRef(false); diff --git a/src/bindings.ts b/src/bindings.ts index d370fa769..9b5dd5022 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -843,7 +843,7 @@ export type ModelUnloadTimeout = "never" | "immediately" | "min_2" | "min_5" | " export type OverlayPosition = "none" | "top" | "bottom" export type PasteMethod = "ctrl_v" | "direct" | "none" | "shift_insert" | "ctrl_shift_v" | "external_script" export type PostProcessAction = { key: number; name: string; prompt: string; model?: string | null; provider_id?: string | null } -export type PostProcessProvider = { id: string; label: string; base_url: string; allow_base_url_edit?: boolean; models_endpoint?: string | null; supports_structured_output?: boolean } +export type PostProcessProvider = { id: string; label: string; base_url: string; allow_base_url_edit?: boolean; requires_api_key?: boolean; models_endpoint?: string | null; supports_structured_output?: boolean } export type RecordingRetentionPeriod = "never" | "preserve_limit" | "days_3" | "weeks_2" | "months_3" export type SavedProcessingModel = { id: string; provider_id: string; model_id: string; label: string } export type ShortcutBinding = { id: string; name: string; description: string; default_binding: string; current_binding: string } diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 5f16e0c07..fe1812cf7 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -1,7 +1,7 @@ import React from "react"; import { useTranslation } from "react-i18next"; import { Cog, FlaskConical, History, Info, Sparkles, Cpu } from "lucide-react"; -import ParlerTextLogo from "./icons/ParlerTextLogo"; +import PhraserTextLogo from "./icons/PhraserTextLogo"; import HandyHand from "./icons/HandyHand"; import { useSettings } from "../hooks/useSettings"; import { @@ -94,7 +94,7 @@ export const Sidebar: React.FC = ({ return (
- +
{availableSections.map((section) => { const Icon = section.icon; diff --git a/src/components/__tests__/Sidebar.test.tsx b/src/components/__tests__/Sidebar.test.tsx new file mode 100644 index 000000000..0207f0420 --- /dev/null +++ b/src/components/__tests__/Sidebar.test.tsx @@ -0,0 +1,54 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { Sidebar } from "../Sidebar"; +import { useSettings } from "@/hooks/useSettings"; +import { makeSettings } from "@/test/mockSettings"; + +vi.mock("@/hooks/useSettings"); +const mockUseSettings = vi.mocked(useSettings); + +beforeEach(() => { + vi.clearAllMocks(); + mockUseSettings.mockReturnValue(makeSettings({ debug_mode: false })); +}); + +describe("Sidebar", () => { + it("renders all always-visible sections", () => { + render(); + expect(screen.getByText("sidebar.general")).toBeInTheDocument(); + expect(screen.getByText("sidebar.models")).toBeInTheDocument(); + expect(screen.getByText("sidebar.advanced")).toBeInTheDocument(); + expect(screen.getByText("sidebar.postProcessing")).toBeInTheDocument(); + expect(screen.getByText("sidebar.history")).toBeInTheDocument(); + expect(screen.getByText("sidebar.about")).toBeInTheDocument(); + }); + + it("hides the debug section when debug_mode is false", () => { + mockUseSettings.mockReturnValue(makeSettings({ debug_mode: false })); + render(); + expect(screen.queryByText("sidebar.debug")).not.toBeInTheDocument(); + }); + + it("shows the debug section when debug_mode is true", () => { + mockUseSettings.mockReturnValue(makeSettings({ debug_mode: true })); + render(); + expect(screen.getByText("sidebar.debug")).toBeInTheDocument(); + }); + + it("calls onSectionChange with the clicked section id", async () => { + const onSectionChange = vi.fn(); + render( + , + ); + await userEvent.click(screen.getByText("sidebar.advanced")); + expect(onSectionChange).toHaveBeenCalledWith("advanced"); + }); + + it("highlights the active section", () => { + render(); + // The active section container has bg-logo-primary/80 class + const activeItem = screen.getByText("sidebar.models").closest("div"); + expect(activeItem).toHaveClass("bg-logo-primary/80"); + }); +}); diff --git a/src/components/icons/ParlerTextLogo.tsx b/src/components/icons/PhraserTextLogo.tsx similarity index 54% rename from src/components/icons/ParlerTextLogo.tsx rename to src/components/icons/PhraserTextLogo.tsx index adb1da871..76d971055 100644 --- a/src/components/icons/ParlerTextLogo.tsx +++ b/src/components/icons/PhraserTextLogo.tsx @@ -1,25 +1,22 @@ -const ParlerTextLogo = ({ +const PhraserTextLogo = ({ width, className, }: { width?: number; - height?: number; className?: string; }) => { return (
- PARLER + PHRASER
); }; -export default ParlerTextLogo; +export default PhraserTextLogo; diff --git a/src/components/onboarding/AccessibilityOnboarding.tsx b/src/components/onboarding/AccessibilityOnboarding.tsx index b8ad6ad45..f59c6174d 100644 --- a/src/components/onboarding/AccessibilityOnboarding.tsx +++ b/src/components/onboarding/AccessibilityOnboarding.tsx @@ -9,8 +9,8 @@ import { } from "tauri-plugin-macos-permissions-api"; import { toast } from "sonner"; import { commands } from "@/bindings"; -import { useSettingsStore } from "@/stores/settingsStore"; -import ParlerTextLogo from "../icons/ParlerTextLogo"; +import { useAudioDeviceStore } from "@/stores/audioDeviceStore"; +import PhraserTextLogo from "../icons/PhraserTextLogo"; import { Keyboard, Mic, Check, Loader2 } from "lucide-react"; interface AccessibilityOnboardingProps { @@ -28,10 +28,10 @@ const AccessibilityOnboarding: React.FC = ({ onComplete, }) => { const { t } = useTranslation(); - const refreshAudioDevices = useSettingsStore( + const refreshAudioDevices = useAudioDeviceStore( (state) => state.refreshAudioDevices, ); - const refreshOutputDevices = useSettingsStore( + const refreshOutputDevices = useAudioDeviceStore( (state) => state.refreshOutputDevices, ); const [isMacOS, setIsMacOS] = useState(null); @@ -231,7 +231,7 @@ const AccessibilityOnboarding: React.FC = ({ return (
- +
diff --git a/src/components/onboarding/Onboarding.tsx b/src/components/onboarding/Onboarding.tsx index b58bf5648..11dc6ddb8 100644 --- a/src/components/onboarding/Onboarding.tsx +++ b/src/components/onboarding/Onboarding.tsx @@ -4,7 +4,7 @@ import { toast } from "sonner"; import type { ModelInfo } from "@/bindings"; import type { ModelCardStatus } from "./ModelCard"; import ModelCard from "./ModelCard"; -import ParlerTextLogo from "../icons/ParlerTextLogo"; +import PhraserTextLogo from "../icons/PhraserTextLogo"; import { useModelStore } from "../../stores/modelStore"; interface OnboardingProps { @@ -81,7 +81,7 @@ const Onboarding: React.FC = ({ onModelSelected }) => { return (
- +

{t("onboarding.subtitle")}

diff --git a/src/components/settings/__tests__/AboutSettings.test.tsx b/src/components/settings/__tests__/AboutSettings.test.tsx new file mode 100644 index 000000000..4b319d03a --- /dev/null +++ b/src/components/settings/__tests__/AboutSettings.test.tsx @@ -0,0 +1,103 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { useSettings } from "@/hooks/useSettings"; +import { makeSettings } from "@/test/mockSettings"; + +import { AboutSettings } from "../about/AboutSettings"; + +vi.mock("@/hooks/useSettings"); +const mockUseSettings = vi.mocked(useSettings); + +vi.mock("@tauri-apps/api/app", () => ({ + getVersion: vi.fn().mockResolvedValue("1.2.3"), +})); + +vi.mock("@tauri-apps/plugin-opener", () => ({ + openUrl: vi.fn().mockResolvedValue(undefined), +})); + +// AppDataDirectory and LogDirectory make Tauri calls — stub them. +vi.mock("../AppDataDirectory", () => ({ + AppDataDirectory: () =>
, +})); +vi.mock("../debug", () => ({ + LogDirectory: () =>
, +})); + +beforeEach(() => { + vi.clearAllMocks(); + mockUseSettings.mockReturnValue(makeSettings({ app_language: "en" })); +}); + +describe("AboutSettings", () => { + it("renders the about title", async () => { + render(); + await waitFor(() => { + expect(screen.getByText("settings.about.title")).toBeInTheDocument(); + }); + }); + + it("displays the app version fetched from Tauri", async () => { + render(); + await waitFor(() => { + expect(screen.getByText("v1.2.3")).toBeInTheDocument(); + }); + }); + + it("falls back to version string on error", async () => { + const { getVersion } = await import("@tauri-apps/api/app"); + vi.mocked(getVersion).mockRejectedValueOnce(new Error("fail")); + render(); + await waitFor(() => { + expect(screen.getByText("v0.1.2")).toBeInTheDocument(); + }); + }); + + it("renders the donate button", async () => { + render(); + await waitFor(() => { + expect( + screen.getByRole("button", { + name: "settings.about.supportDevelopment.button", + }), + ).toBeInTheDocument(); + }); + }); + + it("opens the donate URL when donate button is clicked", async () => { + const { openUrl } = await import("@tauri-apps/plugin-opener"); + render(); + await waitFor(() => + screen.getByRole("button", { + name: "settings.about.supportDevelopment.button", + }), + ); + await userEvent.click( + screen.getByRole("button", { + name: "settings.about.supportDevelopment.button", + }), + ); + expect(openUrl).toHaveBeenCalledWith(expect.stringContaining("donate")); + }); + + it("renders the source code button", async () => { + render(); + await waitFor(() => { + expect( + screen.getByRole("button", { + name: "settings.about.sourceCode.button", + }), + ).toBeInTheDocument(); + }); + }); + + it("renders the acknowledgments section", async () => { + render(); + await waitFor(() => { + expect( + screen.getByText("settings.about.acknowledgments.title"), + ).toBeInTheDocument(); + }); + }); +}); diff --git a/src/components/settings/__tests__/AudioFeedback.test.tsx b/src/components/settings/__tests__/AudioFeedback.test.tsx new file mode 100644 index 000000000..2c2a84202 --- /dev/null +++ b/src/components/settings/__tests__/AudioFeedback.test.tsx @@ -0,0 +1,70 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { AudioFeedback } from "../AudioFeedback"; +import { useSettings } from "@/hooks/useSettings"; + +vi.mock("@/hooks/useSettings"); + +const mockUseSettings = vi.mocked(useSettings); + +function makeSettings(audioFeedback: boolean, isUpdating = false) { + return { + getSetting: vi.fn((key: string) => + key === "audio_feedback" ? audioFeedback : undefined, + ), + updateSetting: vi.fn().mockResolvedValue(undefined), + isUpdating: vi.fn().mockReturnValue(isUpdating), + } as unknown as ReturnType; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("AudioFeedback", () => { + it("renders the audio feedback label", () => { + mockUseSettings.mockReturnValue(makeSettings(false)); + render(); + expect( + screen.getByText("settings.sound.audioFeedback.label"), + ).toBeInTheDocument(); + }); + + it("toggle is unchecked when audio_feedback is false", () => { + mockUseSettings.mockReturnValue(makeSettings(false)); + render(); + expect(screen.getByRole("checkbox")).not.toBeChecked(); + }); + + it("toggle is checked when audio_feedback is true", () => { + mockUseSettings.mockReturnValue(makeSettings(true)); + render(); + expect(screen.getByRole("checkbox")).toBeChecked(); + }); + + it("calls updateSetting('audio_feedback', true) when toggled on", async () => { + const settings = makeSettings(false); + mockUseSettings.mockReturnValue(settings); + render(); + await userEvent.click(screen.getByRole("checkbox")); + expect(settings.updateSetting).toHaveBeenCalledWith("audio_feedback", true); + }); + + it("calls updateSetting('audio_feedback', false) when toggled off", async () => { + const settings = makeSettings(true); + mockUseSettings.mockReturnValue(settings); + render(); + await userEvent.click(screen.getByRole("checkbox")); + expect(settings.updateSetting).toHaveBeenCalledWith( + "audio_feedback", + false, + ); + }); + + it("toggle is disabled while isUpdating", () => { + mockUseSettings.mockReturnValue(makeSettings(false, true)); + render(); + expect(screen.getByRole("checkbox")).toBeDisabled(); + }); +}); diff --git a/src/components/settings/__tests__/CustomWords.test.tsx b/src/components/settings/__tests__/CustomWords.test.tsx new file mode 100644 index 000000000..d38f37e05 --- /dev/null +++ b/src/components/settings/__tests__/CustomWords.test.tsx @@ -0,0 +1,132 @@ +import { render, screen, fireEvent } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { useSettings } from "@/hooks/useSettings"; +import { makeSettings } from "@/test/mockSettings"; +import { toast } from "sonner"; + +import { CustomWords } from "../CustomWords"; + +vi.mock("@/hooks/useSettings"); +const mockUseSettings = vi.mocked(useSettings); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("CustomWords", () => { + it("renders the title", () => { + mockUseSettings.mockReturnValue(makeSettings({ custom_words: [] })); + render(); + expect( + screen.getByText("settings.advanced.customWords.title"), + ).toBeInTheDocument(); + }); + + it("renders the placeholder on the input", () => { + mockUseSettings.mockReturnValue(makeSettings({ custom_words: [] })); + render(); + expect( + screen.getByPlaceholderText("settings.advanced.customWords.placeholder"), + ).toBeInTheDocument(); + }); + + it("shows existing words as buttons", () => { + mockUseSettings.mockReturnValue( + makeSettings({ custom_words: ["hello", "world"] }), + ); + render(); + expect(screen.getByText("hello")).toBeInTheDocument(); + expect(screen.getByText("world")).toBeInTheDocument(); + }); + + it("add button is disabled when input is empty", () => { + mockUseSettings.mockReturnValue(makeSettings({ custom_words: [] })); + render(); + const addButton = screen.getByRole("button", { + name: "settings.advanced.customWords.add", + }); + expect(addButton).toBeDisabled(); + }); + + it("add button is disabled when input contains a space", async () => { + mockUseSettings.mockReturnValue(makeSettings({ custom_words: [] })); + render(); + const input = screen.getByPlaceholderText( + "settings.advanced.customWords.placeholder", + ); + await userEvent.type(input, "two words"); + const addButton = screen.getByRole("button", { + name: "settings.advanced.customWords.add", + }); + expect(addButton).toBeDisabled(); + }); + + it("calls updateSetting with new word when add button is clicked", async () => { + const settings = makeSettings({ custom_words: [] }); + mockUseSettings.mockReturnValue(settings); + render(); + const input = screen.getByPlaceholderText( + "settings.advanced.customWords.placeholder", + ); + await userEvent.type(input, "newword"); + fireEvent.click( + screen.getByRole("button", { + name: "settings.advanced.customWords.add", + }), + ); + expect(settings.updateSetting).toHaveBeenCalledWith("custom_words", [ + "newword", + ]); + }); + + it("calls updateSetting when Enter key is pressed", async () => { + const settings = makeSettings({ custom_words: [] }); + mockUseSettings.mockReturnValue(settings); + render(); + const input = screen.getByPlaceholderText( + "settings.advanced.customWords.placeholder", + ); + await userEvent.type(input, "newword{Enter}"); + expect(settings.updateSetting).toHaveBeenCalledWith("custom_words", [ + "newword", + ]); + }); + + it("shows toast error on duplicate word", async () => { + mockUseSettings.mockReturnValue(makeSettings({ custom_words: ["hello"] })); + render(); + const input = screen.getByPlaceholderText( + "settings.advanced.customWords.placeholder", + ); + await userEvent.type(input, "hello{Enter}"); + expect(vi.mocked(toast.error)).toHaveBeenCalled(); + }); + + it("calls updateSetting to remove a word when its button is clicked", () => { + const settings = makeSettings({ custom_words: ["hello", "world"] }); + mockUseSettings.mockReturnValue(settings); + render(); + // Both word buttons share the same aria-label key — click the first one ("hello"). + const removeButtons = screen.getAllByRole("button", { + name: "settings.advanced.customWords.remove", + }); + fireEvent.click(removeButtons[0]); + expect(settings.updateSetting).toHaveBeenCalledWith("custom_words", [ + "world", + ]); + }); + + it("strips special chars from input before adding", async () => { + const settings = makeSettings({ custom_words: [] }); + mockUseSettings.mockReturnValue(settings); + render(); + const input = screen.getByPlaceholderText( + "settings.advanced.customWords.placeholder", + ); + await userEvent.type(input, "hel { + vi.clearAllMocks(); +}); + +// ─── OutputDeviceSelector ──────────────────────────────────────────────────── + +describe("OutputDeviceSelector", () => { + it("renders the output device title", () => { + mockUseSettings.mockReturnValue( + makeSettings({ selected_output_device: "default" }), + ); + render(); + expect( + screen.getByText("settings.sound.outputDevice.title"), + ).toBeInTheDocument(); + }); + + it("shows the selected device name", () => { + mockUseSettings.mockReturnValue( + makeSettings( + { selected_output_device: "default" }, + { outputDevices: [DEFAULT_OUTPUT] }, + ), + ); + render(); + expect(screen.getByRole("button", { name: /Default/ })).toBeInTheDocument(); + }); + + it("calls updateSetting when a device is selected", async () => { + const settings = makeSettings( + { selected_output_device: "default" }, + { outputDevices: [DEFAULT_OUTPUT] }, + ); + mockUseSettings.mockReturnValue(settings); + render(); + await userEvent.click(screen.getByRole("button", { name: /Default/ })); + // After dropdown opens, two spans with "Default" are visible — click the option (last one) + const options = screen.getAllByText("Default"); + await userEvent.click(options[options.length - 1]); + expect(settings.updateSetting).toHaveBeenCalledWith( + "selected_output_device", + "Default", + ); + }); + + it("calls resetSetting when reset button is clicked", () => { + const settings = makeSettings({ selected_output_device: "default" }); + mockUseSettings.mockReturnValue(settings); + render(); + // Only actual diff --git a/src/components/settings/models/ModelsSettings.tsx b/src/components/settings/models/ModelsSettings.tsx index b41b513a2..36dc7d18e 100644 --- a/src/components/settings/models/ModelsSettings.tsx +++ b/src/components/settings/models/ModelsSettings.tsx @@ -1,4 +1,10 @@ -import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; import { useTranslation } from "react-i18next"; import { ask } from "@tauri-apps/plugin-dialog"; import { ChevronDown, Globe, RefreshCcw, X } from "lucide-react"; @@ -26,11 +32,13 @@ const ProcessingModelsSection: React.FC = () => { refreshSettings, fetchPostProcessModels, updatePostProcessApiKey, + updatePostProcessBaseUrl, postProcessModelOptions, } = useSettings(); const [isAdding, setIsAdding] = useState(false); const [selectedProviderId, setSelectedProviderId] = useState(""); const [apiKey, setApiKey] = useState(""); + const [baseUrl, setBaseUrl] = useState(""); const [selectedModel, setSelectedModel] = useState(""); const [isFetching, setIsFetching] = useState(false); @@ -48,15 +56,18 @@ const ProcessingModelsSection: React.FC = () => { [availableModels], ); + const selectedProvider = providers.find((p) => p.id === selectedProviderId); + const handleProviderChange = useCallback( (providerId: string) => { setSelectedProviderId(providerId); setSelectedModel(""); - const existingKey = - settings?.post_process_api_keys?.[providerId] ?? ""; + const existingKey = settings?.post_process_api_keys?.[providerId] ?? ""; setApiKey(existingKey); + const provider = providers.find((p) => p.id === providerId); + setBaseUrl(provider?.base_url ?? ""); }, - [settings], + [settings, providers], ); const handleFetchModels = useCallback(async () => { @@ -64,13 +75,24 @@ const ProcessingModelsSection: React.FC = () => { if (apiKey.trim()) { await updatePostProcessApiKey(selectedProviderId, apiKey.trim()); } + if (selectedProvider?.allow_base_url_edit && baseUrl.trim()) { + await updatePostProcessBaseUrl(selectedProviderId, baseUrl.trim()); + } setIsFetching(true); try { await fetchPostProcessModels(selectedProviderId); } finally { setIsFetching(false); } - }, [selectedProviderId, apiKey, fetchPostProcessModels, updatePostProcessApiKey]); + }, [ + selectedProviderId, + apiKey, + baseUrl, + selectedProvider, + fetchPostProcessModels, + updatePostProcessApiKey, + updatePostProcessBaseUrl, + ]); const handleSave = useCallback(async () => { if (!selectedProviderId || !selectedModel) return; @@ -109,6 +131,7 @@ const ProcessingModelsSection: React.FC = () => { setSelectedProviderId(""); setSelectedModel(""); setApiKey(""); + setBaseUrl(""); }, []); return ( @@ -160,20 +183,37 @@ const ProcessingModelsSection: React.FC = () => { {selectedProviderId && ( <> -
- - setApiKey(e.target.value)} - placeholder={t( - "settings.models.processingModels.apiKeyPlaceholder", - )} - variant="compact" - /> -
+ {selectedProvider?.allow_base_url_edit && ( +
+ + setBaseUrl(e.target.value)} + placeholder="http://localhost:11434/v1" + variant="compact" + /> +
+ )} + + {selectedProvider?.requires_api_key !== false && ( +
+ + setApiKey(e.target.value)} + placeholder={t( + "settings.models.processingModels.apiKeyPlaceholder", + )} + variant="compact" + /> +
+ )}