[
  {
    "path": ".cargo/config.toml",
    "content": "[build]\n"
  },
  {
    "path": ".github/FUNDING.yml",
    "content": "github: cjpais\ncustom: [\"https://handy.computer/donate\", \"https://www.paypal.me/cjpais\"]\nbuy_me_a_coffee: cjpais\nko_fi: cjpais\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/bug_report.md",
    "content": "---\nname: Bug Report\nabout: Create a report to help us improve Handy\ntitle: \"[BUG] \"\nlabels: [\"bug\"]\nassignees: \"\"\n---\n\n## Before You Submit\n\n**Please search [existing issues](https://github.com/cjpais/Handy/issues) to avoid duplicates.** Your bug may already be reported! Right now it's just me maintaining this project so many issues can be overwhelming! Help me out by checking first.\n\n## Bug Description\n\nA clear and concise description of what the bug is.\n\n## System Information\n\n**App Version:**\n\n<!-- You can find this in the app settings or about section -->\n\n**Operating System:**\n\n<!-- e.g., macOS 14.1, Windows 11, Ubuntu 22.04 -->\n\n**CPU:**\n\n<!-- e.g., Apple M2, Intel i7-12700K, AMD Ryzen 7 5800X -->\n\n**GPU:**\n\n<!-- e.g., Apple M2 GPU, NVIDIA RTX 4080, AMD RX 6800 XT, Intel UHD Graphics -->\n\n## Logs\n\n<!-- Please attach relevant logs to help us diagnose the issue. You can find the log directory by going to Settings > About in the app. -->\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/config.yml",
    "content": "blank_issues_enabled: false\ncontact_links:\n  - name: ✏️ Post-processing / Editing Transcripts\n    url: https://github.com/cjpais/Handy/discussions/168\n    about: Looking to edit, format, or post-process transcripts? Join this discussion\n  - name: ⌨️ Keyboard Shortcuts / Hotkeys\n    url: https://github.com/cjpais/Handy/discussions/211\n    about: Want different keyboard shortcuts or hotkey configurations? Join this discussion\n  - name: 💡 Feature Request or Idea\n    url: https://github.com/cjpais/Handy/discussions\n    about: Please post feature requests and ideas in our Discussions tab\n  - name: 💬 General Discussion\n    url: https://github.com/cjpais/Handy/discussions\n    about: Ask questions and discuss Handy with the community\n"
  },
  {
    "path": ".github/PULL_REQUEST_TEMPLATE.md",
    "content": "## Before Submitting This PR\n\n<!--\nHANDY IS UNDERGOING A FEATURE FREEZE. IF YOU ARE SUBMITTING A PR WHICH IS A NEW FEATURE THAT THE COMMUNITY HAS NOT ASKED FOR: PREPARE TO BE REJECTED. IF THE COMMUNITY HAS ASKED FOR IT, OR YOU HAVE EXPLICITLY GATEHRED SUPPORT IT MAY STILL BE CONSIDERED.\n\nBUG FIXES ARE THE TOP PRIORITY. THERE ARE 60+ ISSUES TO FIX.\n-->\n\n**Please confirm you have done the following:**\n\n- [ ] I have searched [existing issues](https://github.com/cjpais/Handy/issues) and [pull requests](https://github.com/cjpais/Handy/pulls) (including closed ones) to ensure this isn't a duplicate\n- [ ] I have read [CONTRIBUTING.md](https://github.com/cjpais/Handy/blob/main/CONTRIBUTING.md)\n\n**If this is a feature or change that was previously closed/rejected:**\n\n- [ ] I have explained in the description below why this should be reconsidered\n- [ ] I have gathered community feedback (link to discussion below)\n\n## Human Written Description\n\n<!-- Describe your changes clearly and concisely\n\nPlease write 2-3 sentences in your own words explaining:\n- What problem you noticed or idea you had\n- Why you think this change matters\n\nThis section should be YOUR thinking, not AI-generated text. Even if AI helped write the code, we want to hear from you directly. Your perspective as a human is what makes contributions meaningful. Your PR may be rejected if you do not\ninclude a human-written description.\n-->\n\n## Related Issues/Discussions\n\n<!-- Link to related issues, discussions, or previous PRs -->\n<!-- If reopening something previously closed, explain why this should be reconsidered -->\n\nFixes #\nDiscussion:\n\n## Community Feedback\n\n<!--\nPRs with community support are much more likely to be merged.\n\nFor features: Link to a discussion where community members have expressed interest.\nFor bug fixes: Link to the issue where others have confirmed the bug.\n\nIf you haven't gathered feedback yet, consider starting a discussion first:\nhttps://github.com/cjpais/Handy/discussions\n\nIt is not explicitly required to gather feedback, but it certainly helps your PR get merged.\n-->\n\n## Testing\n\n<!-- Describe how you tested your changes and if you need help getting additional testing -->\n\n## Screenshots/Videos (if applicable)\n\n<!-- Add screenshots or videos demonstrating the change -->\n\n## AI Assistance\n\n<!-- AI-assisted PRs are welcome! Just let us know so we can review appropriately. -->\n\n- [ ] No AI was used in this PR\n- [ ] AI was used (please describe below)\n\n**If AI was used:**\n\n- Tools used:\n- How extensively:\n"
  },
  {
    "path": ".github/workflows/build-test.yml",
    "content": "name: \"Build Test\"\n\non: workflow_dispatch\n\njobs:\n  build-test:\n    permissions:\n      contents: write\n    strategy:\n      fail-fast: false\n      matrix:\n        include:\n          - platform: \"macos-26\" # for Arm based macs (M1 and above). Uses macOS 26 for Apple Intelligence SDK.\n            args: \"--target aarch64-apple-darwin\"\n            target: \"aarch64-apple-darwin\"\n          - platform: \"macos-latest\" # for Intel based macs.\n            args: \"--target x86_64-apple-darwin\"\n            target: \"x86_64-apple-darwin\"\n          - platform: \"ubuntu-22.04\" # Build .deb on 22.04\n            args: \"--bundles deb\"\n            target: \"x86_64-unknown-linux-gnu\"\n          - platform: \"ubuntu-24.04\" # Build AppImage and RPM on 24.04\n            args: \"--bundles appimage,rpm\"\n            target: \"x86_64-unknown-linux-gnu\"\n          - platform: \"ubuntu-24.04-arm\" # Build for ARM64 Linux\n            args: \"--bundles appimage,deb,rpm\"\n            target: \"aarch64-unknown-linux-gnu\"\n          - platform: \"windows-latest\"\n            args: \"\"\n            target: \"x86_64-pc-windows-msvc\"\n          - platform: \"windows-11-arm\" # for ARM64 Windows runner\n            args: \"--target aarch64-pc-windows-msvc\"\n            target: \"aarch64-pc-windows-msvc\"\n\n    uses: ./.github/workflows/build.yml\n    with:\n      platform: ${{ matrix.platform }}\n      target: ${{ matrix.target }}\n      build-args: ${{ matrix.args }}\n      sign-binaries: true\n      asset-prefix: \"handy-test\"\n      upload-artifacts: true\n      is-debug-build: ${{ contains(matrix.args, '--debug') }}\n    secrets: inherit\n"
  },
  {
    "path": ".github/workflows/build.yml",
    "content": "name: \"Build\"\n\non:\n  workflow_call:\n    inputs:\n      platform:\n        required: true\n        type: string\n      target:\n        required: true\n        type: string\n      build-args:\n        required: false\n        type: string\n        default: \"\"\n      release-id:\n        required: false\n        type: string\n      asset-prefix:\n        required: false\n        type: string\n        default: \"handy\"\n      asset-name-pattern:\n        required: false\n        type: string\n        default: \"\"\n      upload-artifacts:\n        required: false\n        type: boolean\n        default: false\n      sign-binaries:\n        required: false\n        type: boolean\n        default: false\n      repository:\n        required: false\n        type: string\n      ref:\n        required: false\n        type: string\n        default: ${{ github.ref }}\n      is-debug-build:\n        required: false\n        type: boolean\n        default: false\n\nenv:\n  TSC_VERSION: \"0.9.0\"\n\njobs:\n  build:\n    permissions:\n      contents: write\n    runs-on: ${{ inputs.platform }}\n    steps:\n      - name: Enable long paths (Windows)\n        if: runner.os == 'Windows'\n        shell: pwsh\n        run: |\n          New-ItemProperty -Path \"HKLM:\\SYSTEM\\CurrentControlSet\\Control\\FileSystem\" `\n            -Name \"LongPathsEnabled\" -Value 1 -PropertyType DWORD -Force\n          git config --system core.longpaths true\n\n      - name: Checkout repository\n        uses: actions/checkout@v4\n        with:\n          repository: ${{ inputs.repository }}\n          ref: ${{ inputs.ref }}\n          fetch-depth: 0\n\n      - name: Get version from tauri.conf.json\n        id: get-version\n        shell: bash\n        run: |\n          VERSION=$(grep -o '\"version\": \"[^\"]*\"' src-tauri/tauri.conf.json | cut -d'\"' -f4)\n          echo \"Application version from tauri.conf.json: $VERSION\"\n          echo \"version=$VERSION\" >> \"$GITHUB_OUTPUT\"\n\n      - name: Determine build profile\n        id: build-profile\n        shell: bash\n        run: |\n          if [[ \"${{ inputs.is-debug-build }}\" == \"true\" ]]; then\n            echo \"profile=debug\" >> \"$GITHUB_OUTPUT\"\n            echo \"Build profile: debug\"\n          else\n            echo \"profile=release\" >> \"$GITHUB_OUTPUT\"\n            echo \"Build profile: release\"\n          fi\n\n      - name: Setup Bun\n        uses: oven-sh/setup-bun@v2\n\n      - name: install Rust stable\n        uses: dtolnay/rust-toolchain@stable\n        with:\n          # Those targets are only used on macos runners so it's in an `if` to slightly speed up windows and linux builds.\n          targets: ${{ contains(inputs.platform, 'macos') && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}\n\n      - name: Rust cache\n        uses: swatinem/rust-cache@v2\n        with:\n          workspaces: \"./src-tauri -> target\"\n          key: ${{ inputs.platform }}-${{ inputs.target }}\n\n      - name: install dependencies (ubuntu 24.04 x64)\n        if: contains(inputs.platform, 'ubuntu-24.04') && !contains(inputs.platform, 'arm')\n        run: |\n          sudo apt-get update\n          sudo apt-get install -y libappindicator3-dev librsvg2-dev patchelf libasound2-dev libopenblas-dev libx11-dev libxtst-dev libxrandr-dev libgtk-layer-shell0 libgtk-layer-shell-dev \\\n            libwebkit2gtk-4.1-0=2.44.0-2 \\\n            libwebkit2gtk-4.1-dev=2.44.0-2 \\\n            libjavascriptcoregtk-4.1-0=2.44.0-2 \\\n            libjavascriptcoregtk-4.1-dev=2.44.0-2 \\\n            gir1.2-javascriptcoregtk-4.1=2.44.0-2 \\\n            gir1.2-webkit2-4.1=2.44.0-2\n\n      - name: install dependencies (ubuntu 24.04 arm64)\n        if: contains(inputs.platform, 'ubuntu-24.04-arm')\n        run: |\n          sudo apt-get update\n          sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf libasound2-dev libopenblas-dev libx11-dev libxtst-dev libxrandr-dev libgtk-layer-shell0 libgtk-layer-shell-dev xdg-utils\n\n      - name: install dependencies (ubuntu 22.04)\n        if: contains(inputs.platform, 'ubuntu-22.04') && !contains(inputs.platform, 'arm')\n        run: |\n          sudo apt-get update\n          sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf libasound2-dev libopenblas-dev libx11-dev libxtst-dev libxrandr-dev libgtk-layer-shell0 libgtk-layer-shell-dev\n\n      - name: Verify gtk-layer-shell runtime dependency (Ubuntu)\n        if: contains(inputs.platform, 'ubuntu')\n        run: |\n          dpkg-query -W -f='${Status}\\n' libgtk-layer-shell0 | grep -q \"install ok installed\"\n          ldconfig -p | grep -q \"libgtk-layer-shell.so.0\"\n\n      - name: Install Vulkan SDK (Windows x64)\n        if: contains(inputs.platform, 'windows') && !contains(inputs.target, 'aarch64')\n        uses: humbletim/install-vulkan-sdk@v1.2\n        with:\n          version: 1.4.309.0\n          cache: true\n\n      # humbletim/install-vulkan-sdk@v1.2 cannot target Windows ARM64 yet.\n      # Download prebuilt binaries (Bin) + build headers/libs from source.\n      # See https://github.com/humbletim/install-vulkan-sdk/pull/22 for prebuilt ARM64 support progress.\n      - name: Prepare Vulkan SDK env (Windows ARM64)\n        if: contains(inputs.platform, 'windows') && contains(inputs.target, 'aarch64')\n        shell: pwsh\n        run: |\n          $sdkDir = Join-Path $env:GITHUB_WORKSPACE \"VULKAN_SDK\"\n          New-Item -ItemType Directory -Force -Path $sdkDir | Out-Null\n          Add-Content -Path $env:GITHUB_ENV -Value \"VULKAN_SDK=$sdkDir\"\n\n      - name: Download Vulkan SDK binaries (Windows ARM64)\n        if: contains(inputs.platform, 'windows') && contains(inputs.target, 'aarch64')\n        shell: pwsh\n        run: |\n          $url = \"https://sdk.lunarg.com/sdk/download/1.4.309.0/warm/InstallVulkanARM64-1.4.309.0.exe\"\n          $outFile = \"vulkan_sdk_arm.exe\"\n          Write-Host \"Downloading Vulkan SDK binaries from $url\"\n          Invoke-WebRequest -Uri $url -OutFile $outFile\n\n      - name: Extract Vulkan SDK binaries (Windows ARM64)\n        if: contains(inputs.platform, 'windows') && contains(inputs.target, 'aarch64')\n        shell: pwsh\n        run: |\n          $sdkDir = $env:VULKAN_SDK\n          $sevenZip = (Get-Command 7z.exe -ErrorAction Stop).Source\n          Write-Host \"Extracting binaries to $sdkDir\"\n          & $sevenZip x \"./vulkan_sdk_arm.exe\" \"-o$sdkDir\" -aoa\n          $binPath = Join-Path $sdkDir \"Bin\"\n          Add-Content -Path $env:GITHUB_PATH -Value $binPath\n          Write-Host \"Verifying glslc...\"\n          & (Join-Path $binPath \"glslc.exe\") --version\n\n      - name: Build Vulkan SDK headers and libs (Windows ARM64)\n        if: contains(inputs.platform, 'windows') && contains(inputs.target, 'aarch64')\n        uses: humbletim/setup-vulkan-sdk@v1.2.1\n        with:\n          vulkan-query-version: 1.4.309.0\n          vulkan-components: Vulkan-Headers, Vulkan-Loader\n          vulkan-use-cache: true\n\n      - name: Cache trusted-signing-cli\n        if: contains(inputs.platform, 'windows') && inputs.sign-binaries\n        id: cache-tsc\n        uses: actions/cache@v4\n        with:\n          path: ~/.cargo/bin/trusted-signing-cli*\n          key: trusted-signing-cli-${{ env.TSC_VERSION }}-${{ runner.os }}-${{ runner.arch }}\n\n      - name: Install trusted-signing-cli\n        if: contains(inputs.platform, 'windows') && inputs.sign-binaries && steps.cache-tsc.outputs.cache-hit != 'true'\n        run: cargo install trusted-signing-cli@${{ env.TSC_VERSION }}\n\n      - name: Prepare Vulkan SDK for Ubuntu 24.04\n        if: contains(inputs.platform, 'ubuntu-24.04') && !contains(inputs.platform, 'arm')\n        run: |\n          wget -qO- https://packages.lunarg.com/lunarg-signing-key-pub.asc | sudo tee /etc/apt/trusted.gpg.d/lunarg.asc\n          sudo wget -qO /etc/apt/sources.list.d/lunarg-vulkan-1.3.290-noble.list https://packages.lunarg.com/vulkan/1.3.290/lunarg-vulkan-1.3.290-noble.list\n          sudo apt update\n          sudo apt install vulkan-sdk -y\n          sudo apt-get install -y mesa-vulkan-drivers\n\n      - name: Prepare Vulkan SDK for Ubuntu ARM64\n        if: contains(inputs.platform, 'ubuntu') && contains(inputs.platform, 'arm')\n        uses: jakoch/install-vulkan-sdk-action@v1\n        with:\n          vulkan_version: 1.4.335.0\n          cache: true\n\n      - name: Install Vulkan runtime libraries (Ubuntu ARM64)\n        if: contains(inputs.platform, 'ubuntu') && contains(inputs.platform, 'arm')\n        run: |\n          sudo apt-get update\n          sudo apt-get install -y libvulkan-dev mesa-vulkan-drivers\n\n      - name: Prepare Vulkan SDK for Ubuntu 22.04\n        if: contains(inputs.platform, 'ubuntu-22.04') && !contains(inputs.platform, 'arm')\n        run: |\n          wget -qO- https://packages.lunarg.com/lunarg-signing-key-pub.asc | sudo tee /etc/apt/trusted.gpg.d/lunarg.asc\n          sudo wget -qO /etc/apt/sources.list.d/lunarg-vulkan-1.3.290-jammy.list https://packages.lunarg.com/vulkan/1.3.290/lunarg-vulkan-1.3.290-jammy.list\n          sudo apt update\n          sudo apt install vulkan-sdk -y\n          sudo apt-get install -y mesa-vulkan-drivers\n\n      - name: install frontend dependencies\n        if: ${{ !(contains(inputs.platform, 'windows') && contains(inputs.target, 'aarch64')) }}\n        run: bun install\n\n      - name: install frontend dependencies (Windows ARM64)\n        if: ${{ contains(inputs.platform, 'windows') && contains(inputs.target, 'aarch64') }}\n        run: bun install --cpu=arm64\n\n      - name: rustup install target\n        if: ${{ inputs.target != '' && !contains(inputs.target, 'x86_64-unknown-linux-gnu') && !contains(inputs.target, 'aarch64-unknown-linux-gnu') && !contains(inputs.target, 'x86_64-pc-windows-msvc') }}\n        run: rustup target add ${{ inputs.target }}\n\n      - name: import Apple Developer Certificate\n        if: contains(inputs.platform, 'macos') && inputs.sign-binaries\n        env:\n          APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}\n          APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}\n          KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}\n        run: |\n          echo $APPLE_CERTIFICATE | base64 --decode > certificate.p12\n          security create-keychain -p \"$KEYCHAIN_PASSWORD\" build.keychain\n          security default-keychain -s build.keychain\n          security unlock-keychain -p \"$KEYCHAIN_PASSWORD\" build.keychain\n          security set-keychain-settings -t 3600 -u build.keychain\n          security import certificate.p12 -k build.keychain -P \"$APPLE_CERTIFICATE_PASSWORD\" -T /usr/bin/codesign\n          security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k \"$KEYCHAIN_PASSWORD\" build.keychain\n          security find-identity -v -p codesigning build.keychain\n\n      - name: verify certificate\n        if: contains(inputs.platform, 'macos') && inputs.sign-binaries\n        run: |\n          CERT_INFO=$(security find-identity -v -p codesigning build.keychain | grep \"Developer ID Application\")\n          CERT_ID=$(echo \"$CERT_INFO\" | awk -F'\"' '{print $2}')\n          echo \"CERT_ID=$CERT_ID\" >> $GITHUB_ENV\n          echo \"Certificate imported.\"\n\n      - name: Patch asset name pattern\n        id: patch-release-name\n        shell: bash\n        if: ${{ inputs.release-id != '' && inputs.asset-name-pattern != '' }}\n        run: |\n          platform=\"${{ inputs.platform }}\"\n          replacement=\"$(echo ${platform} | sed -E 's/-latest//')\"\n          patched_platform=$(echo '${{ inputs.asset-name-pattern }}' | sed -E \"s/\\[platform\\]/${replacement}/\")\n          if [[ -n \"${{ inputs.asset-prefix }}\" ]]; then\n            patched_platform=\"${{ inputs.asset-prefix }}_${patched_platform}\"\n          fi\n          echo \"platform=${patched_platform}\" >> $GITHUB_OUTPUT\n\n      # whisper-rs-sys cmake builds create paths that exceed Windows MAX_PATH\n      # (260 chars). Shorten the target dir to keep paths under the limit.\n      - name: Shorten build path (Windows)\n        if: runner.os == 'Windows'\n        shell: pwsh\n        run: |\n          $drive = Split-Path -Qualifier $env:GITHUB_WORKSPACE\n          $targetDir = \"$drive\\t\"\n          New-Item -ItemType Directory -Force -Path $targetDir | Out-Null\n          echo \"CARGO_TARGET_DIR=$targetDir\" >> $env:GITHUB_ENV\n\n      # ggml requires clang for ARM. The VS generator determines the compiler\n      # via the toolset (-T flag) which can't be overridden from env vars.\n      # Use Ninja + clang-cl instead, which respects CMAKE_C_COMPILER.\n      - name: Configure cmake for ARM64 (Windows)\n        if: contains(inputs.platform, 'windows') && contains(inputs.target, 'aarch64')\n        shell: pwsh\n        run: |\n          echo \"CMAKE_GENERATOR=Ninja\" >> $env:GITHUB_ENV\n          echo \"CMAKE_C_COMPILER=clang-cl\" >> $env:GITHUB_ENV\n          echo \"CMAKE_CXX_COMPILER=clang-cl\" >> $env:GITHUB_ENV\n          echo \"CMAKE_C_COMPILER_TARGET=aarch64-pc-windows-msvc\" >> $env:GITHUB_ENV\n          echo \"CMAKE_CXX_COMPILER_TARGET=aarch64-pc-windows-msvc\" >> $env:GITHUB_ENV\n          echo \"CL=/EHsc\" >> $env:GITHUB_ENV\n\n      - name: Install ONNX Runtime (x86_64 macOS)\n        if: inputs.target == 'x86_64-apple-darwin'\n        shell: bash\n        run: |\n          ORT_VERSION=\"1.24.2\"\n          curl -L -o ort.tgz \"https://blob.handy.computer/onnxruntime-osx-x86_64-${ORT_VERSION}.tgz\"\n          tar xzf ort.tgz\n          ORT_DIR=\"$(pwd)/onnxruntime-osx-x86_64-${ORT_VERSION}\"\n          echo \"ORT_LIB_LOCATION=$ORT_DIR/lib\" >> $GITHUB_ENV\n          echo \"ORT_PREFER_DYNAMIC_LINK=1\" >> $GITHUB_ENV\n          # Bundle the versioned dylib (matches the install name @rpath/libonnxruntime.1.24.2.dylib)\n          jq --arg lib \"$ORT_DIR/lib/libonnxruntime.${ORT_VERSION}.dylib\" \\\n            '.bundle.macOS.frameworks = [$lib]' \\\n            src-tauri/tauri.conf.json > tmp.json && mv tmp.json src-tauri/tauri.conf.json\n\n      - name: Install ONNX Runtime (x86_64 Linux, Ubuntu 22.04)\n        if: contains(inputs.platform, 'ubuntu-22.04') && inputs.target == 'x86_64-unknown-linux-gnu'\n        shell: bash\n        run: |\n          ORT_VERSION=\"1.24.2\"\n          curl -L -o ort.tgz \"https://blob.handy.computer/onnxruntime-linux-x86_64-${ORT_VERSION}.tgz\"\n          tar xzf ort.tgz\n          ORT_DIR=\"$(pwd)/onnxruntime-linux-x86_64-${ORT_VERSION}\"\n          echo \"ORT_LIB_LOCATION=$ORT_DIR/lib\" >> $GITHUB_ENV\n          echo \"ORT_PREFER_DYNAMIC_LINK=1\" >> $GITHUB_ENV\n          # Resolve symlinks so the deb bundler gets real files (not broken symlinks)\n          for f in \"$ORT_DIR\"/lib/libonnxruntime.so*; do\n            if [ -L \"$f\" ]; then\n              cp -L \"$f\" \"$f.real\" && mv \"$f.real\" \"$f\"\n            fi\n          done\n          # Add the shared libs to the deb package under /usr/lib\n          # deb.files key = destination in package, value = source on disk\n          jq --arg so \"$ORT_DIR/lib/libonnxruntime.so\" \\\n             --arg so1 \"$ORT_DIR/lib/libonnxruntime.so.1\" \\\n             --arg sov \"$ORT_DIR/lib/libonnxruntime.so.1.24.2\" \\\n            '.bundle.linux.deb.files[\"/usr/lib/libonnxruntime.so\"] = $so | .bundle.linux.deb.files[\"/usr/lib/libonnxruntime.so.1\"] = $so1 | .bundle.linux.deb.files[\"/usr/lib/libonnxruntime.so.1.24.2\"] = $sov' \\\n            src-tauri/tauri.conf.json > tmp.json && mv tmp.json src-tauri/tauri.conf.json\n\n      - name: Build with Tauri\n        uses: tauri-apps/tauri-action@v0\n        env:\n          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n          APPLE_ID: ${{ inputs.sign-binaries && secrets.APPLE_ID || '' }}\n          APPLE_ID_PASSWORD: ${{ inputs.sign-binaries && secrets.APPLE_ID_PASSWORD || '' }}\n          APPLE_PASSWORD: ${{ inputs.sign-binaries && secrets.APPLE_PASSWORD || '' }}\n          APPLE_TEAM_ID: ${{ inputs.sign-binaries && secrets.APPLE_TEAM_ID || '' }}\n          APPLE_CERTIFICATE: ${{ inputs.sign-binaries && secrets.APPLE_CERTIFICATE || '' }}\n          APPLE_CERTIFICATE_PASSWORD: ${{ inputs.sign-binaries && secrets.APPLE_CERTIFICATE_PASSWORD || '' }}\n          APPLE_SIGNING_IDENTITY: ${{ inputs.sign-binaries && env.CERT_ID || '' }}\n          AZURE_CLIENT_ID: ${{ inputs.sign-binaries && secrets.AZURE_CLIENT_ID || '' }}\n          AZURE_CLIENT_SECRET: ${{ inputs.sign-binaries && secrets.AZURE_CLIENT_SECRET || '' }}\n          AZURE_TENANT_ID: ${{ inputs.sign-binaries && secrets.AZURE_TENANT_ID || '' }}\n          TAURI_SIGNING_PRIVATE_KEY: ${{ inputs.sign-binaries && secrets.TAURI_SIGNING_PRIVATE_KEY || '' }}\n          TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ inputs.sign-binaries && secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD || '' }}\n          WHISPER_NO_AVX: ${{ contains(inputs.platform, 'ubuntu') && !contains(inputs.platform, 'arm') && 'ON' || '' }}\n          WHISPER_NO_AVX2: ${{ contains(inputs.platform, 'ubuntu') && !contains(inputs.platform, 'arm') && 'ON' || '' }}\n        with:\n          tagName: ${{ inputs.release-id && format('v{0}', steps.get-version.outputs.version) || '' }}\n          releaseName: ${{ inputs.release-id && format('v{0}', steps.get-version.outputs.version) || '' }}\n          releaseId: ${{ inputs.release-id }}\n          assetNamePattern: ${{ steps.patch-release-name.outputs.platform }}\n          args: ${{ inputs.build-args }}\n\n      - name: Verify macOS dylib bundling\n        if: inputs.target == 'x86_64-apple-darwin'\n        shell: bash\n        run: |\n          APP=$(find src-tauri/target -name \"*.app\" -type d | head -1)\n          echo \"=== Frameworks contents ===\"\n          ls -la \"$APP/Contents/Frameworks/\" | grep onnx || true\n          echo \"=== Binary linked libs ===\"\n          otool -L \"$APP/Contents/MacOS/handy\" | grep onnx || true\n          echo \"=== Checking all @rpath deps are satisfied ===\"\n          # Extract every @rpath lib the binary needs\n          otool -L \"$APP/Contents/MacOS/handy\" | grep '@rpath/' | awk '{print $1}' | while read dep; do\n            libname=$(basename \"$dep\")\n            if [ ! -f \"$APP/Contents/Frameworks/$libname\" ]; then\n              echo \"MISSING: $libname not found in Frameworks/\"\n              exit 1\n            else\n              echo \"OK: $libname\"\n            fi\n          done\n\n      - name: Upload artifacts (macOS)\n        if: inputs.upload-artifacts && contains(inputs.platform, 'macos')\n        uses: actions/upload-artifact@v4\n        with:\n          name: ${{ inputs.asset-prefix }}-${{ inputs.target }}\n          path: |\n            src-tauri/target/${{ inputs.target }}/${{ steps.build-profile.outputs.profile }}/bundle/dmg/*.dmg\n            src-tauri/target/${{ inputs.target }}/${{ steps.build-profile.outputs.profile }}/bundle/macos/*.app\n          retention-days: 30\n\n      - name: Install FUSE for AppImage processing\n        if: contains(inputs.platform, 'ubuntu')\n        run: |\n          sudo apt-get update\n          sudo apt-get install -y fuse libfuse2\n\n      - name: Remove libwayland-client.so from AppImage\n        if: contains(inputs.platform, 'ubuntu')\n        run: |\n          # Find the AppImage file\n          APPIMAGE_PATH=$(find src-tauri/target/${{ steps.build-profile.outputs.profile }}/bundle/appimage -name \"*.AppImage\" | head -1)\n\n          if [ -n \"$APPIMAGE_PATH\" ]; then\n            echo \"Processing AppImage: $APPIMAGE_PATH\"\n\n            # Make AppImage executable\n            chmod +x \"$APPIMAGE_PATH\"\n\n            # Extract AppImage\n            cd \"$(dirname \"$APPIMAGE_PATH\")\"\n            APPIMAGE_NAME=$(basename \"$APPIMAGE_PATH\")\n\n            # Extract using the AppImage itself\n            \"./$APPIMAGE_NAME\" --appimage-extract\n\n            # Remove libwayland-client.so files\n            echo \"Removing libwayland-client.so files...\"\n            find squashfs-root -name \"libwayland-client.so*\" -type f -delete\n\n            # List what was removed for verification\n            echo \"Files remaining in lib directories:\"\n            find squashfs-root -name \"lib*\" -type d | head -5 | while read dir; do\n              echo \"Contents of $dir:\"\n              ls \"$dir\" | grep -E \"(wayland|fuse)\" || echo \"  No wayland/fuse libraries found\"\n            done\n\n            # Detect architecture and get appropriate appimagetool\n            if [[ \"$(uname -m)\" == \"aarch64\" ]]; then\n              APPIMAGETOOL_ARCH=\"aarch64\"\n            else\n              APPIMAGETOOL_ARCH=\"x86_64\"\n            fi\n\n            wget -q \"https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-${APPIMAGETOOL_ARCH}.AppImage\"\n            chmod +x \"appimagetool-${APPIMAGETOOL_ARCH}.AppImage\"\n\n            # Repackage AppImage with no-appstream to avoid warnings\n            ARCH=\"${APPIMAGETOOL_ARCH}\" \"./appimagetool-${APPIMAGETOOL_ARCH}.AppImage\" --no-appstream squashfs-root \"$APPIMAGE_NAME\"\n\n            # Clean up\n            rm -rf squashfs-root \"appimagetool-${APPIMAGETOOL_ARCH}.AppImage\"\n\n            echo \"libwayland-client.so removed from AppImage successfully\"\n          else\n            echo \"No AppImage found to process\"\n          fi\n\n      - name: Upload artifacts (Linux)\n        if: inputs.upload-artifacts && contains(inputs.platform, 'ubuntu')\n        uses: actions/upload-artifact@v4\n        with:\n          name: ${{ inputs.asset-prefix }}-${{ inputs.platform }}-${{ inputs.target }}\n          path: |\n            src-tauri/target/${{ steps.build-profile.outputs.profile }}/bundle/deb/*.deb\n            src-tauri/target/${{ steps.build-profile.outputs.profile }}/bundle/appimage/*.AppImage\n            src-tauri/target/${{ steps.build-profile.outputs.profile }}/bundle/rpm/*.rpm\n          retention-days: 30\n\n      - name: Resolve Windows artifact path\n        if: inputs.upload-artifacts && contains(inputs.platform, 'windows')\n        id: win-artifact-path\n        shell: pwsh\n        run: |\n          $base = if ($env:CARGO_TARGET_DIR) { $env:CARGO_TARGET_DIR } else { \"src-tauri/target\" }\n          echo \"base=$base\" >> $env:GITHUB_OUTPUT\n\n      - name: Upload artifacts (Windows)\n        if: inputs.upload-artifacts && contains(inputs.platform, 'windows')\n        uses: actions/upload-artifact@v4\n        with:\n          name: ${{ inputs.asset-prefix }}-${{ inputs.target }}\n          path: |\n            ${{ steps.win-artifact-path.outputs.base }}/${{ 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\n            ${{ steps.win-artifact-path.outputs.base }}/${{ 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\n          retention-days: 30\n"
  },
  {
    "path": ".github/workflows/code-quality.yml",
    "content": "name: \"code quality\"\non:\n  workflow_dispatch:\n  push:\n    branches: [main]\n    paths:\n      - \"src/**\"\n      - \"package.json\"\n      - \"bun.lock\"\n      - \".eslintrc*\"\n      - \"eslint.config.*\"\n      - \".prettierrc*\"\n      - \"tsconfig*\"\n      - \"tailwind.config.*\"\n      - \".github/workflows/**\"\n  pull_request:\n    paths:\n      - \"src/**\"\n      - \"package.json\"\n      - \"bun.lock\"\n      - \".eslintrc*\"\n      - \"eslint.config.*\"\n      - \".prettierrc*\"\n      - \"tsconfig*\"\n      - \"tailwind.config.*\"\n      - \".github/workflows/**\"\n\nconcurrency:\n  group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}\n  cancel-in-progress: true\n\njobs:\n  code-quality:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n\n      - uses: oven-sh/setup-bun@v2\n        with:\n          bun-version: latest\n\n      - name: Install dependencies\n        run: bun install --frozen-lockfile\n\n      - name: Check translation consistency\n        run: bun run check:translations\n\n      - name: Run ESLint\n        run: bun run lint\n\n      - name: Run prettier\n        run: bun run format:check\n"
  },
  {
    "path": ".github/workflows/main-build.yml",
    "content": "name: \"Main Branch Build\"\n\n# Runs the full cross-platform build on every push to main so breakage is\n# caught before a manual release is triggered. Artifacts are kept for 30 days\n# so any commit on main has a downloadable, testable build.\n\non:\n  push:\n    branches: [main]\n\nconcurrency:\n  group: ${{ github.workflow }}-${{ github.ref }}\n  cancel-in-progress: true\n\njobs:\n  build:\n    permissions:\n      contents: write\n    strategy:\n      fail-fast: false\n      matrix:\n        include:\n          - platform: \"macos-26\"\n            args: \"--target aarch64-apple-darwin\"\n            target: \"aarch64-apple-darwin\"\n          - platform: \"macos-latest\"\n            args: \"--target x86_64-apple-darwin\"\n            target: \"x86_64-apple-darwin\"\n          - platform: \"ubuntu-22.04\"\n            args: \"--bundles deb\"\n            target: \"x86_64-unknown-linux-gnu\"\n          - platform: \"ubuntu-24.04\"\n            args: \"--bundles appimage,rpm\"\n            target: \"x86_64-unknown-linux-gnu\"\n          - platform: \"ubuntu-24.04-arm\"\n            args: \"--bundles appimage,deb,rpm\"\n            target: \"aarch64-unknown-linux-gnu\"\n          - platform: \"windows-latest\"\n            args: \"\"\n            target: \"x86_64-pc-windows-msvc\"\n          - platform: \"windows-11-arm\"\n            args: \"--target aarch64-pc-windows-msvc\"\n            target: \"aarch64-pc-windows-msvc\"\n\n    uses: ./.github/workflows/build.yml\n    with:\n      platform: ${{ matrix.platform }}\n      target: ${{ matrix.target }}\n      build-args: ${{ matrix.args }}\n      upload-artifacts: true\n      sign-binaries: true\n    secrets: inherit\n"
  },
  {
    "path": ".github/workflows/nix-check.yml",
    "content": "# Nix CI — two tiers:\n#\n# 1. Quick checks (bun.nix sync, flake eval) run on ANY source change\n#    so compilation-breaking edits are caught by flake eval.\n# 2. Full nix build (~25 min) only runs when nix packaging files change.\n#\n# Setting up a Cachix binary cache would further reduce full-build times.\n\nname: \"nix build check\"\non:\n  workflow_dispatch:\n  push:\n    branches: [main]\n    paths:\n      - \"flake.nix\"\n      - \"flake.lock\"\n      - \".nix/**\"\n      - \"bun.lock\"\n      - \"src-tauri/**\"\n      - \"src/**\"\n      - \".github/workflows/**\"\n  pull_request:\n    paths:\n      - \"flake.nix\"\n      - \"flake.lock\"\n      - \".nix/**\"\n      - \"bun.lock\"\n      - \"src-tauri/**\"\n      - \"src/**\"\n      - \".github/workflows/**\"\n\nconcurrency:\n  group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}\n  cancel-in-progress: true\n\njobs:\n  nix-build:\n    runs-on: ubuntu-24.04\n    continue-on-error: false\n    steps:\n      - uses: actions/checkout@v4\n\n      - uses: oven-sh/setup-bun@v2\n        with:\n          bun-version: latest\n\n      - uses: cachix/install-nix-action@v30\n        with:\n          nix_path: nixpkgs=channel:nixos-unstable\n\n      - uses: DeterminateSystems/magic-nix-cache-action@565684385bcd71bad329742eefe8d12f2e765b39 # v13\n\n      # Regenerate .nix/bun.nix from bun.lock and check if it matches\n      # what's committed. A diff means the developer forgot to run\n      # bun scripts/check-nix-deps.ts or bun install (which triggers it).\n      - name: Check bun.nix is up to date\n        id: bun-check\n        run: |\n          bunx bun2nix -o .nix/bun.nix\n          if ! git diff --quiet .nix/bun.nix; then\n            echo \"outdated=true\" >> \"$GITHUB_OUTPUT\"\n          fi\n\n      - name: Hint on outdated bun.nix\n        if: steps.bun-check.outputs.outdated == 'true'\n        run: |\n          echo \"\"\n          echo \"::warning::.nix/bun.nix is out of sync with bun.lock\"\n          echo \"\"\n          echo \"┌──────────────────────────────────────────────────────────────┐\"\n          echo \"│  .nix/bun.nix is outdated. To fix, run:                      │\"\n          echo \"│                                                              │\"\n          echo \"│    bun scripts/check-nix-deps.ts                             │\"\n          echo \"│                                                              │\"\n          echo \"│  Or simply run 'bun install' — the postinstall hook will     │\"\n          echo \"│  regenerate it automatically. Commit the resulting changes.  │\"\n          echo \"└──────────────────────────────────────────────────────────────┘\"\n          echo \"\"\n          echo \"Diff:\"\n          git diff .nix/bun.nix\n          exit 1\n\n      # Evaluate the flake to catch issues with cargo git dependency hashes,\n      # missing inputs, or other Nix expression errors.\n      # Skip if bun.nix is already outdated — nix eval would fail with a\n      # cryptic error, and the bun-check step already printed a clear message.\n      - name: Check flake evaluation\n        if: steps.bun-check.outputs.outdated != 'true'\n        id: eval\n        run: |\n          if ! nix eval .#packages.x86_64-linux.handy.drvPath 2>eval_err.log; then\n            echo \"failed=true\" >> \"$GITHUB_OUTPUT\"\n            cat eval_err.log\n          fi\n\n      - name: Hint on evaluation failure\n        if: steps.eval.outputs.failed == 'true'\n        run: |\n          echo \"\"\n          echo \"::warning::flake.nix evaluation failed\"\n          echo \"\"\n          cat eval_err.log\n          exit 1\n\n      # Detect whether nix packaging files changed — if only source code\n      # changed, the quick checks above are sufficient.\n      # Skipped on workflow_dispatch (no base ref) — full build runs instead.\n      - name: Check if nix files changed\n        if: github.event_name == 'pull_request'\n        id: nix-files\n        run: |\n          git fetch origin ${{ github.base_ref }} --depth=1\n          if git diff --name-only origin/${{ github.base_ref }}...HEAD | grep -qE '^(flake\\.(nix|lock)|\\.nix/|bun\\.lock|src-tauri/(Cargo\\.(toml|lock)|tauri\\.conf\\.json|build\\.rs))'; then\n            echo \"changed=true\" >> \"$GITHUB_OUTPUT\"\n          fi\n\n      # Full build — catches runtime build errors (broken dependencies,\n      # sandbox issues, compilation failures) that flake eval alone misses.\n      # On PRs: only runs when nix packaging files change (~25 min with cold cache).\n      # On push to main and workflow_dispatch: always runs so every commit on\n      # main has a verified nix build before release.\n      - name: Build handy\n        if: steps.bun-check.outputs.outdated != 'true' && steps.eval.outputs.failed != 'true' && (steps.nix-files.outputs.changed == 'true' || github.event_name == 'workflow_dispatch' || github.event_name == 'push')\n        run: nix build .#handy -L --show-trace\n"
  },
  {
    "path": ".github/workflows/playwright.yml",
    "content": "name: \"Playwright\"\non:\n  workflow_dispatch:\n  pull_request:\n    paths:\n      - \"src/**\"\n      - \"package.json\"\n      - \"bun.lock\"\n      - \"playwright.config.*\"\n      - \"tests/**\"\n\nconcurrency:\n  group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}\n  cancel-in-progress: true\n\njobs:\n  playwright:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n\n      - uses: oven-sh/setup-bun@v2\n        with:\n          bun-version: latest\n\n      - name: Install dependencies\n        run: bun install --frozen-lockfile\n\n      - name: Install Playwright browsers\n        run: bunx playwright install chromium\n\n      - name: Run Playwright tests\n        run: bun run test:playwright\n\n      - name: Upload test results\n        if: failure()\n        uses: actions/upload-artifact@v4\n        with:\n          name: playwright-report\n          path: playwright-report/\n          retention-days: 7\n"
  },
  {
    "path": ".github/workflows/pr-test-build.yml",
    "content": "name: \"PR Test Build\"\n\non:\n  workflow_dispatch:\n    inputs:\n      pr_number:\n        description: \"PR number to build\"\n        required: true\n        type: string\n\njobs:\n  build-test:\n    permissions:\n      contents: write\n    strategy:\n      fail-fast: false\n      matrix:\n        include:\n          - platform: \"macos-26\"\n            args: \"--target aarch64-apple-darwin\"\n            target: \"aarch64-apple-darwin\"\n          - platform: \"macos-latest\"\n            args: \"--target x86_64-apple-darwin\"\n            target: \"x86_64-apple-darwin\"\n          - platform: \"ubuntu-22.04\"\n            args: \"--bundles deb\"\n            target: \"x86_64-unknown-linux-gnu\"\n          - platform: \"ubuntu-24.04\"\n            args: \"--bundles appimage,rpm\"\n            target: \"x86_64-unknown-linux-gnu\"\n          - platform: \"ubuntu-24.04-arm\" # Build for ARM64 Linux\n            args: \"--bundles appimage,deb,rpm\"\n            target: \"aarch64-unknown-linux-gnu\"\n          - platform: \"windows-latest\"\n            args: \"\"\n            target: \"x86_64-pc-windows-msvc\"\n          - platform: \"windows-11-arm\"\n            args: \"--target aarch64-pc-windows-msvc\"\n            target: \"aarch64-pc-windows-msvc\"\n\n    uses: ./.github/workflows/build.yml\n    with:\n      platform: ${{ matrix.platform }}\n      target: ${{ matrix.target }}\n      build-args: ${{ matrix.args }}\n      sign-binaries: true\n      asset-prefix: \"handy-pr-${{ inputs.pr_number }}\"\n      upload-artifacts: true\n      is-debug-build: ${{ contains(matrix.args, '--debug') }}\n      ref: ${{ format('refs/pull/{0}/merge', inputs.pr_number) }}\n    secrets: inherit\n\n  comment-on-pr:\n    needs: build-test\n    runs-on: ubuntu-latest\n    permissions:\n      pull-requests: write\n    steps:\n      - name: Post artifact links to PR\n        uses: actions/github-script@v7\n        with:\n          script: |\n            const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;\n            await github.rest.issues.createComment({\n              owner: context.repo.owner,\n              repo: context.repo.repo,\n              issue_number: ${{ inputs.pr_number }},\n              body: `## 🧪 Test Build Ready\\n\\nBuild artifacts for PR #${{ inputs.pr_number }} are available for testing.\\n\\n**[Download artifacts from workflow run](${runUrl})**\\n\\nArtifacts expire after 30 days.`\n            });\n"
  },
  {
    "path": ".github/workflows/release.yml",
    "content": "name: \"Release\"\n\non: workflow_dispatch\n\njobs:\n  create-release:\n    permissions:\n      contents: write\n    runs-on: ubuntu-latest\n    outputs:\n      release-id: ${{ steps.create-release.outputs.result }}\n      version: ${{ steps.get-version.outputs.version }}\n    steps:\n      - name: Checkout repository\n        uses: actions/checkout@v4\n\n      - name: Get version from tauri.conf.json\n        id: get-version\n        shell: bash\n        run: |\n          VERSION=$(grep -o '\"version\": \"[^\"]*\"' src-tauri/tauri.conf.json | cut -d'\"' -f4)\n          echo \"Application version from tauri.conf.json: $VERSION\"\n          echo \"version=$VERSION\" >> \"$GITHUB_OUTPUT\"\n\n      - name: Create Draft Release\n        id: create-release\n        uses: actions/github-script@v7\n        with:\n          script: |\n            const { data } = await github.rest.repos.createRelease({\n              owner: context.repo.owner,\n              repo: context.repo.repo,\n              tag_name: `v${{ steps.get-version.outputs.version }}`,\n              name: `v${{ steps.get-version.outputs.version }}`,\n              draft: true,\n              prerelease: false,\n              generate_release_notes: true\n            })\n            return data.id\n\n  publish-tauri:\n    permissions:\n      contents: write\n    needs: create-release\n    strategy:\n      fail-fast: false\n      matrix:\n        include:\n          - platform: \"macos-26\" # for Arm based macs (M1 and above). Uses macOS 26 for Apple Intelligence SDK.\n            args: \"--target aarch64-apple-darwin\"\n            target: \"aarch64-apple-darwin\"\n          - platform: \"macos-latest\" # for Intel based macs.\n            args: \"--target x86_64-apple-darwin\"\n            target: \"x86_64-apple-darwin\"\n          - platform: \"ubuntu-22.04\" # Build .deb on 22.04\n            args: \"--bundles deb\"\n            target: \"x86_64-unknown-linux-gnu\"\n          - platform: \"ubuntu-24.04\" # Build AppImage and RPM on 24.04\n            args: \"--bundles appimage,rpm\"\n            target: \"x86_64-unknown-linux-gnu\"\n          - platform: \"ubuntu-24.04-arm\" # Build for ARM64 Linux\n            args: \"--bundles appimage,deb,rpm\"\n            target: \"aarch64-unknown-linux-gnu\"\n          - platform: \"windows-latest\"\n            args: \"\"\n            target: \"x86_64-pc-windows-msvc\"\n          - platform: \"windows-11-arm\" # for ARM64 Windows runner\n            args: \"--target aarch64-pc-windows-msvc\"\n            target: \"aarch64-pc-windows-msvc\"\n\n    uses: ./.github/workflows/build.yml\n    with:\n      platform: ${{ matrix.platform }}\n      target: ${{ matrix.target }}\n      build-args: ${{ matrix.args }}\n      sign-binaries: true\n      asset-prefix: \"handy\"\n      upload-artifacts: false\n      release-id: ${{ needs.create-release.outputs.release-id }}\n    secrets: inherit\n"
  },
  {
    "path": ".github/workflows/test.yml",
    "content": "name: \"test\"\non:\n  workflow_dispatch:\n  push:\n    branches: [main]\n    paths:\n      - \"src-tauri/**\"\n  pull_request:\n    paths:\n      - \"src-tauri/**\"\n\nconcurrency:\n  group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}\n  cancel-in-progress: true\n\njobs:\n  rust-tests:\n    runs-on: ubuntu-24.04\n    steps:\n      - uses: actions/checkout@v4\n\n      - name: Install system dependencies\n        run: |\n          sudo apt-get update\n          sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev libasound2-dev libssl-dev libgtk-layer-shell-dev\n\n      - uses: swatinem/rust-cache@v2\n        with:\n          workspaces: \"./src-tauri -> target\"\n\n      - name: Use mock TranscriptionManager (CI only)\n        working-directory: src-tauri\n        run: |\n          # Swap to mock adapter - avoids compiling whisper/Vulkan\n          cp src/managers/transcription_mock.rs src/managers/transcription.rs\n          sed -i '/^transcribe-rs/d' Cargo.toml\n\n      - name: Run Rust tests\n        working-directory: src-tauri\n        run: cargo test\n"
  },
  {
    "path": ".gitignore",
    "content": "# Logs\nlogs\n*.log\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\npnpm-debug.log*\nlerna-debug.log*\n\npackage-lock.json\n\nnode_modules\ndist\ndist-ssr\n*.local\n*.local.*\n\n# Editor directories and files\n.vscode/*\n!.vscode/extensions.json\n.idea\n.DS_Store\n*.suo\n*.ntvs*\n*.njsproj\n*.sln\n*.sw?\n\n/target/\nrecording_*\n.crush/\n\n# Playwright\nplaywright-report/\ntest-results/\nblob-report/\n\n.direnv\n.envrc\n\n# Nix build output\nresult\n"
  },
  {
    "path": ".nix/bun-lock-hash",
    "content": "e00b12c719a762004194cec01f2ad0b78ae483c41452bcca8537179d28e704b1\n"
  },
  {
    "path": ".nix/bun.nix",
    "content": "# Autogenerated by `bun2nix`, editing manually is not recommended\n#\n# Set of Bun packages to install\n#\n# Consume this with `fetchBunDeps` (recommended)\n# or `pkgs.callPackage` if you wish to handle\n# it manually.\n{\n  copyPathToStore,\n  fetchFromGitHub,\n  fetchgit,\n  fetchurl,\n  ...\n}:\n{\n  \"@babel/code-frame@7.27.1\" = fetchurl {\n    url = \"https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz\";\n    hash = \"sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==\";\n  };\n  \"@babel/compat-data@7.28.5\" = fetchurl {\n    url = \"https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.5.tgz\";\n    hash = \"sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==\";\n  };\n  \"@babel/core@7.28.5\" = fetchurl {\n    url = \"https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz\";\n    hash = \"sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==\";\n  };\n  \"@babel/generator@7.28.5\" = fetchurl {\n    url = \"https://registry.npmjs.org/@babel/generator/-/generator-7.28.5.tgz\";\n    hash = \"sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==\";\n  };\n  \"@babel/helper-compilation-targets@7.27.2\" = fetchurl {\n    url = \"https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz\";\n    hash = \"sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==\";\n  };\n  \"@babel/helper-globals@7.28.0\" = fetchurl {\n    url = \"https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz\";\n    hash = \"sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==\";\n  };\n  \"@babel/helper-module-imports@7.27.1\" = fetchurl {\n    url = \"https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz\";\n    hash = \"sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==\";\n  };\n  \"@babel/helper-module-transforms@7.28.3\" = fetchurl {\n    url = \"https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz\";\n    hash = \"sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==\";\n  };\n  \"@babel/helper-plugin-utils@7.27.1\" = fetchurl {\n    url = \"https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz\";\n    hash = \"sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==\";\n  };\n  \"@babel/helper-string-parser@7.27.1\" = fetchurl {\n    url = \"https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz\";\n    hash = \"sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==\";\n  };\n  \"@babel/helper-validator-identifier@7.28.5\" = fetchurl {\n    url = \"https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz\";\n    hash = \"sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==\";\n  };\n  \"@babel/helper-validator-option@7.27.1\" = fetchurl {\n    url = \"https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz\";\n    hash = \"sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==\";\n  };\n  \"@babel/helpers@7.28.4\" = fetchurl {\n    url = \"https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz\";\n    hash = \"sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==\";\n  };\n  \"@babel/parser@7.28.5\" = fetchurl {\n    url = \"https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz\";\n    hash = \"sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==\";\n  };\n  \"@babel/plugin-transform-react-jsx-self@7.27.1\" = fetchurl {\n    url = \"https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz\";\n    hash = \"sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==\";\n  };\n  \"@babel/plugin-transform-react-jsx-source@7.27.1\" = fetchurl {\n    url = \"https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz\";\n    hash = \"sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==\";\n  };\n  \"@babel/runtime@7.28.4\" = fetchurl {\n    url = \"https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz\";\n    hash = \"sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==\";\n  };\n  \"@babel/template@7.27.2\" = fetchurl {\n    url = \"https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz\";\n    hash = \"sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==\";\n  };\n  \"@babel/traverse@7.28.5\" = fetchurl {\n    url = \"https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.5.tgz\";\n    hash = \"sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==\";\n  };\n  \"@babel/types@7.28.5\" = fetchurl {\n    url = \"https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz\";\n    hash = \"sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==\";\n  };\n  \"@emnapi/core@1.6.0\" = fetchurl {\n    url = \"https://registry.npmjs.org/@emnapi/core/-/core-1.6.0.tgz\";\n    hash = \"sha512-zq/ay+9fNIJJtJiZxdTnXS20PllcYMX3OE23ESc4HK/bdYu3cOWYVhsOhVnXALfU/uqJIxn5NBPd9z4v+SfoSg==\";\n  };\n  \"@emnapi/runtime@1.6.0\" = fetchurl {\n    url = \"https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.6.0.tgz\";\n    hash = \"sha512-obtUmAHTMjll499P+D9A3axeJFlhdjOWdKUNs/U6QIGT7V5RjcUW1xToAzjvmgTSQhDbYn/NwfTRoJcQ2rNBxA==\";\n  };\n  \"@emnapi/wasi-threads@1.1.0\" = fetchurl {\n    url = \"https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz\";\n    hash = \"sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==\";\n  };\n  \"@emotion/babel-plugin@11.13.5\" = fetchurl {\n    url = \"https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz\";\n    hash = \"sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==\";\n  };\n  \"@emotion/cache@11.14.0\" = fetchurl {\n    url = \"https://registry.npmjs.org/@emotion/cache/-/cache-11.14.0.tgz\";\n    hash = \"sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA==\";\n  };\n  \"@emotion/hash@0.9.2\" = fetchurl {\n    url = \"https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz\";\n    hash = \"sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==\";\n  };\n  \"@emotion/memoize@0.9.0\" = fetchurl {\n    url = \"https://registry.npmjs.org/@emotion/memoize/-/memoize-0.9.0.tgz\";\n    hash = \"sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==\";\n  };\n  \"@emotion/react@11.14.0\" = fetchurl {\n    url = \"https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz\";\n    hash = \"sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==\";\n  };\n  \"@emotion/serialize@1.3.3\" = fetchurl {\n    url = \"https://registry.npmjs.org/@emotion/serialize/-/serialize-1.3.3.tgz\";\n    hash = \"sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA==\";\n  };\n  \"@emotion/sheet@1.4.0\" = fetchurl {\n    url = \"https://registry.npmjs.org/@emotion/sheet/-/sheet-1.4.0.tgz\";\n    hash = \"sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg==\";\n  };\n  \"@emotion/unitless@0.10.0\" = fetchurl {\n    url = \"https://registry.npmjs.org/@emotion/unitless/-/unitless-0.10.0.tgz\";\n    hash = \"sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg==\";\n  };\n  \"@emotion/use-insertion-effect-with-fallbacks@1.2.0\" = fetchurl {\n    url = \"https://registry.npmjs.org/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.2.0.tgz\";\n    hash = \"sha512-yJMtVdH59sxi/aVJBpk9FQq+OR8ll5GT8oWd57UpeaKEVGab41JWaCFA7FRLoMLloOZF/c/wsPoe+bfGmRKgDg==\";\n  };\n  \"@emotion/utils@1.4.2\" = fetchurl {\n    url = \"https://registry.npmjs.org/@emotion/utils/-/utils-1.4.2.tgz\";\n    hash = \"sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA==\";\n  };\n  \"@emotion/weak-memoize@0.4.0\" = fetchurl {\n    url = \"https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz\";\n    hash = \"sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==\";\n  };\n  \"@esbuild/aix-ppc64@0.25.11\" = fetchurl {\n    url = \"https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.11.tgz\";\n    hash = \"sha512-Xt1dOL13m8u0WE8iplx9Ibbm+hFAO0GsU2P34UNoDGvZYkY8ifSiy6Zuc1lYxfG7svWE2fzqCUmFp5HCn51gJg==\";\n  };\n  \"@esbuild/android-arm64@0.25.11\" = fetchurl {\n    url = \"https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.11.tgz\";\n    hash = \"sha512-9slpyFBc4FPPz48+f6jyiXOx/Y4v34TUeDDXJpZqAWQn/08lKGeD8aDp9TMn9jDz2CiEuHwfhRmGBvpnd/PWIQ==\";\n  };\n  \"@esbuild/android-arm@0.25.11\" = fetchurl {\n    url = \"https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.11.tgz\";\n    hash = \"sha512-uoa7dU+Dt3HYsethkJ1k6Z9YdcHjTrSb5NUy66ZfZaSV8hEYGD5ZHbEMXnqLFlbBflLsl89Zke7CAdDJ4JI+Gg==\";\n  };\n  \"@esbuild/android-x64@0.25.11\" = fetchurl {\n    url = \"https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.11.tgz\";\n    hash = \"sha512-Sgiab4xBjPU1QoPEIqS3Xx+R2lezu0LKIEcYe6pftr56PqPygbB7+szVnzoShbx64MUupqoE0KyRlN7gezbl8g==\";\n  };\n  \"@esbuild/darwin-arm64@0.25.11\" = fetchurl {\n    url = \"https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.11.tgz\";\n    hash = \"sha512-VekY0PBCukppoQrycFxUqkCojnTQhdec0vevUL/EDOCnXd9LKWqD/bHwMPzigIJXPhC59Vd1WFIL57SKs2mg4w==\";\n  };\n  \"@esbuild/darwin-x64@0.25.11\" = fetchurl {\n    url = \"https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.11.tgz\";\n    hash = \"sha512-+hfp3yfBalNEpTGp9loYgbknjR695HkqtY3d3/JjSRUyPg/xd6q+mQqIb5qdywnDxRZykIHs3axEqU6l1+oWEQ==\";\n  };\n  \"@esbuild/freebsd-arm64@0.25.11\" = fetchurl {\n    url = \"https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.11.tgz\";\n    hash = \"sha512-CmKjrnayyTJF2eVuO//uSjl/K3KsMIeYeyN7FyDBjsR3lnSJHaXlVoAK8DZa7lXWChbuOk7NjAc7ygAwrnPBhA==\";\n  };\n  \"@esbuild/freebsd-x64@0.25.11\" = fetchurl {\n    url = \"https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.11.tgz\";\n    hash = \"sha512-Dyq+5oscTJvMaYPvW3x3FLpi2+gSZTCE/1ffdwuM6G1ARang/mb3jvjxs0mw6n3Lsw84ocfo9CrNMqc5lTfGOw==\";\n  };\n  \"@esbuild/linux-arm64@0.25.11\" = fetchurl {\n    url = \"https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.11.tgz\";\n    hash = \"sha512-Qr8AzcplUhGvdyUF08A1kHU3Vr2O88xxP0Tm8GcdVOUm25XYcMPp2YqSVHbLuXzYQMf9Bh/iKx7YPqECs6ffLA==\";\n  };\n  \"@esbuild/linux-arm@0.25.11\" = fetchurl {\n    url = \"https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.11.tgz\";\n    hash = \"sha512-TBMv6B4kCfrGJ8cUPo7vd6NECZH/8hPpBHHlYI3qzoYFvWu2AdTvZNuU/7hsbKWqu/COU7NIK12dHAAqBLLXgw==\";\n  };\n  \"@esbuild/linux-ia32@0.25.11\" = fetchurl {\n    url = \"https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.11.tgz\";\n    hash = \"sha512-TmnJg8BMGPehs5JKrCLqyWTVAvielc615jbkOirATQvWWB1NMXY77oLMzsUjRLa0+ngecEmDGqt5jiDC6bfvOw==\";\n  };\n  \"@esbuild/linux-loong64@0.25.11\" = fetchurl {\n    url = \"https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.11.tgz\";\n    hash = \"sha512-DIGXL2+gvDaXlaq8xruNXUJdT5tF+SBbJQKbWy/0J7OhU8gOHOzKmGIlfTTl6nHaCOoipxQbuJi7O++ldrxgMw==\";\n  };\n  \"@esbuild/linux-mips64el@0.25.11\" = fetchurl {\n    url = \"https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.11.tgz\";\n    hash = \"sha512-Osx1nALUJu4pU43o9OyjSCXokFkFbyzjXb6VhGIJZQ5JZi8ylCQ9/LFagolPsHtgw6himDSyb5ETSfmp4rpiKQ==\";\n  };\n  \"@esbuild/linux-ppc64@0.25.11\" = fetchurl {\n    url = \"https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.11.tgz\";\n    hash = \"sha512-nbLFgsQQEsBa8XSgSTSlrnBSrpoWh7ioFDUmwo158gIm5NNP+17IYmNWzaIzWmgCxq56vfr34xGkOcZ7jX6CPw==\";\n  };\n  \"@esbuild/linux-riscv64@0.25.11\" = fetchurl {\n    url = \"https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.11.tgz\";\n    hash = \"sha512-HfyAmqZi9uBAbgKYP1yGuI7tSREXwIb438q0nqvlpxAOs3XnZ8RsisRfmVsgV486NdjD7Mw2UrFSw51lzUk1ww==\";\n  };\n  \"@esbuild/linux-s390x@0.25.11\" = fetchurl {\n    url = \"https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.11.tgz\";\n    hash = \"sha512-HjLqVgSSYnVXRisyfmzsH6mXqyvj0SA7pG5g+9W7ESgwA70AXYNpfKBqh1KbTxmQVaYxpzA/SvlB9oclGPbApw==\";\n  };\n  \"@esbuild/linux-x64@0.25.11\" = fetchurl {\n    url = \"https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.11.tgz\";\n    hash = \"sha512-HSFAT4+WYjIhrHxKBwGmOOSpphjYkcswF449j6EjsjbinTZbp8PJtjsVK1XFJStdzXdy/jaddAep2FGY+wyFAQ==\";\n  };\n  \"@esbuild/netbsd-arm64@0.25.11\" = fetchurl {\n    url = \"https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.11.tgz\";\n    hash = \"sha512-hr9Oxj1Fa4r04dNpWr3P8QKVVsjQhqrMSUzZzf+LZcYjZNqhA3IAfPQdEh1FLVUJSiu6sgAwp3OmwBfbFgG2Xg==\";\n  };\n  \"@esbuild/netbsd-x64@0.25.11\" = fetchurl {\n    url = \"https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.11.tgz\";\n    hash = \"sha512-u7tKA+qbzBydyj0vgpu+5h5AeudxOAGncb8N6C9Kh1N4n7wU1Xw1JDApsRjpShRpXRQlJLb9wY28ELpwdPcZ7A==\";\n  };\n  \"@esbuild/openbsd-arm64@0.25.11\" = fetchurl {\n    url = \"https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.11.tgz\";\n    hash = \"sha512-Qq6YHhayieor3DxFOoYM1q0q1uMFYb7cSpLD2qzDSvK1NAvqFi8Xgivv0cFC6J+hWVw2teCYltyy9/m/14ryHg==\";\n  };\n  \"@esbuild/openbsd-x64@0.25.11\" = fetchurl {\n    url = \"https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.11.tgz\";\n    hash = \"sha512-CN+7c++kkbrckTOz5hrehxWN7uIhFFlmS/hqziSFVWpAzpWrQoAG4chH+nN3Be+Kzv/uuo7zhX716x3Sn2Jduw==\";\n  };\n  \"@esbuild/openharmony-arm64@0.25.11\" = fetchurl {\n    url = \"https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.11.tgz\";\n    hash = \"sha512-rOREuNIQgaiR+9QuNkbkxubbp8MSO9rONmwP5nKncnWJ9v5jQ4JxFnLu4zDSRPf3x4u+2VN4pM4RdyIzDty/wQ==\";\n  };\n  \"@esbuild/sunos-x64@0.25.11\" = fetchurl {\n    url = \"https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.11.tgz\";\n    hash = \"sha512-nq2xdYaWxyg9DcIyXkZhcYulC6pQ2FuCgem3LI92IwMgIZ69KHeY8T4Y88pcwoLIjbed8n36CyKoYRDygNSGhA==\";\n  };\n  \"@esbuild/win32-arm64@0.25.11\" = fetchurl {\n    url = \"https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.11.tgz\";\n    hash = \"sha512-3XxECOWJq1qMZ3MN8srCJ/QfoLpL+VaxD/WfNRm1O3B4+AZ/BnLVgFbUV3eiRYDMXetciH16dwPbbHqwe1uU0Q==\";\n  };\n  \"@esbuild/win32-ia32@0.25.11\" = fetchurl {\n    url = \"https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.11.tgz\";\n    hash = \"sha512-3ukss6gb9XZ8TlRyJlgLn17ecsK4NSQTmdIXRASVsiS2sQ6zPPZklNJT5GR5tE/MUarymmy8kCEf5xPCNCqVOA==\";\n  };\n  \"@esbuild/win32-x64@0.25.11\" = fetchurl {\n    url = \"https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.11.tgz\";\n    hash = \"sha512-D7Hpz6A2L4hzsRpPaCYkQnGOotdUpDzSGRIv9I+1ITdHROSFUWW95ZPZWQmGka1Fg7W3zFJowyn9WGwMJ0+KPA==\";\n  };\n  \"@eslint-community/eslint-utils@4.9.0\" = fetchurl {\n    url = \"https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz\";\n    hash = \"sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==\";\n  };\n  \"@eslint-community/regexpp@4.12.2\" = fetchurl {\n    url = \"https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz\";\n    hash = \"sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==\";\n  };\n  \"@eslint/config-array@0.21.1\" = fetchurl {\n    url = \"https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz\";\n    hash = \"sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==\";\n  };\n  \"@eslint/config-helpers@0.4.2\" = fetchurl {\n    url = \"https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz\";\n    hash = \"sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==\";\n  };\n  \"@eslint/core@0.17.0\" = fetchurl {\n    url = \"https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz\";\n    hash = \"sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==\";\n  };\n  \"@eslint/eslintrc@3.3.3\" = fetchurl {\n    url = \"https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.3.tgz\";\n    hash = \"sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==\";\n  };\n  \"@eslint/js@9.39.1\" = fetchurl {\n    url = \"https://registry.npmjs.org/@eslint/js/-/js-9.39.1.tgz\";\n    hash = \"sha512-S26Stp4zCy88tH94QbBv3XCuzRQiZ9yXofEILmglYTh/Ug/a9/umqvgFtYBAo3Lp0nsI/5/qH1CCrbdK3AP1Tw==\";\n  };\n  \"@eslint/object-schema@2.1.7\" = fetchurl {\n    url = \"https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz\";\n    hash = \"sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==\";\n  };\n  \"@eslint/plugin-kit@0.4.1\" = fetchurl {\n    url = \"https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz\";\n    hash = \"sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==\";\n  };\n  \"@floating-ui/core@1.7.3\" = fetchurl {\n    url = \"https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz\";\n    hash = \"sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==\";\n  };\n  \"@floating-ui/dom@1.7.4\" = fetchurl {\n    url = \"https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz\";\n    hash = \"sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==\";\n  };\n  \"@floating-ui/utils@0.2.10\" = fetchurl {\n    url = \"https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz\";\n    hash = \"sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==\";\n  };\n  \"@humanfs/core@0.19.1\" = fetchurl {\n    url = \"https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz\";\n    hash = \"sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==\";\n  };\n  \"@humanfs/node@0.16.7\" = fetchurl {\n    url = \"https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz\";\n    hash = \"sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==\";\n  };\n  \"@humanwhocodes/module-importer@1.0.1\" = fetchurl {\n    url = \"https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz\";\n    hash = \"sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==\";\n  };\n  \"@humanwhocodes/retry@0.4.3\" = fetchurl {\n    url = \"https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz\";\n    hash = \"sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==\";\n  };\n  \"@jridgewell/gen-mapping@0.3.13\" = fetchurl {\n    url = \"https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz\";\n    hash = \"sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==\";\n  };\n  \"@jridgewell/remapping@2.3.5\" = fetchurl {\n    url = \"https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz\";\n    hash = \"sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==\";\n  };\n  \"@jridgewell/resolve-uri@3.1.2\" = fetchurl {\n    url = \"https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz\";\n    hash = \"sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==\";\n  };\n  \"@jridgewell/sourcemap-codec@1.5.5\" = fetchurl {\n    url = \"https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz\";\n    hash = \"sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==\";\n  };\n  \"@jridgewell/trace-mapping@0.3.31\" = fetchurl {\n    url = \"https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz\";\n    hash = \"sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==\";\n  };\n  \"@napi-rs/wasm-runtime@1.0.7\" = fetchurl {\n    url = \"https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.0.7.tgz\";\n    hash = \"sha512-SeDnOO0Tk7Okiq6DbXmmBODgOAb9dp9gjlphokTUxmt8U3liIP1ZsozBahH69j/RJv+Rfs6IwUKHTgQYJ/HBAw==\";\n  };\n  \"@playwright/test@1.58.0\" = fetchurl {\n    url = \"https://registry.npmjs.org/@playwright/test/-/test-1.58.0.tgz\";\n    hash = \"sha512-fWza+Lpbj6SkQKCrU6si4iu+fD2dD3gxNHFhUPxsfXBPhnv3rRSQVd0NtBUT9Z/RhF/boCBcuUaMUSTRTopjZg==\";\n  };\n  \"@rolldown/pluginutils@1.0.0-beta.27\" = fetchurl {\n    url = \"https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz\";\n    hash = \"sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==\";\n  };\n  \"@rollup/rollup-android-arm-eabi@4.52.5\" = fetchurl {\n    url = \"https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.52.5.tgz\";\n    hash = \"sha512-8c1vW4ocv3UOMp9K+gToY5zL2XiiVw3k7f1ksf4yO1FlDFQ1C2u72iACFnSOceJFsWskc2WZNqeRhFRPzv+wtQ==\";\n  };\n  \"@rollup/rollup-android-arm64@4.52.5\" = fetchurl {\n    url = \"https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.52.5.tgz\";\n    hash = \"sha512-mQGfsIEFcu21mvqkEKKu2dYmtuSZOBMmAl5CFlPGLY94Vlcm+zWApK7F/eocsNzp8tKmbeBP8yXyAbx0XHsFNA==\";\n  };\n  \"@rollup/rollup-darwin-arm64@4.52.5\" = fetchurl {\n    url = \"https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.52.5.tgz\";\n    hash = \"sha512-takF3CR71mCAGA+v794QUZ0b6ZSrgJkArC+gUiG6LB6TQty9T0Mqh3m2ImRBOxS2IeYBo4lKWIieSvnEk2OQWA==\";\n  };\n  \"@rollup/rollup-darwin-x64@4.52.5\" = fetchurl {\n    url = \"https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.52.5.tgz\";\n    hash = \"sha512-W901Pla8Ya95WpxDn//VF9K9u2JbocwV/v75TE0YIHNTbhqUTv9w4VuQ9MaWlNOkkEfFwkdNhXgcLqPSmHy0fA==\";\n  };\n  \"@rollup/rollup-freebsd-arm64@4.52.5\" = fetchurl {\n    url = \"https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.52.5.tgz\";\n    hash = \"sha512-QofO7i7JycsYOWxe0GFqhLmF6l1TqBswJMvICnRUjqCx8b47MTo46W8AoeQwiokAx3zVryVnxtBMcGcnX12LvA==\";\n  };\n  \"@rollup/rollup-freebsd-x64@4.52.5\" = fetchurl {\n    url = \"https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.52.5.tgz\";\n    hash = \"sha512-jr21b/99ew8ujZubPo9skbrItHEIE50WdV86cdSoRkKtmWa+DDr6fu2c/xyRT0F/WazZpam6kk7IHBerSL7LDQ==\";\n  };\n  \"@rollup/rollup-linux-arm-gnueabihf@4.52.5\" = fetchurl {\n    url = \"https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.52.5.tgz\";\n    hash = \"sha512-PsNAbcyv9CcecAUagQefwX8fQn9LQ4nZkpDboBOttmyffnInRy8R8dSg6hxxl2Re5QhHBf6FYIDhIj5v982ATQ==\";\n  };\n  \"@rollup/rollup-linux-arm-musleabihf@4.52.5\" = fetchurl {\n    url = \"https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.52.5.tgz\";\n    hash = \"sha512-Fw4tysRutyQc/wwkmcyoqFtJhh0u31K+Q6jYjeicsGJJ7bbEq8LwPWV/w0cnzOqR2m694/Af6hpFayLJZkG2VQ==\";\n  };\n  \"@rollup/rollup-linux-arm64-gnu@4.52.5\" = fetchurl {\n    url = \"https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.52.5.tgz\";\n    hash = \"sha512-a+3wVnAYdQClOTlyapKmyI6BLPAFYs0JM8HRpgYZQO02rMR09ZcV9LbQB+NL6sljzG38869YqThrRnfPMCDtZg==\";\n  };\n  \"@rollup/rollup-linux-arm64-musl@4.52.5\" = fetchurl {\n    url = \"https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.52.5.tgz\";\n    hash = \"sha512-AvttBOMwO9Pcuuf7m9PkC1PUIKsfaAJ4AYhy944qeTJgQOqJYJ9oVl2nYgY7Rk0mkbsuOpCAYSs6wLYB2Xiw0Q==\";\n  };\n  \"@rollup/rollup-linux-loong64-gnu@4.52.5\" = fetchurl {\n    url = \"https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.52.5.tgz\";\n    hash = \"sha512-DkDk8pmXQV2wVrF6oq5tONK6UHLz/XcEVow4JTTerdeV1uqPeHxwcg7aFsfnSm9L+OO8WJsWotKM2JJPMWrQtA==\";\n  };\n  \"@rollup/rollup-linux-ppc64-gnu@4.52.5\" = fetchurl {\n    url = \"https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.52.5.tgz\";\n    hash = \"sha512-W/b9ZN/U9+hPQVvlGwjzi+Wy4xdoH2I8EjaCkMvzpI7wJUs8sWJ03Rq96jRnHkSrcHTpQe8h5Tg3ZzUPGauvAw==\";\n  };\n  \"@rollup/rollup-linux-riscv64-gnu@4.52.5\" = fetchurl {\n    url = \"https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.52.5.tgz\";\n    hash = \"sha512-sjQLr9BW7R/ZiXnQiWPkErNfLMkkWIoCz7YMn27HldKsADEKa5WYdobaa1hmN6slu9oWQbB6/jFpJ+P2IkVrmw==\";\n  };\n  \"@rollup/rollup-linux-riscv64-musl@4.52.5\" = fetchurl {\n    url = \"https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.52.5.tgz\";\n    hash = \"sha512-hq3jU/kGyjXWTvAh2awn8oHroCbrPm8JqM7RUpKjalIRWWXE01CQOf/tUNWNHjmbMHg/hmNCwc/Pz3k1T/j/Lg==\";\n  };\n  \"@rollup/rollup-linux-s390x-gnu@4.52.5\" = fetchurl {\n    url = \"https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.52.5.tgz\";\n    hash = \"sha512-gn8kHOrku8D4NGHMK1Y7NA7INQTRdVOntt1OCYypZPRt6skGbddska44K8iocdpxHTMMNui5oH4elPH4QOLrFQ==\";\n  };\n  \"@rollup/rollup-linux-x64-gnu@4.52.5\" = fetchurl {\n    url = \"https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.52.5.tgz\";\n    hash = \"sha512-hXGLYpdhiNElzN770+H2nlx+jRog8TyynpTVzdlc6bndktjKWyZyiCsuDAlpd+j+W+WNqfcyAWz9HxxIGfZm1Q==\";\n  };\n  \"@rollup/rollup-linux-x64-musl@4.52.5\" = fetchurl {\n    url = \"https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.52.5.tgz\";\n    hash = \"sha512-arCGIcuNKjBoKAXD+y7XomR9gY6Mw7HnFBv5Rw7wQRvwYLR7gBAgV7Mb2QTyjXfTveBNFAtPt46/36vV9STLNg==\";\n  };\n  \"@rollup/rollup-openharmony-arm64@4.52.5\" = fetchurl {\n    url = \"https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.52.5.tgz\";\n    hash = \"sha512-QoFqB6+/9Rly/RiPjaomPLmR/13cgkIGfA40LHly9zcH1S0bN2HVFYk3a1eAyHQyjs3ZJYlXvIGtcCs5tko9Cw==\";\n  };\n  \"@rollup/rollup-win32-arm64-msvc@4.52.5\" = fetchurl {\n    url = \"https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.52.5.tgz\";\n    hash = \"sha512-w0cDWVR6MlTstla1cIfOGyl8+qb93FlAVutcor14Gf5Md5ap5ySfQ7R9S/NjNaMLSFdUnKGEasmVnu3lCMqB7w==\";\n  };\n  \"@rollup/rollup-win32-ia32-msvc@4.52.5\" = fetchurl {\n    url = \"https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.52.5.tgz\";\n    hash = \"sha512-Aufdpzp7DpOTULJCuvzqcItSGDH73pF3ko/f+ckJhxQyHtp67rHw3HMNxoIdDMUITJESNE6a8uh4Lo4SLouOUg==\";\n  };\n  \"@rollup/rollup-win32-x64-gnu@4.52.5\" = fetchurl {\n    url = \"https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.52.5.tgz\";\n    hash = \"sha512-UGBUGPFp1vkj6p8wCRraqNhqwX/4kNQPS57BCFc8wYh0g94iVIW33wJtQAx3G7vrjjNtRaxiMUylM0ktp/TRSQ==\";\n  };\n  \"@rollup/rollup-win32-x64-msvc@4.52.5\" = fetchurl {\n    url = \"https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.52.5.tgz\";\n    hash = \"sha512-TAcgQh2sSkykPRWLrdyy2AiceMckNf5loITqXxFI5VuQjS5tSuw3WlwdN8qv8vzjLAUTvYaH/mVjSFpbkFbpTg==\";\n  };\n  \"@tailwindcss/node@4.1.16\" = fetchurl {\n    url = \"https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.16.tgz\";\n    hash = \"sha512-BX5iaSsloNuvKNHRN3k2RcCuTEgASTo77mofW0vmeHkfrDWaoFAFvNHpEgtu0eqyypcyiBkDWzSMxJhp3AUVcw==\";\n  };\n  \"@tailwindcss/oxide-android-arm64@4.1.16\" = fetchurl {\n    url = \"https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.16.tgz\";\n    hash = \"sha512-8+ctzkjHgwDJ5caq9IqRSgsP70xhdhJvm+oueS/yhD5ixLhqTw9fSL1OurzMUhBwE5zK26FXLCz2f/RtkISqHA==\";\n  };\n  \"@tailwindcss/oxide-darwin-arm64@4.1.16\" = fetchurl {\n    url = \"https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.16.tgz\";\n    hash = \"sha512-C3oZy5042v2FOALBZtY0JTDnGNdS6w7DxL/odvSny17ORUnaRKhyTse8xYi3yKGyfnTUOdavRCdmc8QqJYwFKA==\";\n  };\n  \"@tailwindcss/oxide-darwin-x64@4.1.16\" = fetchurl {\n    url = \"https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.16.tgz\";\n    hash = \"sha512-vjrl/1Ub9+JwU6BP0emgipGjowzYZMjbWCDqwA2Z4vCa+HBSpP4v6U2ddejcHsolsYxwL5r4bPNoamlV0xDdLg==\";\n  };\n  \"@tailwindcss/oxide-freebsd-x64@4.1.16\" = fetchurl {\n    url = \"https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.16.tgz\";\n    hash = \"sha512-TSMpPYpQLm+aR1wW5rKuUuEruc/oOX3C7H0BTnPDn7W/eMw8W+MRMpiypKMkXZfwH8wqPIRKppuZoedTtNj2tg==\";\n  };\n  \"@tailwindcss/oxide-linux-arm-gnueabihf@4.1.16\" = fetchurl {\n    url = \"https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.16.tgz\";\n    hash = \"sha512-p0GGfRg/w0sdsFKBjMYvvKIiKy/LNWLWgV/plR4lUgrsxFAoQBFrXkZ4C0w8IOXfslB9vHK/JGASWD2IefIpvw==\";\n  };\n  \"@tailwindcss/oxide-linux-arm64-gnu@4.1.16\" = fetchurl {\n    url = \"https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.16.tgz\";\n    hash = \"sha512-DoixyMmTNO19rwRPdqviTrG1rYzpxgyYJl8RgQvdAQUzxC1ToLRqtNJpU/ATURSKgIg6uerPw2feW0aS8SNr/w==\";\n  };\n  \"@tailwindcss/oxide-linux-arm64-musl@4.1.16\" = fetchurl {\n    url = \"https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.16.tgz\";\n    hash = \"sha512-H81UXMa9hJhWhaAUca6bU2wm5RRFpuHImrwXBUvPbYb+3jo32I9VIwpOX6hms0fPmA6f2pGVlybO6qU8pF4fzQ==\";\n  };\n  \"@tailwindcss/oxide-linux-x64-gnu@4.1.16\" = fetchurl {\n    url = \"https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.16.tgz\";\n    hash = \"sha512-ZGHQxDtFC2/ruo7t99Qo2TTIvOERULPl5l0K1g0oK6b5PGqjYMga+FcY1wIUnrUxY56h28FxybtDEla+ICOyew==\";\n  };\n  \"@tailwindcss/oxide-linux-x64-musl@4.1.16\" = fetchurl {\n    url = \"https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.16.tgz\";\n    hash = \"sha512-Oi1tAaa0rcKf1Og9MzKeINZzMLPbhxvm7rno5/zuP1WYmpiG0bEHq4AcRUiG2165/WUzvxkW4XDYCscZWbTLZw==\";\n  };\n  \"@tailwindcss/oxide-wasm32-wasi@4.1.16\" = fetchurl {\n    url = \"https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.16.tgz\";\n    hash = \"sha512-B01u/b8LteGRwucIBmCQ07FVXLzImWESAIMcUU6nvFt/tYsQ6IHz8DmZ5KtvmwxD+iTYBtM1xwoGXswnlu9v0Q==\";\n  };\n  \"@tailwindcss/oxide-win32-arm64-msvc@4.1.16\" = fetchurl {\n    url = \"https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.16.tgz\";\n    hash = \"sha512-zX+Q8sSkGj6HKRTMJXuPvOcP8XfYON24zJBRPlszcH1Np7xuHXhWn8qfFjIujVzvH3BHU+16jBXwgpl20i+v9A==\";\n  };\n  \"@tailwindcss/oxide-win32-x64-msvc@4.1.16\" = fetchurl {\n    url = \"https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.16.tgz\";\n    hash = \"sha512-m5dDFJUEejbFqP+UXVstd4W/wnxA4F61q8SoL+mqTypId2T2ZpuxosNSgowiCnLp2+Z+rivdU0AqpfgiD7yCBg==\";\n  };\n  \"@tailwindcss/oxide@4.1.16\" = fetchurl {\n    url = \"https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.16.tgz\";\n    hash = \"sha512-2OSv52FRuhdlgyOQqgtQHuCgXnS8nFSYRp2tJ+4WZXKgTxqPy7SMSls8c3mPT5pkZ17SBToGM5LHEJBO7miEdg==\";\n  };\n  \"@tailwindcss/vite@4.1.16\" = fetchurl {\n    url = \"https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.1.16.tgz\";\n    hash = \"sha512-bbguNBcDxsRmi9nnlWJxhfDWamY3lmcyACHcdO1crxfzuLpOhHLLtEIN/nCbbAtj5rchUgQD17QVAKi1f7IsKg==\";\n  };\n  \"@tauri-apps/api@2.10.1\" = fetchurl {\n    url = \"https://registry.npmjs.org/@tauri-apps/api/-/api-2.10.1.tgz\";\n    hash = \"sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw==\";\n  };\n  \"@tauri-apps/api@2.9.0\" = fetchurl {\n    url = \"https://registry.npmjs.org/@tauri-apps/api/-/api-2.9.0.tgz\";\n    hash = \"sha512-qD5tMjh7utwBk9/5PrTA/aGr3i5QaJ/Mlt7p8NilQ45WgbifUNPyKWsA63iQ8YfQq6R8ajMapU+/Q8nMcPRLNw==\";\n  };\n  \"@tauri-apps/cli-darwin-arm64@2.10.0\" = fetchurl {\n    url = \"https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.10.0.tgz\";\n    hash = \"sha512-avqHD4HRjrMamE/7R/kzJPcAJnZs0IIS+1nkDP5b+TNBn3py7N2aIo9LIpy+VQq0AkN8G5dDpZtOOBkmWt/zjA==\";\n  };\n  \"@tauri-apps/cli-darwin-x64@2.10.0\" = fetchurl {\n    url = \"https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.10.0.tgz\";\n    hash = \"sha512-keDmlvJRStzVFjZTd0xYkBONLtgBC9eMTpmXnBXzsHuawV2q9PvDo2x6D5mhuoMVrJ9QWjgaPKBBCFks4dK71Q==\";\n  };\n  \"@tauri-apps/cli-linux-arm-gnueabihf@2.10.0\" = fetchurl {\n    url = \"https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.10.0.tgz\";\n    hash = \"sha512-e5u0VfLZsMAC9iHaOEANumgl6lfnJx0Dtjkd8IJpysZ8jp0tJ6wrIkto2OzQgzcYyRCKgX72aKE0PFgZputA8g==\";\n  };\n  \"@tauri-apps/cli-linux-arm64-gnu@2.10.0\" = fetchurl {\n    url = \"https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.10.0.tgz\";\n    hash = \"sha512-YrYYk2dfmBs5m+OIMCrb+JH/oo+4FtlpcrTCgiFYc7vcs6m3QDd1TTyWu0u01ewsCtK2kOdluhr/zKku+KP7HA==\";\n  };\n  \"@tauri-apps/cli-linux-arm64-musl@2.10.0\" = fetchurl {\n    url = \"https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.10.0.tgz\";\n    hash = \"sha512-GUoPdVJmrJRIXFfW3Rkt+eGK9ygOdyISACZfC/bCSfOnGt8kNdQIQr5WRH9QUaTVFIwxMlQyV3m+yXYP+xhSVA==\";\n  };\n  \"@tauri-apps/cli-linux-riscv64-gnu@2.10.0\" = fetchurl {\n    url = \"https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.10.0.tgz\";\n    hash = \"sha512-JO7s3TlSxshwsoKNCDkyvsx5gw2QAs/Y2GbR5UE2d5kkU138ATKoPOtxn8G1fFT1aDW4LH0rYAAfBpGkDyJJnw==\";\n  };\n  \"@tauri-apps/cli-linux-x64-gnu@2.10.0\" = fetchurl {\n    url = \"https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.10.0.tgz\";\n    hash = \"sha512-Uvh4SUUp4A6DVRSMWjelww0GnZI3PlVy7VS+DRF5napKuIehVjGl9XD0uKoCoxwAQBLctvipyEK+pDXpJeoHng==\";\n  };\n  \"@tauri-apps/cli-linux-x64-musl@2.10.0\" = fetchurl {\n    url = \"https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.10.0.tgz\";\n    hash = \"sha512-AP0KRK6bJuTpQ8kMNWvhIpKUkQJfcPFeba7QshOQZjJ8wOS6emwTN4K5g/d3AbCMo0RRdnZWwu67MlmtJyxC1Q==\";\n  };\n  \"@tauri-apps/cli-win32-arm64-msvc@2.10.0\" = fetchurl {\n    url = \"https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.10.0.tgz\";\n    hash = \"sha512-97DXVU3dJystrq7W41IX+82JEorLNY+3+ECYxvXWqkq7DBN6FsA08x/EFGE8N/b0LTOui9X2dvpGGoeZKKV08g==\";\n  };\n  \"@tauri-apps/cli-win32-ia32-msvc@2.10.0\" = fetchurl {\n    url = \"https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.10.0.tgz\";\n    hash = \"sha512-EHyQ1iwrWy1CwMalEm9z2a6L5isQ121pe7FcA2xe4VWMJp+GHSDDGvbTv/OPdkt2Lyr7DAZBpZHM6nvlHXEc4A==\";\n  };\n  \"@tauri-apps/cli-win32-x64-msvc@2.10.0\" = fetchurl {\n    url = \"https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.10.0.tgz\";\n    hash = \"sha512-NTpyQxkpzGmU6ceWBTY2xRIEaS0ZLbVx1HE1zTA3TY/pV3+cPoPPOs+7YScr4IMzXMtOw7tLw5LEXo5oIG3qaQ==\";\n  };\n  \"@tauri-apps/cli@2.10.0\" = fetchurl {\n    url = \"https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.10.0.tgz\";\n    hash = \"sha512-ZwT0T+7bw4+DPCSWzmviwq5XbXlM0cNoleDKOYPFYqcZqeKY31KlpoMW/MOON/tOFBPgi31a2v3w9gliqwL2+Q==\";\n  };\n  \"@tauri-apps/plugin-autostart@2.5.1\" = fetchurl {\n    url = \"https://registry.npmjs.org/@tauri-apps/plugin-autostart/-/plugin-autostart-2.5.1.tgz\";\n    hash = \"sha512-zS/xx7yzveCcotkA+8TqkI2lysmG2wvQXv2HGAVExITmnFfHAdj1arGsbbfs3o6EktRHf6l34pJxc3YGG2mg7w==\";\n  };\n  \"@tauri-apps/plugin-clipboard-manager@2.3.2\" = fetchurl {\n    url = \"https://registry.npmjs.org/@tauri-apps/plugin-clipboard-manager/-/plugin-clipboard-manager-2.3.2.tgz\";\n    hash = \"sha512-CUlb5Hqi2oZbcZf4VUyUH53XWPPdtpw43EUpCza5HWZJwxEoDowFzNUDt1tRUXA8Uq+XPn17Ysfptip33sG4eQ==\";\n  };\n  \"@tauri-apps/plugin-dialog@2.6.0\" = fetchurl {\n    url = \"https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.6.0.tgz\";\n    hash = \"sha512-q4Uq3eY87TdcYzXACiYSPhmpBA76shgmQswGkSVio4C82Sz2W4iehe9TnKYwbq7weHiL88Yw19XZm7v28+Micg==\";\n  };\n  \"@tauri-apps/plugin-fs@2.4.4\" = fetchurl {\n    url = \"https://registry.npmjs.org/@tauri-apps/plugin-fs/-/plugin-fs-2.4.4.tgz\";\n    hash = \"sha512-MTorXxIRmOnOPT1jZ3w96vjSuScER38ryXY88vl5F0uiKdnvTKKTtaEjTEo8uPbl4e3gnUtfsDVwC7h77GQLvQ==\";\n  };\n  \"@tauri-apps/plugin-global-shortcut@2.3.1\" = fetchurl {\n    url = \"https://registry.npmjs.org/@tauri-apps/plugin-global-shortcut/-/plugin-global-shortcut-2.3.1.tgz\";\n    hash = \"sha512-vr40W2N6G63dmBPaha1TsBQLLURXG538RQbH5vAm0G/ovVZyXJrmZR1HF1W+WneNloQvwn4dm8xzwpEXRW560g==\";\n  };\n  \"@tauri-apps/plugin-opener@2.5.2\" = fetchurl {\n    url = \"https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.5.2.tgz\";\n    hash = \"sha512-ei/yRRoCklWHImwpCcDK3VhNXx+QXM9793aQ64YxpqVF0BDuuIlXhZgiAkc15wnPVav+IbkYhmDJIv5R326Mew==\";\n  };\n  \"@tauri-apps/plugin-os@2.3.2\" = fetchurl {\n    url = \"https://registry.npmjs.org/@tauri-apps/plugin-os/-/plugin-os-2.3.2.tgz\";\n    hash = \"sha512-n+nXWeuSeF9wcEsSPmRnBEGrRgOy6jjkSU+UVCOV8YUGKb2erhDOxis7IqRXiRVHhY8XMKks00BJ0OAdkpf6+A==\";\n  };\n  \"@tauri-apps/plugin-process@2.3.1\" = fetchurl {\n    url = \"https://registry.npmjs.org/@tauri-apps/plugin-process/-/plugin-process-2.3.1.tgz\";\n    hash = \"sha512-nCa4fGVaDL/B9ai03VyPOjfAHRHSBz5v6F/ObsB73r/dA3MHHhZtldaDMIc0V/pnUw9ehzr2iEG+XkSEyC0JJA==\";\n  };\n  \"@tauri-apps/plugin-sql@2.3.1\" = fetchurl {\n    url = \"https://registry.npmjs.org/@tauri-apps/plugin-sql/-/plugin-sql-2.3.1.tgz\";\n    hash = \"sha512-iNgHnFIR+jRkx9INKVKepzMlxXtNkJUaWuhagFjT4dOttPaNyRnVHgwTjpqZhyVjiklDh2UdEPAJkQKiCPAekw==\";\n  };\n  \"@tauri-apps/plugin-store@2.4.1\" = fetchurl {\n    url = \"https://registry.npmjs.org/@tauri-apps/plugin-store/-/plugin-store-2.4.1.tgz\";\n    hash = \"sha512-ckGSEzZ5Ii4Hf2D5x25Oqnm2Zf9MfDWAzR+volY0z/OOBz6aucPKEY0F649JvQ0Vupku6UJo7ugpGRDOFOunkA==\";\n  };\n  \"@tauri-apps/plugin-updater@2.10.0\" = fetchurl {\n    url = \"https://registry.npmjs.org/@tauri-apps/plugin-updater/-/plugin-updater-2.10.0.tgz\";\n    hash = \"sha512-ljN8jPlnT0aSn8ecYhuBib84alxfMx6Hc8vJSKMJyzGbTPFZAC44T2I1QNFZssgWKrAlofvJqCC6Rr472JWfkQ==\";\n  };\n  \"@tybys/wasm-util@0.10.1\" = fetchurl {\n    url = \"https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz\";\n    hash = \"sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==\";\n  };\n  \"@types/babel__core@7.20.5\" = fetchurl {\n    url = \"https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz\";\n    hash = \"sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==\";\n  };\n  \"@types/babel__generator@7.27.0\" = fetchurl {\n    url = \"https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz\";\n    hash = \"sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==\";\n  };\n  \"@types/babel__template@7.4.4\" = fetchurl {\n    url = \"https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz\";\n    hash = \"sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==\";\n  };\n  \"@types/babel__traverse@7.28.0\" = fetchurl {\n    url = \"https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz\";\n    hash = \"sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==\";\n  };\n  \"@types/estree@1.0.8\" = fetchurl {\n    url = \"https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz\";\n    hash = \"sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==\";\n  };\n  \"@types/json-schema@7.0.15\" = fetchurl {\n    url = \"https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz\";\n    hash = \"sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==\";\n  };\n  \"@types/node@24.9.1\" = fetchurl {\n    url = \"https://registry.npmjs.org/@types/node/-/node-24.9.1.tgz\";\n    hash = \"sha512-QoiaXANRkSXK6p0Duvt56W208du4P9Uye9hWLWgGMDTEoKPhuenzNcC4vGUmrNkiOKTlIrBoyNQYNpSwfEZXSg==\";\n  };\n  \"@types/parse-json@4.0.2\" = fetchurl {\n    url = \"https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz\";\n    hash = \"sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==\";\n  };\n  \"@types/prop-types@15.7.15\" = fetchurl {\n    url = \"https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz\";\n    hash = \"sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==\";\n  };\n  \"@types/react-dom@18.3.7\" = fetchurl {\n    url = \"https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz\";\n    hash = \"sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==\";\n  };\n  \"@types/react-select@5.0.1\" = fetchurl {\n    url = \"https://registry.npmjs.org/@types/react-select/-/react-select-5.0.1.tgz\";\n    hash = \"sha512-h5Im0AP0dr4AVeHtrcvQrLV+gmPa7SA0AGdxl2jOhtwiE6KgXBFSogWw8az32/nusE6AQHlCOHQWjP1S/+oMWA==\";\n  };\n  \"@types/react-transition-group@4.4.12\" = fetchurl {\n    url = \"https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.12.tgz\";\n    hash = \"sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w==\";\n  };\n  \"@types/react@18.3.26\" = fetchurl {\n    url = \"https://registry.npmjs.org/@types/react/-/react-18.3.26.tgz\";\n    hash = \"sha512-RFA/bURkcKzx/X9oumPG9Vp3D3JUgus/d0b67KB0t5S/raciymilkOa66olh78MUI92QLbEJevO7rvqU/kjwKA==\";\n  };\n  \"@typescript-eslint/eslint-plugin@8.49.0\" = fetchurl {\n    url = \"https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.49.0.tgz\";\n    hash = \"sha512-JXij0vzIaTtCwu6SxTh8qBc66kmf1xs7pI4UOiMDFVct6q86G0Zs7KRcEoJgY3Cav3x5Tq0MF5jwgpgLqgKG3A==\";\n  };\n  \"@typescript-eslint/parser@8.49.0\" = fetchurl {\n    url = \"https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.49.0.tgz\";\n    hash = \"sha512-N9lBGA9o9aqb1hVMc9hzySbhKibHmB+N3IpoShyV6HyQYRGIhlrO5rQgttypi+yEeKsKI4idxC8Jw6gXKD4THA==\";\n  };\n  \"@typescript-eslint/project-service@8.49.0\" = fetchurl {\n    url = \"https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.49.0.tgz\";\n    hash = \"sha512-/wJN0/DKkmRUMXjZUXYZpD1NEQzQAAn9QWfGwo+Ai8gnzqH7tvqS7oNVdTjKqOcPyVIdZdyCMoqN66Ia789e7g==\";\n  };\n  \"@typescript-eslint/scope-manager@8.49.0\" = fetchurl {\n    url = \"https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.49.0.tgz\";\n    hash = \"sha512-npgS3zi+/30KSOkXNs0LQXtsg9ekZ8OISAOLGWA/ZOEn0ZH74Ginfl7foziV8DT+D98WfQ5Kopwqb/PZOaIJGg==\";\n  };\n  \"@typescript-eslint/tsconfig-utils@8.49.0\" = fetchurl {\n    url = \"https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.49.0.tgz\";\n    hash = \"sha512-8prixNi1/6nawsRYxet4YOhnbW+W9FK/bQPxsGB1D3ZrDzbJ5FXw5XmzxZv82X3B+ZccuSxo/X8q9nQ+mFecWA==\";\n  };\n  \"@typescript-eslint/type-utils@8.49.0\" = fetchurl {\n    url = \"https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.49.0.tgz\";\n    hash = \"sha512-KTExJfQ+svY8I10P4HdxKzWsvtVnsuCifU5MvXrRwoP2KOlNZ9ADNEWWsQTJgMxLzS5VLQKDjkCT/YzgsnqmZg==\";\n  };\n  \"@typescript-eslint/types@8.49.0\" = fetchurl {\n    url = \"https://registry.npmjs.org/@typescript-eslint/types/-/types-8.49.0.tgz\";\n    hash = \"sha512-e9k/fneezorUo6WShlQpMxXh8/8wfyc+biu6tnAqA81oWrEic0k21RHzP9uqqpyBBeBKu4T+Bsjy9/b8u7obXQ==\";\n  };\n  \"@typescript-eslint/typescript-estree@8.49.0\" = fetchurl {\n    url = \"https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.49.0.tgz\";\n    hash = \"sha512-jrLdRuAbPfPIdYNppHJ/D0wN+wwNfJ32YTAm10eJVsFmrVpXQnDWBn8niCSMlWjvml8jsce5E/O+86IQtTbJWA==\";\n  };\n  \"@typescript-eslint/utils@8.49.0\" = fetchurl {\n    url = \"https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.49.0.tgz\";\n    hash = \"sha512-N3W7rJw7Rw+z1tRsHZbK395TWSYvufBXumYtEGzypgMUthlg0/hmCImeA8hgO2d2G4pd7ftpxxul2J8OdtdaFA==\";\n  };\n  \"@typescript-eslint/visitor-keys@8.49.0\" = fetchurl {\n    url = \"https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.49.0.tgz\";\n    hash = \"sha512-LlKaciDe3GmZFphXIc79THF/YYBugZ7FS1pO581E/edlVVNbZKDy93evqmrfQ9/Y4uN0vVhX4iuchq26mK/iiA==\";\n  };\n  \"@vitejs/plugin-react@4.7.0\" = fetchurl {\n    url = \"https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz\";\n    hash = \"sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==\";\n  };\n  \"acorn-jsx@5.3.2\" = fetchurl {\n    url = \"https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz\";\n    hash = \"sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==\";\n  };\n  \"acorn@8.15.0\" = fetchurl {\n    url = \"https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz\";\n    hash = \"sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==\";\n  };\n  \"ajv@6.12.6\" = fetchurl {\n    url = \"https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz\";\n    hash = \"sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==\";\n  };\n  \"ansi-styles@4.3.0\" = fetchurl {\n    url = \"https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz\";\n    hash = \"sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==\";\n  };\n  \"argparse@2.0.1\" = fetchurl {\n    url = \"https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz\";\n    hash = \"sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==\";\n  };\n  \"babel-plugin-macros@3.1.0\" = fetchurl {\n    url = \"https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz\";\n    hash = \"sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==\";\n  };\n  \"balanced-match@1.0.2\" = fetchurl {\n    url = \"https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz\";\n    hash = \"sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==\";\n  };\n  \"baseline-browser-mapping@2.8.20\" = fetchurl {\n    url = \"https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.20.tgz\";\n    hash = \"sha512-JMWsdF+O8Orq3EMukbUN1QfbLK9mX2CkUmQBcW2T0s8OmdAUL5LLM/6wFwSrqXzlXB13yhyK9gTKS1rIizOduQ==\";\n  };\n  \"brace-expansion@1.1.12\" = fetchurl {\n    url = \"https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz\";\n    hash = \"sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==\";\n  };\n  \"brace-expansion@2.0.2\" = fetchurl {\n    url = \"https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz\";\n    hash = \"sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==\";\n  };\n  \"browserslist@4.27.0\" = fetchurl {\n    url = \"https://registry.npmjs.org/browserslist/-/browserslist-4.27.0.tgz\";\n    hash = \"sha512-AXVQwdhot1eqLihwasPElhX2tAZiBjWdJ9i/Zcj2S6QYIjkx62OKSfnobkriB81C3l4w0rVy3Nt4jaTBltYEpw==\";\n  };\n  \"callsites@3.1.0\" = fetchurl {\n    url = \"https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz\";\n    hash = \"sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==\";\n  };\n  \"caniuse-lite@1.0.30001751\" = fetchurl {\n    url = \"https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001751.tgz\";\n    hash = \"sha512-A0QJhug0Ly64Ii3eIqHu5X51ebln3k4yTUkY1j8drqpWHVreg/VLijN48cZ1bYPiqOQuqpkIKnzr/Ul8V+p6Cw==\";\n  };\n  \"chalk@4.1.2\" = fetchurl {\n    url = \"https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz\";\n    hash = \"sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==\";\n  };\n  \"color-convert@2.0.1\" = fetchurl {\n    url = \"https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz\";\n    hash = \"sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==\";\n  };\n  \"color-name@1.1.4\" = fetchurl {\n    url = \"https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz\";\n    hash = \"sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==\";\n  };\n  \"concat-map@0.0.1\" = fetchurl {\n    url = \"https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz\";\n    hash = \"sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==\";\n  };\n  \"convert-source-map@1.9.0\" = fetchurl {\n    url = \"https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz\";\n    hash = \"sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==\";\n  };\n  \"convert-source-map@2.0.0\" = fetchurl {\n    url = \"https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz\";\n    hash = \"sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==\";\n  };\n  \"cosmiconfig@7.1.0\" = fetchurl {\n    url = \"https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz\";\n    hash = \"sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==\";\n  };\n  \"cross-spawn@7.0.6\" = fetchurl {\n    url = \"https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz\";\n    hash = \"sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==\";\n  };\n  \"csstype@3.1.3\" = fetchurl {\n    url = \"https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz\";\n    hash = \"sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==\";\n  };\n  \"debug@4.4.3\" = fetchurl {\n    url = \"https://registry.npmjs.org/debug/-/debug-4.4.3.tgz\";\n    hash = \"sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==\";\n  };\n  \"deep-is@0.1.4\" = fetchurl {\n    url = \"https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz\";\n    hash = \"sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==\";\n  };\n  \"detect-libc@2.1.2\" = fetchurl {\n    url = \"https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz\";\n    hash = \"sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==\";\n  };\n  \"dom-helpers@5.2.1\" = fetchurl {\n    url = \"https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz\";\n    hash = \"sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==\";\n  };\n  \"electron-to-chromium@1.5.240\" = fetchurl {\n    url = \"https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.240.tgz\";\n    hash = \"sha512-OBwbZjWgrCOH+g6uJsA2/7Twpas2OlepS9uvByJjR2datRDuKGYeD+nP8lBBks2qnB7bGJNHDUx7c/YLaT3QMQ==\";\n  };\n  \"enhanced-resolve@5.18.3\" = fetchurl {\n    url = \"https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz\";\n    hash = \"sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==\";\n  };\n  \"error-ex@1.3.4\" = fetchurl {\n    url = \"https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz\";\n    hash = \"sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==\";\n  };\n  \"esbuild@0.25.11\" = fetchurl {\n    url = \"https://registry.npmjs.org/esbuild/-/esbuild-0.25.11.tgz\";\n    hash = \"sha512-KohQwyzrKTQmhXDW1PjCv3Tyspn9n5GcY2RTDqeORIdIJY8yKIF7sTSopFmn/wpMPW4rdPXI0UE5LJLuq3bx0Q==\";\n  };\n  \"escalade@3.2.0\" = fetchurl {\n    url = \"https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz\";\n    hash = \"sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==\";\n  };\n  \"escape-string-regexp@4.0.0\" = fetchurl {\n    url = \"https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz\";\n    hash = \"sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==\";\n  };\n  \"eslint-plugin-i18next@6.1.3\" = fetchurl {\n    url = \"https://registry.npmjs.org/eslint-plugin-i18next/-/eslint-plugin-i18next-6.1.3.tgz\";\n    hash = \"sha512-z/h4oBRd9wI1ET60HqcLSU6XPeAh/EPOrBBTyCdkWeMoYrWAaUVA+DOQkWTiNIyCltG4NTmy62SQisVXxoXurw==\";\n  };\n  \"eslint-scope@8.4.0\" = fetchurl {\n    url = \"https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz\";\n    hash = \"sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==\";\n  };\n  \"eslint-visitor-keys@3.4.3\" = fetchurl {\n    url = \"https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz\";\n    hash = \"sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==\";\n  };\n  \"eslint-visitor-keys@4.2.1\" = fetchurl {\n    url = \"https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz\";\n    hash = \"sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==\";\n  };\n  \"eslint@9.39.1\" = fetchurl {\n    url = \"https://registry.npmjs.org/eslint/-/eslint-9.39.1.tgz\";\n    hash = \"sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==\";\n  };\n  \"espree@10.4.0\" = fetchurl {\n    url = \"https://registry.npmjs.org/espree/-/espree-10.4.0.tgz\";\n    hash = \"sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==\";\n  };\n  \"esquery@1.6.0\" = fetchurl {\n    url = \"https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz\";\n    hash = \"sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==\";\n  };\n  \"esrecurse@4.3.0\" = fetchurl {\n    url = \"https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz\";\n    hash = \"sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==\";\n  };\n  \"estraverse@5.3.0\" = fetchurl {\n    url = \"https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz\";\n    hash = \"sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==\";\n  };\n  \"esutils@2.0.3\" = fetchurl {\n    url = \"https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz\";\n    hash = \"sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==\";\n  };\n  \"fast-deep-equal@3.1.3\" = fetchurl {\n    url = \"https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz\";\n    hash = \"sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==\";\n  };\n  \"fast-json-stable-stringify@2.1.0\" = fetchurl {\n    url = \"https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz\";\n    hash = \"sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==\";\n  };\n  \"fast-levenshtein@2.0.6\" = fetchurl {\n    url = \"https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz\";\n    hash = \"sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==\";\n  };\n  \"fdir@6.5.0\" = fetchurl {\n    url = \"https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz\";\n    hash = \"sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==\";\n  };\n  \"file-entry-cache@8.0.0\" = fetchurl {\n    url = \"https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz\";\n    hash = \"sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==\";\n  };\n  \"find-root@1.1.0\" = fetchurl {\n    url = \"https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz\";\n    hash = \"sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==\";\n  };\n  \"find-up@5.0.0\" = fetchurl {\n    url = \"https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz\";\n    hash = \"sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==\";\n  };\n  \"flat-cache@4.0.1\" = fetchurl {\n    url = \"https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz\";\n    hash = \"sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==\";\n  };\n  \"flatted@3.3.3\" = fetchurl {\n    url = \"https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz\";\n    hash = \"sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==\";\n  };\n  \"fsevents@2.3.2\" = fetchurl {\n    url = \"https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz\";\n    hash = \"sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==\";\n  };\n  \"fsevents@2.3.3\" = fetchurl {\n    url = \"https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz\";\n    hash = \"sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==\";\n  };\n  \"function-bind@1.1.2\" = fetchurl {\n    url = \"https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz\";\n    hash = \"sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==\";\n  };\n  \"gensync@1.0.0-beta.2\" = fetchurl {\n    url = \"https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz\";\n    hash = \"sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==\";\n  };\n  \"glob-parent@6.0.2\" = fetchurl {\n    url = \"https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz\";\n    hash = \"sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==\";\n  };\n  \"globals@14.0.0\" = fetchurl {\n    url = \"https://registry.npmjs.org/globals/-/globals-14.0.0.tgz\";\n    hash = \"sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==\";\n  };\n  \"graceful-fs@4.2.11\" = fetchurl {\n    url = \"https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz\";\n    hash = \"sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==\";\n  };\n  \"has-flag@4.0.0\" = fetchurl {\n    url = \"https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz\";\n    hash = \"sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==\";\n  };\n  \"hasown@2.0.2\" = fetchurl {\n    url = \"https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz\";\n    hash = \"sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==\";\n  };\n  \"hoist-non-react-statics@3.3.2\" = fetchurl {\n    url = \"https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz\";\n    hash = \"sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==\";\n  };\n  \"html-parse-stringify@3.0.1\" = fetchurl {\n    url = \"https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz\";\n    hash = \"sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==\";\n  };\n  \"i18next@25.7.2\" = fetchurl {\n    url = \"https://registry.npmjs.org/i18next/-/i18next-25.7.2.tgz\";\n    hash = \"sha512-58b4kmLpLv1buWUEwegMDUqZVR5J+rT+WTRFaBGL7lxDuJQQ0NrJFrq+eT2N94aYVR1k1Sr13QITNOL88tZCuw==\";\n  };\n  \"ignore@5.3.2\" = fetchurl {\n    url = \"https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz\";\n    hash = \"sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==\";\n  };\n  \"ignore@7.0.5\" = fetchurl {\n    url = \"https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz\";\n    hash = \"sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==\";\n  };\n  \"immer@11.1.3\" = fetchurl {\n    url = \"https://registry.npmjs.org/immer/-/immer-11.1.3.tgz\";\n    hash = \"sha512-6jQTc5z0KJFtr1UgFpIL3N9XSC3saRaI9PwWtzM2pSqkNGtiNkYY2OSwkOGDK2XcTRcLb1pi/aNkKZz0nxVH4Q==\";\n  };\n  \"import-fresh@3.3.1\" = fetchurl {\n    url = \"https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz\";\n    hash = \"sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==\";\n  };\n  \"imurmurhash@0.1.4\" = fetchurl {\n    url = \"https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz\";\n    hash = \"sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==\";\n  };\n  \"is-arrayish@0.2.1\" = fetchurl {\n    url = \"https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz\";\n    hash = \"sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==\";\n  };\n  \"is-core-module@2.16.1\" = fetchurl {\n    url = \"https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz\";\n    hash = \"sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==\";\n  };\n  \"is-extglob@2.1.1\" = fetchurl {\n    url = \"https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz\";\n    hash = \"sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==\";\n  };\n  \"is-glob@4.0.3\" = fetchurl {\n    url = \"https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz\";\n    hash = \"sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==\";\n  };\n  \"isexe@2.0.0\" = fetchurl {\n    url = \"https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz\";\n    hash = \"sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==\";\n  };\n  \"jiti@2.6.1\" = fetchurl {\n    url = \"https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz\";\n    hash = \"sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==\";\n  };\n  \"js-tokens@4.0.0\" = fetchurl {\n    url = \"https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz\";\n    hash = \"sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==\";\n  };\n  \"js-yaml@4.1.1\" = fetchurl {\n    url = \"https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz\";\n    hash = \"sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==\";\n  };\n  \"jsesc@3.1.0\" = fetchurl {\n    url = \"https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz\";\n    hash = \"sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==\";\n  };\n  \"json-buffer@3.0.1\" = fetchurl {\n    url = \"https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz\";\n    hash = \"sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==\";\n  };\n  \"json-parse-even-better-errors@2.3.1\" = fetchurl {\n    url = \"https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz\";\n    hash = \"sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==\";\n  };\n  \"json-schema-traverse@0.4.1\" = fetchurl {\n    url = \"https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz\";\n    hash = \"sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==\";\n  };\n  \"json-stable-stringify-without-jsonify@1.0.1\" = fetchurl {\n    url = \"https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz\";\n    hash = \"sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==\";\n  };\n  \"json5@2.2.3\" = fetchurl {\n    url = \"https://registry.npmjs.org/json5/-/json5-2.2.3.tgz\";\n    hash = \"sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==\";\n  };\n  \"keyv@4.5.4\" = fetchurl {\n    url = \"https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz\";\n    hash = \"sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==\";\n  };\n  \"levn@0.4.1\" = fetchurl {\n    url = \"https://registry.npmjs.org/levn/-/levn-0.4.1.tgz\";\n    hash = \"sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==\";\n  };\n  \"lightningcss-android-arm64@1.30.2\" = fetchurl {\n    url = \"https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz\";\n    hash = \"sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==\";\n  };\n  \"lightningcss-darwin-arm64@1.30.2\" = fetchurl {\n    url = \"https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz\";\n    hash = \"sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==\";\n  };\n  \"lightningcss-darwin-x64@1.30.2\" = fetchurl {\n    url = \"https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz\";\n    hash = \"sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==\";\n  };\n  \"lightningcss-freebsd-x64@1.30.2\" = fetchurl {\n    url = \"https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz\";\n    hash = \"sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==\";\n  };\n  \"lightningcss-linux-arm-gnueabihf@1.30.2\" = fetchurl {\n    url = \"https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz\";\n    hash = \"sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==\";\n  };\n  \"lightningcss-linux-arm64-gnu@1.30.2\" = fetchurl {\n    url = \"https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz\";\n    hash = \"sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==\";\n  };\n  \"lightningcss-linux-arm64-musl@1.30.2\" = fetchurl {\n    url = \"https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz\";\n    hash = \"sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==\";\n  };\n  \"lightningcss-linux-x64-gnu@1.30.2\" = fetchurl {\n    url = \"https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz\";\n    hash = \"sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==\";\n  };\n  \"lightningcss-linux-x64-musl@1.30.2\" = fetchurl {\n    url = \"https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz\";\n    hash = \"sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==\";\n  };\n  \"lightningcss-win32-arm64-msvc@1.30.2\" = fetchurl {\n    url = \"https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz\";\n    hash = \"sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==\";\n  };\n  \"lightningcss-win32-x64-msvc@1.30.2\" = fetchurl {\n    url = \"https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz\";\n    hash = \"sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==\";\n  };\n  \"lightningcss@1.30.2\" = fetchurl {\n    url = \"https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz\";\n    hash = \"sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==\";\n  };\n  \"lines-and-columns@1.2.4\" = fetchurl {\n    url = \"https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz\";\n    hash = \"sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==\";\n  };\n  \"locate-path@6.0.0\" = fetchurl {\n    url = \"https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz\";\n    hash = \"sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==\";\n  };\n  \"lodash.merge@4.6.2\" = fetchurl {\n    url = \"https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz\";\n    hash = \"sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==\";\n  };\n  \"lodash@4.17.21\" = fetchurl {\n    url = \"https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz\";\n    hash = \"sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==\";\n  };\n  \"loose-envify@1.4.0\" = fetchurl {\n    url = \"https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz\";\n    hash = \"sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==\";\n  };\n  \"lru-cache@5.1.1\" = fetchurl {\n    url = \"https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz\";\n    hash = \"sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==\";\n  };\n  \"lucide-react@0.542.0\" = fetchurl {\n    url = \"https://registry.npmjs.org/lucide-react/-/lucide-react-0.542.0.tgz\";\n    hash = \"sha512-w3hD8/SQB7+lzU2r4VdFyzzOzKnUjTZIF/MQJGSSvni7Llewni4vuViRppfRAa2guOsY5k4jZyxw/i9DQHv+dw==\";\n  };\n  \"magic-string@0.30.21\" = fetchurl {\n    url = \"https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz\";\n    hash = \"sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==\";\n  };\n  \"memoize-one@6.0.0\" = fetchurl {\n    url = \"https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz\";\n    hash = \"sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==\";\n  };\n  \"minimatch@3.1.2\" = fetchurl {\n    url = \"https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz\";\n    hash = \"sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==\";\n  };\n  \"minimatch@9.0.5\" = fetchurl {\n    url = \"https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz\";\n    hash = \"sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==\";\n  };\n  \"ms@2.1.3\" = fetchurl {\n    url = \"https://registry.npmjs.org/ms/-/ms-2.1.3.tgz\";\n    hash = \"sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==\";\n  };\n  \"nanoid@3.3.11\" = fetchurl {\n    url = \"https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz\";\n    hash = \"sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==\";\n  };\n  \"natural-compare@1.4.0\" = fetchurl {\n    url = \"https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz\";\n    hash = \"sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==\";\n  };\n  \"node-releases@2.0.26\" = fetchurl {\n    url = \"https://registry.npmjs.org/node-releases/-/node-releases-2.0.26.tgz\";\n    hash = \"sha512-S2M9YimhSjBSvYnlr5/+umAnPHE++ODwt5e2Ij6FoX45HA/s4vHdkDx1eax2pAPeAOqu4s9b7ppahsyEFdVqQA==\";\n  };\n  \"object-assign@4.1.1\" = fetchurl {\n    url = \"https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz\";\n    hash = \"sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==\";\n  };\n  \"optionator@0.9.4\" = fetchurl {\n    url = \"https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz\";\n    hash = \"sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==\";\n  };\n  \"p-limit@3.1.0\" = fetchurl {\n    url = \"https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz\";\n    hash = \"sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==\";\n  };\n  \"p-locate@5.0.0\" = fetchurl {\n    url = \"https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz\";\n    hash = \"sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==\";\n  };\n  \"parent-module@1.0.1\" = fetchurl {\n    url = \"https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz\";\n    hash = \"sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==\";\n  };\n  \"parse-json@5.2.0\" = fetchurl {\n    url = \"https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz\";\n    hash = \"sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==\";\n  };\n  \"path-exists@4.0.0\" = fetchurl {\n    url = \"https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz\";\n    hash = \"sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==\";\n  };\n  \"path-key@3.1.1\" = fetchurl {\n    url = \"https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz\";\n    hash = \"sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==\";\n  };\n  \"path-parse@1.0.7\" = fetchurl {\n    url = \"https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz\";\n    hash = \"sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==\";\n  };\n  \"path-type@4.0.0\" = fetchurl {\n    url = \"https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz\";\n    hash = \"sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==\";\n  };\n  \"picocolors@1.1.1\" = fetchurl {\n    url = \"https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz\";\n    hash = \"sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==\";\n  };\n  \"picomatch@4.0.3\" = fetchurl {\n    url = \"https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz\";\n    hash = \"sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==\";\n  };\n  \"playwright-core@1.58.0\" = fetchurl {\n    url = \"https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.0.tgz\";\n    hash = \"sha512-aaoB1RWrdNi3//rOeKuMiS65UCcgOVljU46At6eFcOFPFHWtd2weHRRow6z/n+Lec0Lvu0k9ZPKJSjPugikirw==\";\n  };\n  \"playwright@1.58.0\" = fetchurl {\n    url = \"https://registry.npmjs.org/playwright/-/playwright-1.58.0.tgz\";\n    hash = \"sha512-2SVA0sbPktiIY/MCOPX8e86ehA/e+tDNq+e5Y8qjKYti2Z/JG7xnronT/TXTIkKbYGWlCbuucZ6dziEgkoEjQQ==\";\n  };\n  \"postcss@8.5.6\" = fetchurl {\n    url = \"https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz\";\n    hash = \"sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==\";\n  };\n  \"prelude-ls@1.2.1\" = fetchurl {\n    url = \"https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz\";\n    hash = \"sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==\";\n  };\n  \"prettier@3.6.2\" = fetchurl {\n    url = \"https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz\";\n    hash = \"sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==\";\n  };\n  \"prop-types@15.8.1\" = fetchurl {\n    url = \"https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz\";\n    hash = \"sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==\";\n  };\n  \"punycode@2.3.1\" = fetchurl {\n    url = \"https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz\";\n    hash = \"sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==\";\n  };\n  \"react-dom@18.3.1\" = fetchurl {\n    url = \"https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz\";\n    hash = \"sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==\";\n  };\n  \"react-i18next@16.4.1\" = fetchurl {\n    url = \"https://registry.npmjs.org/react-i18next/-/react-i18next-16.4.1.tgz\";\n    hash = \"sha512-GzsYomxb1/uE7nlJm0e1qQ8f+W9I3Xirh9VoycZIahk6C8Pmv/9Fd0ek6zjf1FSgtGLElDGqwi/4FOHEGUbsEQ==\";\n  };\n  \"react-is@16.13.1\" = fetchurl {\n    url = \"https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz\";\n    hash = \"sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==\";\n  };\n  \"react-refresh@0.17.0\" = fetchurl {\n    url = \"https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz\";\n    hash = \"sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==\";\n  };\n  \"react-select@5.10.2\" = fetchurl {\n    url = \"https://registry.npmjs.org/react-select/-/react-select-5.10.2.tgz\";\n    hash = \"sha512-Z33nHdEFWq9tfnfVXaiM12rbJmk+QjFEztWLtmXqQhz6Al4UZZ9xc0wiatmGtUOCCnHN0WizL3tCMYRENX4rVQ==\";\n  };\n  \"react-transition-group@4.4.5\" = fetchurl {\n    url = \"https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz\";\n    hash = \"sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==\";\n  };\n  \"react@18.3.1\" = fetchurl {\n    url = \"https://registry.npmjs.org/react/-/react-18.3.1.tgz\";\n    hash = \"sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==\";\n  };\n  \"requireindex@1.1.0\" = fetchurl {\n    url = \"https://registry.npmjs.org/requireindex/-/requireindex-1.1.0.tgz\";\n    hash = \"sha512-LBnkqsDE7BZKvqylbmn7lTIVdpx4K/QCduRATpO5R+wtPmky/a8pN1bO2D6wXppn1497AJF9mNjqAXr6bdl9jg==\";\n  };\n  \"resolve-from@4.0.0\" = fetchurl {\n    url = \"https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz\";\n    hash = \"sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==\";\n  };\n  \"resolve@1.22.11\" = fetchurl {\n    url = \"https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz\";\n    hash = \"sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==\";\n  };\n  \"rollup@4.52.5\" = fetchurl {\n    url = \"https://registry.npmjs.org/rollup/-/rollup-4.52.5.tgz\";\n    hash = \"sha512-3GuObel8h7Kqdjt0gxkEzaifHTqLVW56Y/bjN7PSQtkKr0w3V/QYSdt6QWYtd7A1xUtYQigtdUfgj1RvWVtorw==\";\n  };\n  \"scheduler@0.23.2\" = fetchurl {\n    url = \"https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz\";\n    hash = \"sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==\";\n  };\n  \"semver@6.3.1\" = fetchurl {\n    url = \"https://registry.npmjs.org/semver/-/semver-6.3.1.tgz\";\n    hash = \"sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==\";\n  };\n  \"semver@7.7.3\" = fetchurl {\n    url = \"https://registry.npmjs.org/semver/-/semver-7.7.3.tgz\";\n    hash = \"sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==\";\n  };\n  \"shebang-command@2.0.0\" = fetchurl {\n    url = \"https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz\";\n    hash = \"sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==\";\n  };\n  \"shebang-regex@3.0.0\" = fetchurl {\n    url = \"https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz\";\n    hash = \"sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==\";\n  };\n  \"sonner@2.0.7\" = fetchurl {\n    url = \"https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz\";\n    hash = \"sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==\";\n  };\n  \"source-map-js@1.2.1\" = fetchurl {\n    url = \"https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz\";\n    hash = \"sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==\";\n  };\n  \"source-map@0.5.7\" = fetchurl {\n    url = \"https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz\";\n    hash = \"sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==\";\n  };\n  \"strip-json-comments@3.1.1\" = fetchurl {\n    url = \"https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz\";\n    hash = \"sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==\";\n  };\n  \"stylis@4.2.0\" = fetchurl {\n    url = \"https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz\";\n    hash = \"sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==\";\n  };\n  \"supports-color@7.2.0\" = fetchurl {\n    url = \"https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz\";\n    hash = \"sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==\";\n  };\n  \"supports-preserve-symlinks-flag@1.0.0\" = fetchurl {\n    url = \"https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz\";\n    hash = \"sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==\";\n  };\n  \"tailwindcss@4.1.16\" = fetchurl {\n    url = \"https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.16.tgz\";\n    hash = \"sha512-pONL5awpaQX4LN5eiv7moSiSPd/DLDzKVRJz8Q9PgzmAdd1R4307GQS2ZpfiN7ZmekdQrfhZZiSE5jkLR4WNaA==\";\n  };\n  \"tapable@2.3.0\" = fetchurl {\n    url = \"https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz\";\n    hash = \"sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==\";\n  };\n  \"tauri-plugin-macos-permissions-api@2.3.0\" = fetchurl {\n    url = \"https://registry.npmjs.org/tauri-plugin-macos-permissions-api/-/tauri-plugin-macos-permissions-api-2.3.0.tgz\";\n    hash = \"sha512-pZp0jmDySysBqrGueknd1a7Rr4XEO9aXpMv9TNrT2PDHP0MSH20njieOagsFYJ5MCVb8A+wcaK0cIkjUC2dOww==\";\n  };\n  \"tinyglobby@0.2.15\" = fetchurl {\n    url = \"https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz\";\n    hash = \"sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==\";\n  };\n  \"ts-api-utils@2.1.0\" = fetchurl {\n    url = \"https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz\";\n    hash = \"sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==\";\n  };\n  \"tslib@2.8.1\" = fetchurl {\n    url = \"https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz\";\n    hash = \"sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==\";\n  };\n  \"type-check@0.4.0\" = fetchurl {\n    url = \"https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz\";\n    hash = \"sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==\";\n  };\n  \"typescript@5.6.3\" = fetchurl {\n    url = \"https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz\";\n    hash = \"sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==\";\n  };\n  \"undici-types@7.16.0\" = fetchurl {\n    url = \"https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz\";\n    hash = \"sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==\";\n  };\n  \"update-browserslist-db@1.1.4\" = fetchurl {\n    url = \"https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.4.tgz\";\n    hash = \"sha512-q0SPT4xyU84saUX+tomz1WLkxUbuaJnR1xWt17M7fJtEJigJeWUNGUqrauFXsHnqev9y9JTRGwk13tFBuKby4A==\";\n  };\n  \"uri-js@4.4.1\" = fetchurl {\n    url = \"https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz\";\n    hash = \"sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==\";\n  };\n  \"use-isomorphic-layout-effect@1.2.1\" = fetchurl {\n    url = \"https://registry.npmjs.org/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.2.1.tgz\";\n    hash = \"sha512-tpZZ+EX0gaghDAiFR37hj5MgY6ZN55kLiPkJsKxBMZ6GZdOSPJXiOzPM984oPYZ5AnehYx5WQp1+ME8I/P/pRA==\";\n  };\n  \"use-sync-external-store@1.6.0\" = fetchurl {\n    url = \"https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz\";\n    hash = \"sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==\";\n  };\n  \"vite@6.4.1\" = fetchurl {\n    url = \"https://registry.npmjs.org/vite/-/vite-6.4.1.tgz\";\n    hash = \"sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==\";\n  };\n  \"void-elements@3.1.0\" = fetchurl {\n    url = \"https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz\";\n    hash = \"sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==\";\n  };\n  \"which@2.0.2\" = fetchurl {\n    url = \"https://registry.npmjs.org/which/-/which-2.0.2.tgz\";\n    hash = \"sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==\";\n  };\n  \"word-wrap@1.2.5\" = fetchurl {\n    url = \"https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz\";\n    hash = \"sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==\";\n  };\n  \"yallist@3.1.1\" = fetchurl {\n    url = \"https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz\";\n    hash = \"sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==\";\n  };\n  \"yaml@1.10.2\" = fetchurl {\n    url = \"https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz\";\n    hash = \"sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==\";\n  };\n  \"yocto-queue@0.1.0\" = fetchurl {\n    url = \"https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz\";\n    hash = \"sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==\";\n  };\n  \"zod@3.25.76\" = fetchurl {\n    url = \"https://registry.npmjs.org/zod/-/zod-3.25.76.tgz\";\n    hash = \"sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==\";\n  };\n  \"zustand@5.0.8\" = fetchurl {\n    url = \"https://registry.npmjs.org/zustand/-/zustand-5.0.8.tgz\";\n    hash = \"sha512-gyPKpIaxY9XcO2vSMrLbiER7QMAMGOQZVRdJ6Zi782jkbzZygq5GI9nG8g+sMgitRtndwaBSl7uiqC49o1SSiw==\";\n  };\n}\n"
  },
  {
    "path": ".prettierignore",
    "content": "# Dependencies\nnode_modules\nbun.lock\npackage-lock.json\n\n# Build outputs\ndist\ntarget\n*.bundle.*\n\n# Tauri\nsrc-tauri/target\nsrc-tauri/gen\n\n# Generated files\nsrc/bindings.ts\n\n# Misc\n.DS_Store\n*.log\n"
  },
  {
    "path": ".prettierrc",
    "content": "{\n  \"endOfLine\": \"lf\"\n}\n"
  },
  {
    "path": ".vscode/extensions.json",
    "content": "{\n  \"recommendations\": [\n    \"tauri-apps.tauri-vscode\",\n    \"rust-lang.rust-analyzer\",\n    \"esbenp.prettier-vscode\"\n  ]\n}\n"
  },
  {
    "path": "AGENTS.md",
    "content": "# AGENTS.md\n\nThis file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.\n\n## Development Commands\n\n**Prerequisites:**\n\n- [Rust](https://rustup.rs/) (latest stable)\n- [Bun](https://bun.sh/) package manager\n\n**Core Development:**\n\n```bash\n# Install dependencies\nbun install\n\n# Run in development mode\nbun run tauri dev\n# If cmake error on macOS:\nCMAKE_POLICY_VERSION_MINIMUM=3.5 bun run tauri dev\n\n# Build for production\nbun run tauri build\n\n# Frontend only development\nbun run dev        # Start Vite dev server\nbun run build      # Build frontend (TypeScript + Vite)\nbun run preview    # Preview built frontend\n```\n\n**Model Setup (Required for Development):**\n\n```bash\n# Create models directory\nmkdir -p src-tauri/resources/models\n\n# Download required VAD model\ncurl -o src-tauri/resources/models/silero_vad_v4.onnx https://blob.handy.computer/silero_vad_v4.onnx\n```\n\n## Architecture Overview\n\nHandy is a cross-platform desktop speech-to-text application built with Tauri (Rust backend + React/TypeScript frontend).\n\n### Core Components\n\n**Backend (Rust - src-tauri/src/):**\n\n- `lib.rs` - Main application entry point with Tauri setup, tray menu, and managers\n- `managers/` - Core business logic managers:\n  - `audio.rs` - Audio recording and device management\n  - `model.rs` - Whisper model downloading and management\n  - `transcription.rs` - Speech-to-text processing pipeline\n- `audio_toolkit/` - Low-level audio processing:\n  - `audio/` - Device enumeration, recording, resampling\n  - `vad/` - Voice Activity Detection using Silero VAD\n- `commands/` - Tauri command handlers for frontend communication\n- `shortcut.rs` - Global keyboard shortcut handling\n- `settings.rs` - Application settings management\n\n**Frontend (React/TypeScript - src/):**\n\n- `App.tsx` - Main application component with onboarding flow\n- `components/settings/` - Settings UI components\n- `components/model-selector/` - Model management interface\n- `hooks/` - React hooks for settings and model management\n- `lib/types.ts` - Shared TypeScript type definitions\n\n### Key Architecture Patterns\n\n**Manager Pattern:** Core functionality is organized into managers (Audio, Model, Transcription) that are initialized at startup and managed by Tauri's state system.\n\n**Command-Event Architecture:** Frontend communicates with backend via Tauri commands, backend sends updates via events.\n\n**Pipeline Processing:** Audio → VAD → Whisper → Text output with configurable components at each stage.\n\n### Technology Stack\n\n**Core Libraries:**\n\n- `whisper-rs` - Local Whisper inference with GPU acceleration\n- `cpal` - Cross-platform audio I/O\n- `vad-rs` - Voice Activity Detection\n- `rdev` - Global keyboard shortcuts\n- `rubato` - Audio resampling\n- `rodio` - Audio playback for feedback sounds\n\n**Platform-Specific Features:**\n\n- macOS: Metal acceleration for Whisper, accessibility permissions\n- Windows: Vulkan acceleration, code signing\n- Linux: OpenBLAS + Vulkan acceleration\n\n### Application Flow\n\n1. **Initialization:** App starts minimized to tray, loads settings, initializes managers\n2. **Model Setup:** First-run downloads preferred Whisper model (Small/Medium/Turbo/Large)\n3. **Recording:** Global shortcut triggers audio recording with VAD filtering\n4. **Processing:** Audio sent to Whisper model for transcription\n5. **Output:** Text pasted to active application via system clipboard\n\n### Settings System\n\nSettings are stored using Tauri's store plugin with reactive updates:\n\n- Keyboard shortcuts (configurable, supports push-to-talk)\n- Audio devices (microphone/output selection)\n- Model preferences (Small/Medium/Turbo/Large Whisper variants)\n- Audio feedback and translation options\n\n### Single Instance Architecture\n\nThe app enforces single instance behavior - launching when already running brings the settings window to front rather than creating a new process.\n"
  },
  {
    "path": "BUILD.md",
    "content": "# Build Instructions\n\nThis guide covers how to set up the development environment and build Handy from source across different platforms.\n\n## Prerequisites\n\n### All Platforms\n\n- [Rust](https://rustup.rs/) (latest stable)\n- [Bun](https://bun.sh/) package manager\n- [Tauri Prerequisites](https://tauri.app/start/prerequisites/)\n\n### Platform-Specific Requirements\n\n#### macOS\n\n- Xcode Command Line Tools\n- Install with: `xcode-select --install`\n\n#### Windows\n\n- Microsoft C++ Build Tools\n- Visual Studio 2019/2022 with C++ development tools\n- Or Visual Studio Build Tools 2019/2022\n\n#### Linux\n\n- Build essentials\n- ALSA development libraries\n- Install with:\n\n  ```bash\n  # Ubuntu/Debian\n  sudo apt update\n  sudo apt install build-essential libasound2-dev pkg-config libssl-dev libvulkan-dev vulkan-tools glslc libgtk-3-dev libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev libgtk-layer-shell0 libgtk-layer-shell-dev patchelf cmake\n\n  # Fedora/RHEL\n  sudo dnf groupinstall \"Development Tools\"\n  sudo dnf install alsa-lib-devel pkgconf openssl-devel vulkan-devel \\\n    gtk3-devel webkit2gtk4.1-devel libappindicator-gtk3-devel librsvg2-devel \\\n    gtk-layer-shell gtk-layer-shell-devel \\\n    cmake\n\n  # Arch Linux\n  sudo pacman -S base-devel alsa-lib pkgconf openssl vulkan-devel \\\n    gtk3 webkit2gtk-4.1 libappindicator-gtk3 librsvg gtk-layer-shell \\\n    cmake\n  ```\n\n## Setup Instructions\n\n### 1. Clone the Repository\n\n```bash\ngit clone git@github.com:cjpais/Handy.git\ncd Handy\n```\n\n### 2. Install Dependencies\n\n```bash\nbun install\n```\n\n### 3. Start Dev Server\n\n```bash\nbun tauri dev\n```\n\n### 4. Build for Production\n\n```bash\nbun run tauri build\n```\n\nThis compiles a release binary and generates platform-specific bundles (deb, rpm, AppImage on Linux; dmg on macOS; msi on Windows).\n\n## Linux Install (from source)\n\nThe raw binary (`src-tauri/target/release/handy`) cannot run standalone — it needs Tauri resource files (tray icons, sounds, VAD model) to be co-located at the expected path.\n\n**Install from the deb bundle** (works on any Linux distro):\n\n```bash\ncd /tmp\nar x /path/to/Handy/src-tauri/target/release/bundle/deb/Handy_*_amd64.deb data.tar.gz\ntar xzf data.tar.gz\nsudo cp usr/bin/handy /usr/bin/\nsudo cp -r usr/lib/Handy /usr/lib/\nsudo cp -r usr/share/icons/hicolor/* /usr/share/icons/hicolor/\nsudo cp usr/share/applications/Handy.desktop /usr/share/applications/\n```\n\nAfter subsequent rebuilds, only the binary needs re-copying:\n\n```bash\nsudo cp src-tauri/target/release/handy /usr/bin/\n```\n\nResources only need re-copying if they change upstream (new icons, sounds, etc.).\n\n## Troubleshooting\n\n### AppImage build fails on Arch / rolling-release distros\n\n`linuxdeploy` bundles its own `strip` binary which is too old to process system libraries built with newer toolchains on rolling-release distros (Arch, CachyOS, Manjaro, EndeavourOS).\n\nThe error from Tauri:\n\n```\nBundling Handy_*_amd64.AppImage\nfailed to bundle project `failed to run linuxdeploy`\n```\n\nTauri swallows the real linuxdeploy error. To see it, run linuxdeploy manually:\n\n```bash\ncd src-tauri/target/release/bundle/appimage\n~/.cache/tauri/linuxdeploy-x86_64.AppImage --appimage-extract-and-run \\\n  --appdir Handy.AppDir --plugin gtk --output appimage\n```\n\n**Workaround:** The binary, deb, and rpm bundles all build fine — only the AppImage step fails. To skip it:\n\n```bash\nbun run tauri build -- --bundles deb\n```\n\nThen install using the deb extraction method above.\n"
  },
  {
    "path": "CHANGELOG.md",
    "content": "# Changelog\n\n## [0.3.0] - 2025-07-11\n\n### Added\n\n- **Translate to English** setting: Added automatic translation of speech to English\n- Settings refactored into React hooks for better state management\n- Audio device switching capability\n- Hysteresis to VAD (Voice Activity Detection) for more stable recording\n\n### Changed\n\n- Major audio backend refactor for improved performance and reliability\n- Moved audio toolkit into src-tauri directory for better permissions handling\n- Model files no longer need to be downloaded separately for releases\n- Updated settings components and transcription logic\n\n### Fixed\n\n- Audio toolkit permissions issues\n- Various stability improvements\n\n## [0.2.3] - 2025-07-03\n\n### Fixed\n\n- Keycode bug that was causing input issues\n- Whisper model optimization: switched to unquantized Whisper Turbo, updated Whisper Medium quantization to 4_1\n\n## [0.2.2] - 2025-07-02\n\n### Fixed\n\n- Removed 50ms delay feature flag for Windows (now applies to all platforms for consistency)\n\n## [0.2.1] - 2025-07-01\n\n### Added\n\n- Ctrl+Space key binding for Windows platform\n\n### Fixed\n\n- Windows crash issue\n- Model loading on startup when available\n- Windows paste functionality bug\n\n## [0.2.0] - 2025-06-30\n\n### Added\n\n- **Microphone activation on demand**: More efficient resource usage\n- Less permissive VAD settings for better accuracy\n\n### Changed\n\n- Improved microphone management and activation system\n\n## [0.1.6] - 2025-06-30\n\n### Added\n\n- **Multiple models support**: Users can now select from different transcription models\n- Model selection onboarding flow\n- Cleanup and refactoring of model management\n\n### Changed\n\n- Enhanced user experience with model selection interface\n- Better language and UI tweaks\n\n## [0.1.5] - 2025-06-27\n\n### Added\n\n- **Different start and stop recording sounds**: Enhanced audio feedback\n- Recording sound samples for better user experience\n\n## [0.1.4] - 2025-06-27\n\n### Fixed\n\n- Build issues\n- Auto-update functionality improvements\n\n## [0.1.3] - 2025-06-26\n\n### Fixed\n\n- Paste functionality using enigo library for better cross-platform compatibility\n\n## [0.1.2] - 2025-06-26\n\n### Added\n\n- **Auto-update functionality**: Application can now automatically update itself\n- Footer displaying current version\n- Improved menu system\n\n### Changed\n\n- Better user interface for version management\n- Enhanced update workflow\n\n## [0.1.1] - 2025-06-25\n\n### Added\n\n- **Comprehensive build system**: Support for Windows, macOS, and Linux\n- Windows code signing for trusted installation\n- Ubuntu/Linux build support with Vulkan\n- Model file download and packaging for releases\n- GitHub Actions CI/CD workflow\n\n### Changed\n\n- Improved build process and release workflow\n- Better cross-platform compatibility\n\n### Fixed\n\n- Various build-related issues across platforms\n\n## [0.1.0] - 2025-05-16\n\n### Added\n\n- **Initial release** of Handy\n- Basic speech-to-text transcription functionality\n- Voice Activity Detection (VAD) for automatic recording\n- Cross-platform support (macOS, Windows, Linux)\n- **Tauri-based desktop application** with React frontend\n- **Global keyboard shortcuts** for activation\n- **Clipboard integration** for automatic text insertion\n- **LLM integration** for enhanced transcription processing\n- **Configurable settings** including:\n  - Custom key bindings\n  - Audio device selection\n  - Microphone settings\n  - Push-to-talk functionality\n- **System tray integration** with recording indicators\n- **Accessibility permissions** handling for macOS\n- **Settings persistence** with unified settings store\n- **Background operation** capability\n- **Multiple audio format support** with on-the-fly resampling\n- **Whisper model integration** for high-quality transcription\n- **MIT License** for open-source distribution\n\n### Technical Implementation\n\n- Built with Tauri (Rust backend) and React (TypeScript frontend)\n- Audio processing with cpal and whisper-rs\n- Real-time transcription with performance optimizations\n- Cross-platform keyboard event handling\n- Modular architecture with managers for audio, models, and transcription\n"
  },
  {
    "path": "CLAUDE.md",
    "content": "# CLAUDE.md\n\nThis file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.\n\n## Development Commands\n\n**Prerequisites:** [Rust](https://rustup.rs/) (latest stable), [Bun](https://bun.sh/)\n\n```bash\n# Install dependencies\nbun install\n\n# Run in development mode\nbun run tauri dev\n# If cmake error on macOS:\nCMAKE_POLICY_VERSION_MINIMUM=3.5 bun run tauri dev\n\n# Build for production\nbun run tauri build\n\n# Linting and formatting (run before committing)\nbun run lint              # ESLint for frontend\nbun run lint:fix          # ESLint with auto-fix\nbun run format            # Prettier + cargo fmt\nbun run format:check      # Check formatting without changes\n```\n\n**Model Setup (Required for Development):**\n\n```bash\nmkdir -p src-tauri/resources/models\ncurl -o src-tauri/resources/models/silero_vad_v4.onnx https://blob.handy.computer/silero_vad_v4.onnx\n```\n\n## Architecture Overview\n\nHandy is a cross-platform desktop speech-to-text app built with Tauri 2.x (Rust backend + React/TypeScript frontend).\n\n### Backend Structure (src-tauri/src/)\n\n- `lib.rs` - Main entry point, Tauri setup, manager initialization\n- `managers/` - Core business logic:\n  - `audio.rs` - Audio recording and device management\n  - `model.rs` - Model downloading and management\n  - `transcription.rs` - Speech-to-text processing pipeline\n  - `history.rs` - Transcription history storage\n- `audio_toolkit/` - Low-level audio processing:\n  - `audio/` - Device enumeration, recording, resampling\n  - `vad/` - Voice Activity Detection (Silero VAD)\n- `commands/` - Tauri command handlers for frontend communication\n- `shortcut.rs` - Global keyboard shortcut handling\n- `settings.rs` - Application settings management\n\n### Frontend Structure (src/)\n\n- `App.tsx` - Main component with onboarding flow\n- `components/settings/` - Settings UI (35+ files)\n- `components/model-selector/` - Model management interface\n- `components/onboarding/` - First-run experience\n- `hooks/useSettings.ts`, `useModels.ts` - State management hooks\n- `stores/settingsStore.ts` - Zustand store for settings\n- `bindings.ts` - Auto-generated Tauri type bindings (via tauri-specta)\n- `overlay/` - Recording overlay window code\n\n### Key Patterns\n\n**Manager Pattern:** Core functionality organized into managers (Audio, Model, Transcription) initialized at startup and managed via Tauri state.\n\n**Command-Event Architecture:** Frontend → Backend via Tauri commands; Backend → Frontend via events.\n\n**Pipeline Processing:** Audio → VAD → Whisper/Parakeet → Text output → Clipboard/Paste\n\n**State Flow:** Zustand → Tauri Command → Rust State → Persistence (tauri-plugin-store)\n\n## Internationalization (i18n)\n\nAll user-facing strings must use i18next translations. ESLint enforces this (no hardcoded strings in JSX).\n\n**Adding new text:**\n\n1. Add key to `src/i18n/locales/en/translation.json`\n2. Use in component: `const { t } = useTranslation(); t('key.path')`\n\n**File structure:**\n\n```\nsrc/i18n/\n├── index.ts           # i18n setup\n├── languages.ts       # Language metadata\n└── locales/\n    ├── en/translation.json  # English (source)\n    ├── es/translation.json  # Spanish\n    ├── fr/translation.json  # French\n    └── vi/translation.json  # Vietnamese\n```\n\n## Code Style\n\n**Rust:**\n\n- Run `cargo fmt` and `cargo clippy` before committing\n- Handle errors explicitly (avoid unwrap in production)\n- Use descriptive names, add doc comments for public APIs\n\n**TypeScript/React:**\n\n- Strict TypeScript, avoid `any` types\n- Functional components with hooks\n- Tailwind CSS for styling\n- Path aliases: `@/` → `./src/`\n\n## Commit Guidelines\n\nUse conventional commits:\n\n- `feat:` new features\n- `fix:` bug fixes\n- `docs:` documentation\n- `refactor:` code refactoring\n- `chore:` maintenance\n\n## CLI Parameters\n\nHandy supports command-line parameters on all platforms for integration with scripts, window managers, and autostart configurations.\n\n**Implementation files:**\n\n- `src-tauri/src/cli.rs` - CLI argument definitions (clap derive)\n- `src-tauri/src/main.rs` - Argument parsing before Tauri launch\n- `src-tauri/src/lib.rs` - Applying CLI overrides (setup closure + single-instance callback)\n- `src-tauri/src/signal_handle.rs` - `send_transcription_input()` reusable function\n\n**Available flags:**\n\n| Flag                     | Description                                                                        |\n| ------------------------ | ---------------------------------------------------------------------------------- |\n| `--toggle-transcription` | Toggle recording on/off on a running instance (via `tauri_plugin_single_instance`) |\n| `--toggle-post-process`  | Toggle recording with post-processing on/off on a running instance                 |\n| `--cancel`               | Cancel the current operation on a running instance                                 |\n| `--start-hidden`         | Launch without showing the main window (tray icon still visible)                   |\n| `--no-tray`              | Launch without the system tray icon (closing window quits the app)                 |\n| `--debug`                | Enable debug mode with verbose (Trace) logging                                     |\n\n**Key design decisions:**\n\n- CLI flags are runtime-only overrides — they do NOT modify persisted settings\n- Remote control flags (`--toggle-transcription`, `--toggle-post-process`, `--cancel`) work by launching a second instance that sends its args to the running instance via `tauri_plugin_single_instance`, then exits\n- `send_transcription_input()` in `signal_handle.rs` is shared between signal handlers and CLI to avoid code duplication\n- `CliArgs` is stored in Tauri managed state (`.manage()`) so it's accessible in `on_window_event` and other handlers\n\n## Debug Mode\n\nAccess debug features: `Cmd+Shift+D` (macOS) or `Ctrl+Shift+D` (Windows/Linux)\n\n## Platform Notes\n\n- **macOS**: Metal acceleration, accessibility permissions required\n- **Windows**: Vulkan acceleration, code signing\n- **Linux**: OpenBLAS + Vulkan, limited Wayland support, overlay disabled by default\n"
  },
  {
    "path": "CONTRIBUTING.md",
    "content": "# Contributing to Handy\n\nThank you for your interest in contributing to Handy! This guide will help you get started with contributing to this open source speech-to-text application.\n\n## 📖 Philosophy\n\nHandy aims to be the most forkable speech-to-text app. 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. We prioritize:\n\n- **Simplicity**: Clear, maintainable code over clever solutions\n- **Extensibility**: Make it easy for others to fork and customize\n- **Privacy**: Keep everything local and offline\n- **Accessibility**: Free tooling that belongs in everyone's hands\n\n## 🚀 Getting Started\n\n### Prerequisites\n\nBefore you begin, ensure you have the following installed:\n\n- [Rust](https://rustup.rs/) (latest stable)\n- [Bun](https://bun.sh/) package manager\n- Platform-specific build tools (see [BUILD.md](BUILD.md))\n\n### Setting Up Your Development Environment\n\n1. **Fork the repository** on GitHub\n\n2. **Clone your fork**:\n\n   ```bash\n   git clone git@github.com:YOUR_USERNAME/Handy.git\n   cd Handy\n   ```\n\n3. **Add upstream remote**:\n\n   ```bash\n   git remote add upstream git@github.com:cjpais/Handy.git\n   ```\n\n4. **Install dependencies**:\n\n   ```bash\n   bun install\n   ```\n\n5. **Download required models**:\n\n   ```bash\n   mkdir -p src-tauri/resources/models\n   curl -o src-tauri/resources/models/silero_vad_v4.onnx https://blob.handy.computer/silero_vad_v4.onnx\n   ```\n\n6. **Run in development mode**:\n   ```bash\n   bun run tauri dev\n   # On macOS if you encounter cmake errors:\n   CMAKE_POLICY_VERSION_MINIMUM=3.5 bun run tauri dev\n   ```\n\nFor detailed platform-specific setup instructions, see [BUILD.md](BUILD.md).\n\n### Understanding the Codebase\n\nHandy follows a clean architecture pattern:\n\n**Backend (Rust - `src-tauri/src/`):**\n\n- `lib.rs` - Main application entry point with Tauri setup\n- `managers/` - Core business logic (audio, model, transcription)\n- `audio_toolkit/` - Low-level audio processing (recording, VAD)\n- `commands/` - Tauri command handlers for frontend communication\n- `shortcut.rs` - Global keyboard shortcut handling\n- `settings.rs` - Application settings management\n\n**Frontend (React/TypeScript - `src/`):**\n\n- `App.tsx` - Main application component\n- `components/` - React UI components\n- `hooks/` - Reusable React hooks\n- `lib/types.ts` - Shared TypeScript types\n\nFor more details, see the Architecture section in [README.md](README.md) or [AGENTS.md](AGENTS.md).\n\n## 🐛 Reporting Bugs\n\n### Before Submitting a Bug Report\n\n1. **Search existing issues** at [github.com/cjpais/Handy/issues](https://github.com/cjpais/Handy/issues)\n2. **Check discussions** at [github.com/cjpais/Handy/discussions](https://github.com/cjpais/Handy/discussions)\n3. **Try the latest release** to see if the issue has been fixed\n4. **Enable debug mode** (`Cmd/Ctrl+Shift+D`) to gather diagnostic information\n\n### Submitting a Bug Report\n\nWhen creating a bug report, please include:\n\n**System Information:**\n\n- App version (found in settings or about section)\n- Operating System (e.g., macOS 14.1, Windows 11, Ubuntu 22.04)\n- CPU (e.g., Apple M2, Intel i7-12700K, AMD Ryzen 7 5800X)\n- GPU (e.g., Apple M2 GPU, NVIDIA RTX 4080, Intel UHD Graphics)\n\n**Bug Details:**\n\n- Clear description of the bug\n- Steps to reproduce\n- Expected behavior\n- Actual behavior\n- Screenshots or logs if applicable\n- Information from debug mode if relevant\n\nUse the [Bug Report template](.github/ISSUE_TEMPLATE/bug_report.md) when creating an issue.\n\n## 💡 Suggesting Features\n\nWe use GitHub Discussions for feature requests rather than issues. This keeps issues focused on bugs and actionable tasks while allowing more open-ended conversations about features.\n\n### Before Suggesting a Feature\n\n1. **Search existing discussions** at [github.com/cjpais/Handy/discussions](https://github.com/cjpais/Handy/discussions)\n2. **Check common feature requests**:\n   - [Post-processing / Editing Transcripts](https://github.com/cjpais/Handy/discussions/168)\n   - [Keyboard Shortcuts / Hotkeys](https://github.com/cjpais/Handy/discussions/211)\n\n### Submitting a Feature Request\n\n1. Go to [Discussions](https://github.com/cjpais/Handy/discussions)\n2. Click \"New discussion\"\n3. Choose the appropriate category (Ideas, Feature Requests, etc.)\n4. Describe your feature idea including:\n   - The problem you're trying to solve\n   - Your proposed solution\n   - Any alternatives you've considered\n   - How it fits with Handy's philosophy\n\n## 🔧 Making Code Contributions\n\n### Before You Start\n\n**This is critical:** Before writing any code, please do the following:\n\n1. **Search existing issues and PRs** - Check both open AND closed issues and pull requests. Someone may have already addressed this, or there may be a reason it was closed.\n   - [Open issues](https://github.com/cjpais/Handy/issues)\n   - [Closed issues](https://github.com/cjpais/Handy/issues?q=is%3Aissue+is%3Aclosed)\n   - [Open PRs](https://github.com/cjpais/Handy/pulls)\n   - [Closed PRs](https://github.com/cjpais/Handy/pulls?q=is%3Apr+is%3Aclosed)\n\n2. **If something was previously closed** - If you want to revisit a closed issue or PR, you need to:\n   - Provide a strong argument for why it should be reconsidered\n   - Gather community feedback first via [Discussions](https://github.com/cjpais/Handy/discussions)\n   - Link to that discussion in your PR\n\n3. **Get community feedback for features** - PRs with demonstrated community interest are **much more likely to be merged**. Start a discussion, get feedback, and link to it in your PR. This helps ensure Handy stays focused and useful for the most people without becoming bloated.\n\nCommunity feedback is essential to keeping Handy the best it can be for everyone. It helps prioritize what matters most and prevents feature creep.\n\n### Development Workflow\n\n1. **Create a feature branch**:\n\n   ```bash\n   git checkout -b feature/your-feature-name\n   # or\n   git checkout -b fix/your-bug-fix\n   ```\n\n2. **Make your changes**:\n   - Write clean, maintainable code\n   - Follow existing code style and patterns\n   - Add comments for complex logic\n   - Keep commits focused and atomic\n\n3. **Test thoroughly**:\n   - Test on your target platform(s)\n   - Verify existing functionality still works\n   - Test edge cases and error conditions\n   - Use debug mode to verify audio/transcription behavior\n\n4. **Commit your changes**:\n\n   ```bash\n   git add .\n   git commit -m \"feat: add your feature description\"\n   # or\n   git commit -m \"fix: describe the bug fix\"\n   ```\n\n   Use conventional commit messages:\n   - `feat:` for new features\n   - `fix:` for bug fixes\n   - `docs:` for documentation changes\n   - `refactor:` for code refactoring\n   - `test:` for test additions/changes\n   - `chore:` for maintenance tasks\n\n5. **Keep your fork updated**:\n\n   ```bash\n   git fetch upstream\n   git rebase upstream/main\n   ```\n\n6. **Push to your fork**:\n\n   ```bash\n   git push origin feature/your-feature-name\n   ```\n\n7. **Create a Pull Request**:\n   - Go to the [Handy repository](https://github.com/cjpais/Handy)\n   - Click \"New Pull Request\"\n   - Select your fork and branch\n   - Fill out the PR template completely, including:\n     - Clear description of changes\n     - Links to related issues or discussions\n     - **Community feedback** (especially important for features)\n     - How you tested the changes\n     - Screenshots/videos if applicable\n     - Breaking changes (if any)\n\n   **Remember:** PRs with community support are prioritized. If you haven't already, start a [discussion](https://github.com/cjpais/Handy/discussions) to gather feedback before or alongside your PR. It is not explicitly required to gather feedback, but it certainly helps your PR get merged faster.\n\n### AI Assistance Disclosure\n\n**AI-assisted PRs are welcome!** Use whatever tools help you contribute, just be upfront about it.\n\nIn your PR description, please include:\n\n- Whether AI was used (yes/no)\n- Which tools were used (e.g., \"Claude Code\", \"GitHub Copilot\", \"ChatGPT\")\n- How extensively it was used (e.g., \"generated boilerplate\", \"helped debug\", \"wrote most of the code\")\n\n### Code Style Guidelines\n\n**Rust:**\n\n- Follow standard Rust formatting (`cargo fmt`)\n- Run `cargo clippy` and address warnings\n- Use descriptive variable and function names\n- Add doc comments for public APIs\n- Handle errors explicitly (avoid unwrap in production code)\n\n**TypeScript/React:**\n\n- Use TypeScript strictly, avoid `any` types\n- Follow React hooks best practices\n- Use functional components\n- Keep components small and focused\n- Use Tailwind CSS for styling\n\n**General:**\n\n- Write self-documenting code\n- Add comments for non-obvious logic\n- Keep functions small and single-purpose\n- Prioritize readability over cleverness\n\n### Testing Your Changes\n\n**Manual Testing:**\n\n- Run the app in development mode: `bun run tauri dev`\n- Test your changes with debug mode enabled\n- Verify on multiple platforms if possible\n- Test with different audio devices\n- Try various transcription scenarios\n\n**Building for Production:**\n\n```bash\nbun run tauri build\n```\n\nTest the production build to ensure it works as expected.\n\n## 📝 Documentation Contributions\n\nDocumentation improvements are highly valued! You can contribute by:\n\n- Improving README.md, BUILD.md, or this CONTRIBUTING.md\n- Adding code comments and doc comments\n- Creating tutorials or guides\n- Improving error messages\n- Updating the project website content\n\n## 🤝 Community Guidelines\n\n- **Be respectful and inclusive** - We welcome contributors of all skill levels\n- **Be patient** - This is maintained by a small team, responses may take time\n- **Be constructive** - Focus on solutions and improvements\n- **Be collaborative** - Help others and share knowledge\n- **Search first** - Check existing issues/discussions before creating new ones\n\n## 🎯 Good First Issues\n\nLook for issues labeled `good first issue` or `help wanted` if you're new to the project. These are typically:\n\n- Well-defined and scoped\n- Good for learning the codebase\n- Mentor support available\n\n## 📞 Getting Help\n\n- **Discord**: Join our [Discord community](https://discord.com/invite/WVBeWsNXK4)\n- **Discussions**: Ask questions in [GitHub Discussions](https://github.com/cjpais/Handy/discussions)\n- **Email**: Reach out at [contact@handy.computer](mailto:contact@handy.computer)\n\n## 📜 License\n\nBy contributing to Handy, you agree that your contributions will be licensed under the MIT License. See [LICENSE](LICENSE) for details.\n\n---\n\n**Thank you for contributing to Handy!** Your efforts help make speech-to-text technology more accessible, private, and extensible for everyone.\n"
  },
  {
    "path": "CONTRIBUTING_TRANSLATIONS.md",
    "content": "# Contributing Translations to Handy\n\nThank you for helping translate Handy! This guide explains how to add or improve translations.\n\n## Quick Start\n\n1. Fork the repository\n2. Copy the English translation file to your language folder\n3. Translate the values (not the keys!)\n4. Submit a pull request\n\n## File Structure\n\nTranslation files are located in:\n\n```\nsrc/i18n/locales/\n├── en/\n│   └── translation.json    # English (source)\n├── vi/\n│   └── translation.json    # Vietnamese\n├── fr/\n│   └── translation.json    # French\n└── [your-language]/\n    └── translation.json    # Your contribution!\n```\n\n## Adding a New Language\n\n### Step 1: Create the Language Folder\n\nCreate a new folder using the [ISO 639-1 language code](https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes):\n\n```bash\nmkdir src/i18n/locales/[language-code]\n```\n\nExamples:\n\n- `de` for German\n- `es` for Spanish\n- `ja` for Japanese\n- `zh` for Chinese\n- `ko` for Korean\n- `pt` for Portuguese\n\n### Step 2: Copy the English File\n\n```bash\ncp src/i18n/locales/en/translation.json src/i18n/locales/[language-code]/translation.json\n```\n\n### Step 3: Translate the Values\n\nOpen the file and translate only the **values** (right side), not the keys (left side):\n\n```json\n{\n  \"sidebar\": {\n    \"general\": \"General\",      // ← Translate this value\n    \"advanced\": \"Advanced\",    // ← Translate this value\n    ...\n  }\n}\n```\n\n**Important:**\n\n- Keep all keys exactly the same\n- Preserve any `{{variables}}` in the text (e.g., `{{error}}`, `{{model}}`)\n- Keep the JSON structure and formatting intact\n\n### Step 4: Register Your Language\n\nEdit `src/i18n/languages.ts` and add your language metadata:\n\n```typescript\nexport const LANGUAGE_METADATA: Record<\n  string,\n  { name: string; nativeName: string }\n> = {\n  en: { name: \"English\", nativeName: \"English\" },\n  es: { name: \"Spanish\", nativeName: \"Español\" },\n  fr: { name: \"French\", nativeName: \"Français\" },\n  vi: { name: \"Vietnamese\", nativeName: \"Tiếng Việt\" },\n  de: { name: \"German\", nativeName: \"Deutsch\" }, // ← Add your language\n};\n```\n\n### Step 5: Test Your Translation\n\n1. Run the app: `bun run tauri dev`\n2. Go to Settings → General → App Language\n3. Select your language\n4. Verify all text displays correctly\n\n### Step 6: Submit a Pull Request\n\n1. Commit your changes\n2. Push to your fork\n3. Open a pull request with:\n   - Language name in the title (e.g., \"Add German translation\")\n   - Any notes about the translation\n\n## Improving Existing Translations\n\nFound a typo or better translation?\n\n1. Edit the relevant `translation.json` file\n2. Submit a PR with a brief description of the change\n\n## Translation Guidelines\n\n### Do:\n\n- Use natural, native-sounding language\n- Keep translations concise (UI space is limited)\n- Match the tone of the English text (friendly, clear)\n- Preserve technical terms when appropriate (e.g., \"API\", \"GPU\")\n\n### Don't:\n\n- Translate brand names (Handy, Whisper.cpp, OpenAI)\n- Change or remove `{{variables}}`\n- Modify JSON keys\n- Add extra spaces or formatting\n\n### Handling Variables\n\nSome strings contain variables like `{{error}}` or `{{model}}`. Keep these exactly as-is:\n\n```json\n// English\n\"downloadModel\": \"Failed to download model: {{error}}\"\n\n// French (correct)\n\"downloadModel\": \"Échec du téléchargement du modèle : {{error}}\"\n\n// French (incorrect - don't translate the variable!)\n\"downloadModel\": \"Échec du téléchargement du modèle : {{erreur}}\"\n```\n\n### Handling Plurals\n\nSome languages have complex plural rules. For now, use a general form that works for all cases. We may add proper plural support in the future.\n\n## Questions?\n\n- Open an issue on GitHub\n- Join the discussion in existing translation PRs\n\n## Currently Supported Languages\n\n| Language   | Code | Status            |\n| ---------- | ---- | ----------------- |\n| English    | `en` | Complete (source) |\n| Chinese    | `zh` | Complete          |\n| French     | `fr` | Complete          |\n| German     | `de` | Complete          |\n| Japanese   | `ja` | Complete          |\n| Spanish    | `es` | Complete          |\n| Vietnamese | `vi` | Complete          |\n\n## Requested Languages\n\nWe'd love help with:\n\n- Korean (`ko`)\n- Portuguese (`pt`)\n- And more!\n\n---\n\nThank you for making Handy accessible to more people around the world!\n"
  },
  {
    "path": "CRUSH.md",
    "content": "# Development Commands\n\n**Environment Setup:**\n\n```bash\nbun install                    # Install dependencies\nmkdir -p src-tauri/resources/models\ncurl -o src-tauri/resources/models/silero_vad_v4.onnx https://blob.handy.computer/silero_vad_v4.onnx\n```\n\n**Development:**\n\n```bash\nbun run tauri dev              # Full app development\nCMAKE_POLICY_VERSION_MINIMUM=3.5 bun run tauri dev  # macOS with cmake fix\nbun run dev                     # Frontend only (Vite)\nbun run build                   # Build frontend\nbun run tauri build             # Production build\n```\n\n**Type Check & Build:**\n\n```bash\nbunx tsc --noEmit               # Type checking\nbun run build                   # Build and validate\n```\n\n# Code Style Guidelines\n\n**Rust (Backend):**\n\n- Use `anyhow::Error` for error handling with descriptive messages\n- Prefer `Arc<Mutex<T>>` for shared state in managers\n- Log with appropriate levels: `debug!`, `info!`, `eprintln!` for errors\n- Builder pattern for initialization chains\n- Snake_case for functions and variables, PascalCase for types\n- Separate logical sections with comment blocks: `/* ─────────── */`\n\n**TypeScript/React (Frontend):**\n\n- Functional components with TypeScript interfaces\n- Zod schemas for type validation and inference\n- `useCallback` hooks for stable function references\n- Destructure props with defaults: `disabled = false`\n- Prefer interface aliases over type aliases for objects\n- React.FC for explicit component typing\n- PascalCase for components, camelCase for variables/functions\n\n**Imports:**\n\n- Group imports: external libs, internal modules, relative imports\n- Use type imports for TypeScript: `import type { Settings }`\n- Named imports preferred over default exports\n\n**Error Handling:**\n\n- Frontend: Try/catch with user feedback, rollback optimistic updates\n- Backend: `?` operator with anyhow context messages\n- Log errors appropriately for debugging level\n\n**Component Patterns:**\n\n- Container component pattern for layout\n- Composition over inheritance\n- Prop drilling minimized with context where appropriate\n"
  },
  {
    "path": "LICENSE",
    "content": "MIT License\n\nCopyright (c) 2025 CJ Pais\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"
  },
  {
    "path": "README.md",
    "content": "# Handy\n\n[![Discord](https://img.shields.io/badge/Discord-%235865F2.svg?style=for-the-badge&logo=discord&logoColor=white)](https://discord.com/invite/WVBeWsNXK4)\n\n**A free, open source, and extensible speech-to-text application that works completely offline.**\n\nHandy 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.\n\n## Why Handy?\n\nHandy was created to fill the gap for a truly open source, extensible speech-to-text tool. As stated on [handy.computer](https://handy.computer):\n\n- **Free**: Accessibility tooling belongs in everyone's hands, not behind a paywall\n- **Open Source**: Together we can build further. Extend Handy for yourself and contribute to something bigger\n- **Private**: Your voice stays on your computer. Get transcriptions without sending audio to the cloud\n- **Simple**: One tool, one job. Transcribe what you say and put it into a text box\n\nHandy isn't trying to be the best speech-to-text app—it's trying to be the most forkable one.\n\n## How It Works\n\n1. **Press** a configurable keyboard shortcut to start/stop recording (or use push-to-talk mode)\n2. **Speak** your words while the shortcut is active\n3. **Release** and Handy processes your speech using Whisper\n4. **Get** your transcribed text pasted directly into whatever app you're using\n\nThe process is entirely local:\n\n- Silence is filtered using VAD (Voice Activity Detection) with Silero\n- Transcription uses your choice of models:\n  - **Whisper models** (Small/Medium/Turbo/Large) with GPU acceleration when available\n  - **Parakeet V3** - CPU-optimized model with excellent performance and automatic language detection\n- Works on Windows, macOS, and Linux\n\n## Quick Start\n\n### Installation\n\n1. Download the latest release from the [releases page](https://github.com/cjpais/Handy/releases) or the [website](https://handy.computer)\n   - **macOS**: Also available via [Homebrew cask](https://formulae.brew.sh/cask/handy): `brew install --cask handy`\n   - **Windows**: Also available via [winget](https://github.com/microsoft/winget-pkgs): `winget install cjpais.Handy` \\\n     **Note:** The Homebrew cask and winget package are not maintained by the Handy developers.\n2. Install the application\n3. Launch Handy and grant necessary system permissions (microphone, accessibility)\n4. Configure your preferred keyboard shortcuts in Settings\n5. Start transcribing!\n\n### Development Setup\n\nFor detailed build instructions including platform-specific requirements, see [BUILD.md](BUILD.md).\n\n## Integrations\n\n<a href=\"https://www.raycast.com/mattiacolombomc/handy\" title=\"Install Handy Raycast Extension\"><img src=\"https://www.raycast.com/mattiacolombomc/handy/install_button@2x.png?v=1.1\" height=\"64\" style=\"height: 64px;\" alt=\"Install handy Raycast Extension\" /></a>\n\nControl Handy from [Raycast](https://www.raycast.com) — start/stop recording, browse transcript history, manage dictionary, switch models and languages.\n\n[Source](https://github.com/mattiacolombomc/raycast-handy) · by [@mattiacolombomc](https://github.com/mattiacolombomc)\n\n## Architecture\n\nHandy is built as a Tauri application combining:\n\n- **Frontend**: React + TypeScript with Tailwind CSS for the settings UI\n- **Backend**: Rust for system integration, audio processing, and ML inference\n- **Core Libraries**:\n  - `whisper-rs`: Local speech recognition with Whisper models\n  - `transcription-rs`: CPU-optimized speech recognition with Parakeet models\n  - `cpal`: Cross-platform audio I/O\n  - `vad-rs`: Voice Activity Detection\n  - `rdev`: Global keyboard shortcuts and system events\n  - `rubato`: Audio resampling\n\n### Debug Mode\n\nHandy includes an advanced debug mode for development and troubleshooting. Access it by pressing:\n\n- **macOS**: `Cmd+Shift+D`\n- **Windows/Linux**: `Ctrl+Shift+D`\n\n### CLI Parameters\n\nHandy supports command-line flags for controlling a running instance and customizing startup behavior. These work on all platforms (macOS, Windows, Linux).\n\n**Remote control flags** (sent to an already-running instance via the single-instance plugin):\n\n```bash\nhandy --toggle-transcription    # Toggle recording on/off\nhandy --toggle-post-process     # Toggle recording with post-processing on/off\nhandy --cancel                  # Cancel the current operation\n```\n\n**Startup flags:**\n\n```bash\nhandy --start-hidden            # Start without showing the main window\nhandy --no-tray                 # Start without the system tray icon\nhandy --debug                   # Enable debug mode with verbose logging\nhandy --help                    # Show all available flags\n```\n\nFlags can be combined for autostart scenarios:\n\n```bash\nhandy --start-hidden --no-tray\n```\n\n> **macOS tip:** When Handy is installed as an app bundle, invoke the binary directly:\n>\n> ```bash\n> /Applications/Handy.app/Contents/MacOS/Handy --toggle-transcription\n> ```\n\n## Known Issues & Current Limitations\n\nThis project is actively being developed and has some [known issues](https://github.com/cjpais/Handy/issues). We believe in transparency about the current state:\n\n### Major Issues (Help Wanted)\n\n**Whisper Model Crashes:**\n\n- Whisper models crash on certain system configurations (Windows and Linux)\n- Does not affect all systems - issue is configuration-dependent\n  - If you experience crashes and are a developer, please help to fix and provide debug logs!\n\n**Wayland Support (Linux):**\n\n- Limited support for Wayland display server\n- Requires [`wtype`](https://github.com/atx/wtype) or [`dotool`](https://sr.ht/~geb/dotool/) for text input to work correctly (see [Linux Notes](#linux-notes) below for installation)\n\n### Linux Notes\n\n**Text Input Tools:**\n\nFor reliable text input on Linux, install the appropriate tool for your display server:\n\n| Display Server | Recommended Tool | Install Command                                    |\n| -------------- | ---------------- | -------------------------------------------------- |\n| X11            | `xdotool`        | `sudo apt install xdotool`                         |\n| Wayland        | `wtype`          | `sudo apt install wtype`                           |\n| Both           | `dotool`         | `sudo apt install dotool` (requires `input` group) |\n\n- **X11**: Install `xdotool` for both direct typing and clipboard paste shortcuts\n- **Wayland**: Install `wtype` (preferred) or `dotool` for text input to work correctly\n- **dotool setup**: Requires adding your user to the `input` group: `sudo usermod -aG input $USER` (then log out and back in)\n\nWithout these tools, Handy falls back to enigo which may have limited compatibility, especially on Wayland.\n\n**Other Notes:**\n\n- **Runtime library dependency (`libgtk-layer-shell.so.0`)**:\n  - Handy 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:\n\n    | Distro        | Package to install    | Example command                        |\n    | ------------- | --------------------- | -------------------------------------- |\n    | Ubuntu/Debian | `libgtk-layer-shell0` | `sudo apt install libgtk-layer-shell0` |\n    | Fedora/RHEL   | `gtk-layer-shell`     | `sudo dnf install gtk-layer-shell`     |\n    | Arch Linux    | `gtk-layer-shell`     | `sudo pacman -S gtk-layer-shell`       |\n\n  - For building from source on Ubuntu/Debian, you may also need `libgtk-layer-shell-dev`.\n\n- 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 Handy 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.\n- If you are having trouble with the app, running with the environment variable `WEBKIT_DISABLE_DMABUF_RENDERER=1` may help\n- **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.\n\n  **GNOME:**\n  1. Open **Settings > Keyboard > Keyboard Shortcuts > Custom Shortcuts**\n  2. Click the **+** button to add a new shortcut\n  3. Set the **Name** to `Toggle Handy Transcription`\n  4. Set the **Command** to `handy --toggle-transcription`\n  5. Click **Set Shortcut** and press your desired key combination (e.g., `Super+O`)\n\n  **KDE Plasma:**\n  1. Open **System Settings > Shortcuts > Custom Shortcuts**\n  2. Click **Edit > New > Global Shortcut > Command/URL**\n  3. Name it `Toggle Handy Transcription`\n  4. In the **Trigger** tab, set your desired key combination\n  5. In the **Action** tab, set the command to `handy --toggle-transcription`\n\n  **Sway / i3:**\n\n  Add to your config file (`~/.config/sway/config` or `~/.config/i3/config`):\n\n  ```ini\n  bindsym $mod+o exec handy --toggle-transcription\n  ```\n\n  **Hyprland:**\n\n  Add to your config file (`~/.config/hypr/hyprland.conf`):\n\n  ```ini\n  bind = $mainMod, O, exec, handy --toggle-transcription\n  ```\n\n- You can also manage global shortcuts outside of Handy via Unix signals, which lets Wayland window managers or other hotkey daemons keep ownership of keybindings:\n\n  | Signal    | Action                                    | Example                |\n  | --------- | ----------------------------------------- | ---------------------- |\n  | `SIGUSR2` | Toggle transcription                      | `pkill -USR2 -n handy` |\n  | `SIGUSR1` | Toggle transcription with post-processing | `pkill -USR1 -n handy` |\n\n  Example Sway config:\n\n  ```ini\n  bindsym $mod+o exec pkill -USR2 -n handy\n  bindsym $mod+p exec pkill -USR1 -n handy\n  ```\n\n  `pkill` here simply delivers the signal—it does not terminate the process.\n\n### Platform Support\n\n- **macOS (both Intel and Apple Silicon)**\n- **x64 Windows**\n- **x64 Linux**\n\n### System Requirements/Recommendations\n\nThe following are recommendations for running Handy 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.\n\n**For Whisper Models:**\n\n- **macOS**: M series Mac, Intel Mac\n- **Windows**: Intel, AMD, or NVIDIA GPU\n- **Linux**: Intel, AMD, or NVIDIA GPU\n  - Ubuntu 22.04, 24.04\n\n**For Parakeet V3 Model:**\n\n- **CPU-only operation** - runs on a wide variety of hardware\n- **Minimum**: Intel Skylake (6th gen) or equivalent AMD processors\n- **Performance**: ~5x real-time speed on mid-range hardware (tested on i5)\n- **Automatic language detection** - no manual language selection required\n\n## Roadmap & Active Development\n\nWe're actively working on several features and improvements. Contributions and feedback are welcome!\n\n### In Progress\n\n**Debug Logging:**\n\n- Adding debug logging to a file to help diagnose issues\n\n**macOS Keyboard Improvements:**\n\n- Support for Globe key as transcription trigger\n- A rewrite of global shortcut handling for MacOS, and potentially other OS's too.\n\n**Opt-in Analytics:**\n\n- Collect anonymous usage data to help improve Handy\n- Privacy-first approach with clear opt-in\n\n**Settings Refactoring:**\n\n- Cleanup and refactor settings system which is becoming bloated and messy\n- Implement better abstractions for settings management\n\n**Tauri Commands Cleanup:**\n\n- Abstract and organize Tauri command patterns\n- Investigate tauri-specta for improved type safety and organization\n\n## Troubleshooting\n\n### Manual Model Installation (For Proxy Users or Network Restrictions)\n\nIf you're behind a proxy, firewall, or in a restricted network environment where Handy cannot download models automatically, you can manually download and install them. The URLs are publicly accessible from any browser.\n\n#### Step 1: Find Your App Data Directory\n\n1. Open Handy settings\n2. Navigate to the **About** section\n3. Copy the \"App Data Directory\" path shown there, or use the shortcuts:\n   - **macOS**: `Cmd+Shift+D` to open debug menu\n   - **Windows/Linux**: `Ctrl+Shift+D` to open debug menu\n\nThe typical paths are:\n\n- **macOS**: `~/Library/Application Support/com.pais.handy/`\n- **Windows**: `C:\\Users\\{username}\\AppData\\Roaming\\com.pais.handy\\`\n- **Linux**: `~/.config/com.pais.handy/`\n\n#### Step 2: Create Models Directory\n\nInside your app data directory, create a `models` folder if it doesn't already exist:\n\n```bash\n# macOS/Linux\nmkdir -p ~/Library/Application\\ Support/com.pais.handy/models\n\n# Windows (PowerShell)\nNew-Item -ItemType Directory -Force -Path \"$env:APPDATA\\com.pais.handy\\models\"\n```\n\n#### Step 3: Download Model Files\n\nDownload the models you want from below\n\n**Whisper Models (single .bin files):**\n\n- Small (487 MB): `https://blob.handy.computer/ggml-small.bin`\n- Medium (492 MB): `https://blob.handy.computer/whisper-medium-q4_1.bin`\n- Turbo (1600 MB): `https://blob.handy.computer/ggml-large-v3-turbo.bin`\n- Large (1100 MB): `https://blob.handy.computer/ggml-large-v3-q5_0.bin`\n\n**Parakeet Models (compressed archives):**\n\n- V2 (473 MB): `https://blob.handy.computer/parakeet-v2-int8.tar.gz`\n- V3 (478 MB): `https://blob.handy.computer/parakeet-v3-int8.tar.gz`\n\n#### Step 4: Install Models\n\n**For Whisper Models (.bin files):**\n\nSimply place the `.bin` file directly into the `models` directory:\n\n```\n{app_data_dir}/models/\n├── ggml-small.bin\n├── whisper-medium-q4_1.bin\n├── ggml-large-v3-turbo.bin\n└── ggml-large-v3-q5_0.bin\n```\n\n**For Parakeet Models (.tar.gz archives):**\n\n1. Extract the `.tar.gz` file\n2. Place the **extracted directory** into the `models` folder\n3. The directory must be named exactly as follows:\n   - **Parakeet V2**: `parakeet-tdt-0.6b-v2-int8`\n   - **Parakeet V3**: `parakeet-tdt-0.6b-v3-int8`\n\nFinal structure should look like:\n\n```\n{app_data_dir}/models/\n├── parakeet-tdt-0.6b-v2-int8/     (directory with model files inside)\n│   ├── (model files)\n│   └── (config files)\n└── parakeet-tdt-0.6b-v3-int8/     (directory with model files inside)\n    ├── (model files)\n    └── (config files)\n```\n\n**Important Notes:**\n\n- For Parakeet models, the extracted directory name **must** match exactly as shown above\n- Do not rename the `.bin` files for Whisper models—use the exact filenames from the download URLs\n- After placing the files, restart Handy to detect the new models\n\n#### Step 5: Verify Installation\n\n1. Restart Handy\n2. Open Settings → Models\n3. Your manually installed models should now appear as \"Downloaded\"\n4. Select the model you want to use and test transcription\n\n### Custom Whisper Models\n\nHandy 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.\n\n**How to use:**\n\n1. Obtain a Whisper model in GGML `.bin` format (e.g., from [Hugging Face](https://huggingface.co/models?search=whisper%20ggml))\n2. Place the `.bin` file in your `models` directory (see paths above)\n3. Restart Handy to discover the new model\n4. The model will appear in the \"Custom Models\" section of the Models settings page\n\n**Important:**\n\n- Community models are user-provided and may not receive troubleshooting assistance\n- The model must be a valid Whisper GGML format (`.bin` file)\n- Model name is derived from the filename (e.g., `my-custom-model.bin` → \"My Custom Model\")\n\n### How to Contribute\n\n1. **Check existing issues** at [github.com/cjpais/Handy/issues](https://github.com/cjpais/Handy/issues)\n2. **Fork the repository** and create a feature branch\n3. **Test thoroughly** on your target platform\n4. **Submit a pull request** with clear description of changes\n5. **Join the discussion** - reach out at [contact@handy.computer](mailto:contact@handy.computer)\n\nThe 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.\n\n## Sponsors\n\n<div align=\"center\">\n  We're grateful for the support of our sponsors who help make Handy possible:\n  <br><br>\n  <a href=\"https://wordcab.com\">\n    <img src=\"sponsor-images/wordcab.png\" alt=\"Wordcab\" width=\"120\" height=\"120\">\n  </a>\n  &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;\n  <a href=\"https://github.com/epicenter-so/epicenter\">\n    <img src=\"sponsor-images/epicenter.png\" alt=\"Epicenter\" width=\"120\" height=\"120\">\n  </a>\n  &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;\n  <a href=\"https://boltai.com?utm_source=handy\">\n    <img src=\"sponsor-images/boltai.jpg\" alt=\"Bolt AI\" width=\"120\" height=\"120\">\n  </a>\n</div>\n\n## Related Projects\n\n- **[Handy CLI](https://github.com/cjpais/handy-cli)** - The original Python command-line version\n- **[handy.computer](https://handy.computer)** - Project website with demos and documentation\n\n## License\n\nMIT License - see [LICENSE](LICENSE) file for details.\n\n## Acknowledgments\n\n- **Whisper** by OpenAI for the speech recognition model\n- **whisper.cpp and ggml** for amazing cross-platform whisper inference/acceleration\n- **Silero** for great lightweight VAD\n- **Tauri** team for the excellent Rust-based app framework\n- **Community contributors** helping make Handy better\n"
  },
  {
    "path": "eslint.config.js",
    "content": "import i18next from \"eslint-plugin-i18next\";\nimport tsParser from \"@typescript-eslint/parser\";\n\nexport default [\n  {\n    files: [\"src/**/*.{ts,tsx}\"],\n    languageOptions: {\n      parser: tsParser,\n      parserOptions: {\n        ecmaFeatures: {\n          jsx: true,\n        },\n      },\n    },\n    plugins: {\n      i18next,\n    },\n    rules: {\n      // Catch text in JSX that should be translated\n      \"i18next/no-literal-string\": [\n        \"error\",\n        {\n          markupOnly: true, // Only check JSX content, not all strings\n          ignoreAttribute: [\n            \"className\",\n            \"style\",\n            \"type\",\n            \"id\",\n            \"name\",\n            \"key\",\n            \"data-*\",\n            \"aria-*\",\n          ], // Ignore common non-translatable attributes\n        },\n      ],\n    },\n  },\n];\n"
  },
  {
    "path": "flake.nix",
    "content": "{\n  description = \"Handy - A free, open source, and extensible speech-to-text application that works completely offline\";\n\n  inputs = {\n    nixpkgs.url = \"github:NixOS/nixpkgs/nixos-unstable\";\n\n    # bun2nix: generates per-package Nix fetchurl expressions from bun.lock,\n    # replacing the old FOD approach where a single hash covered the entire\n    # node_modules directory (that hash would break on bun version changes).\n    # See: https://github.com/nix-community/bun2nix\n    bun2nix = {\n      url = \"github:nix-community/bun2nix/2.0.8\";\n      inputs.nixpkgs.follows = \"nixpkgs\";\n    };\n  };\n\n  outputs =\n    {\n      self,\n      nixpkgs,\n      bun2nix,\n    }:\n    let\n      supportedSystems = [\n        \"x86_64-linux\"\n        \"aarch64-linux\"\n      ];\n      forAllSystems = nixpkgs.lib.genAttrs supportedSystems;\n      # Read version from Cargo.toml\n      cargoToml = fromTOML (builtins.readFile ./src-tauri/Cargo.toml);\n      version = cargoToml.package.version;\n\n      # Shared native library dependencies for both package build and dev shell.\n      # Keep in sync: if a native dep is needed for compilation, add it here.\n      commonNativeDeps = pkgs: with pkgs; [\n        webkitgtk_4_1\n        gtk3\n        glib\n        libsoup_3\n        alsa-lib\n        onnxruntime\n        libayatana-appindicator\n        libevdev\n        libxtst\n        gtk-layer-shell\n        openssl\n        vulkan-loader\n        vulkan-headers\n        shaderc\n      ];\n\n      # GStreamer plugins for WebKitGTK audio/video\n      gstPlugins = pkgs: with pkgs.gst_all_1; [\n        gstreamer\n        gst-plugins-base\n        gst-plugins-good\n        gst-plugins-bad\n        gst-plugins-ugly\n      ];\n\n      # Shared environment variables for Rust/native builds\n      commonEnv = pkgs: let lib = pkgs.lib; in {\n        LIBCLANG_PATH = \"${pkgs.llvmPackages.libclang.lib}/lib\";\n        BINDGEN_EXTRA_CLANG_ARGS = \"-isystem ${pkgs.llvmPackages.libclang.lib}/lib/clang/${lib.getVersion pkgs.llvmPackages.libclang}/include -isystem ${pkgs.glibc.dev}/include\";\n        ORT_LIB_LOCATION = \"${pkgs.onnxruntime}/lib\";\n        ORT_PREFER_DYNAMIC_LINK = \"1\";\n        GST_PLUGIN_SYSTEM_PATH_1_0 = \"${lib.makeSearchPathOutput \"lib\" \"lib/gstreamer-1.0\" (gstPlugins pkgs)}\";\n      };\n\n      # TODO: Remove this overlay once nixpkgs ships onnxruntime ≥ 1.24.\n      # Tracking PR: https://github.com/NixOS/nixpkgs/pull/499389\n      # ort-sys 2.0.0-rc.12 requires ONNX Runtime 1.24 (API v24);\n      # nixpkgs only ships 1.23.2, so use MS prebuilt binaries.\n      onnxruntimeOverlay = (final: prev: {\n        onnxruntime = let\n          onnxVersion = \"1.24.2\";\n          platform = {\n            x86_64-linux = { name = \"linux-x64\"; hash = \"sha256-Q3JUdLpWY2QuF2hHF5Rmk4UOIAXvvXJKxy2ieP6tJeY=\"; };\n            aarch64-linux = { name = \"linux-aarch64\"; hash = \"sha256-spla8PQ3xOAi/YAcV/tcJf0f5mDNM9JutHGUSQpbRsQ=\"; };\n          }.${final.system};\n        in prev.stdenv.mkDerivation {\n          pname = \"onnxruntime\";\n          version = onnxVersion;\n          src = prev.fetchurl {\n            url = \"https://github.com/microsoft/onnxruntime/releases/download/v${onnxVersion}/onnxruntime-${platform.name}-${onnxVersion}.tgz\";\n            hash = platform.hash;\n          };\n          sourceRoot = \"onnxruntime-${platform.name}-${onnxVersion}\";\n          nativeBuildInputs = [ prev.autoPatchelfHook ];\n          buildInputs = [ prev.stdenv.cc.cc.lib ];\n          installPhase = ''\n            runHook preInstall\n            mkdir -p $out/lib $out/include\n            cp -r lib/* $out/lib/\n            cp -r include/* $out/include/\n            runHook postInstall\n          '';\n          meta = prev.onnxruntime.meta // {\n            description = \"ONNX Runtime ${onnxVersion} (prebuilt by Microsoft)\";\n          };\n        };\n      });\n    in\n    {\n      packages = forAllSystems (\n        system:\n        let\n          pkgs = import nixpkgs {\n            inherit system;\n            overlays = [\n              bun2nix.overlays.default\n              onnxruntimeOverlay\n            ];\n          };\n          lib = pkgs.lib;\n        in\n        {\n          handy = pkgs.rustPlatform.buildRustPackage {\n            pname = \"handy\";\n            inherit version;\n            src = self;\n\n            cargoRoot = \"src-tauri\";\n\n            cargoLock = {\n              lockFile = ./src-tauri/Cargo.lock;\n              # Automatically fetch git dependencies using builtins.fetchGit.\n              # This eliminates the need for manual outputHashes that had to be\n              # updated every time a git dependency changed in Cargo.lock.\n              # Safe for standalone flakes (not allowed in nixpkgs, it is needed something like crate2nix).\n              allowBuiltinFetchGit = true;\n            };\n\n            postPatch = ''\n              ${pkgs.jq}/bin/jq 'del(.build.beforeBuildCommand) | .bundle.createUpdaterArtifacts = false' \\\n                src-tauri/tauri.conf.json > $TMPDIR/tauri.conf.json\n              cp $TMPDIR/tauri.conf.json src-tauri/tauri.conf.json\n\n              # Strip postinstall hook — it runs check-nix-deps.ts which is only\n              # needed during local development, not inside the Nix sandbox.\n              ${pkgs.jq}/bin/jq 'del(.scripts.postinstall)' \\\n                package.json > $TMPDIR/package.json\n              cp $TMPDIR/package.json package.json\n\n              # Point libappindicator-sys to the Nix store path\n              substituteInPlace \\\n                $cargoDepsCopy/libappindicator-sys-*/src/lib.rs \\\n                --replace-fail \\\n                  \"libayatana-appindicator3.so.1\" \\\n                  \"${pkgs.libayatana-appindicator}/lib/libayatana-appindicator3.so.1\"\n\n              # Disable cbindgen in ferrous-opencc (calls cargo metadata which fails in sandbox)\n              # Upstream removed this call in v0.3.1+\n              substituteInPlace $cargoDepsCopy/ferrous-opencc-0.2.3/build.rs \\\n                --replace-fail '.expect(\"Unable to generate bindings\")' '.ok();'\n              substituteInPlace $cargoDepsCopy/ferrous-opencc-0.2.3/build.rs \\\n                --replace-fail '.write_to_file(\"opencc.h\");' '// skipped'\n            '';\n\n            # Bun dependencies: fetched per-package using hashes from .nix/bun.nix.\n            # This file is auto-generated by `bunx bun2nix -o .nix/bun.nix` and\n            # kept in sync via the postinstall hook in package.json.\n            # To regenerate manually: bun scripts/check-nix-deps.ts\n            bunDeps = pkgs.bun2nix.fetchBunDeps {\n              bunNix = ./.nix/bun.nix;\n            };\n\n            nativeBuildInputs = with pkgs; [\n              cargo-tauri.hook\n              pkg-config\n              wrapGAppsHook4\n              bun\n              # pkgs.bun2nix (from overlay), not the flake input — `with pkgs;`\n              # doesn't shadow function arguments in Nix.\n              pkgs.bun2nix.hook # Sets up node_modules from pre-fetched bun cache\n              jq\n              cmake\n              llvmPackages.libclang\n              shaderc\n            ];\n\n            preBuild = ''\n              # bun2nix.hook has already set up node_modules from pre-fetched cache.\n              # Build the frontend with bun (tsc + vite).\n              export HOME=$TMPDIR\n              bun run build\n            '';\n\n            # Tests require runtime resources (audio devices, model files, GPU/Vulkan)\n            # not available in the Nix build sandbox\n            doCheck = false;\n\n            # The tauri hook's installPhase expects target/ in cwd, but our\n            # cargoRoot puts it under src-tauri/. Override to extract the DEB.\n            installPhase = ''\n              runHook preInstall\n              mkdir -p $out\n              cd src-tauri\n              mv target/${pkgs.stdenv.hostPlatform.rust.rustcTarget}/release/bundle/deb/*/data/usr/* $out/\n              runHook postInstall\n            '';\n\n            buildInputs = commonNativeDeps pkgs ++ (with pkgs; [\n              glib-networking\n              libx11\n            ]) ++ gstPlugins pkgs;\n\n            env = commonEnv pkgs // {\n              OPENSSL_NO_VENDOR = \"1\";\n            };\n\n            preFixup = ''\n              gappsWrapperArgs+=(\n                --set WEBKIT_DISABLE_DMABUF_RENDERER 1\n                --set ALSA_PLUGIN_DIR \"${pkgs.pipewire}/lib/alsa-lib:${pkgs.alsa-plugins}/lib/alsa-lib\"\n                --prefix LD_LIBRARY_PATH : \"${\n                  lib.makeLibraryPath [\n                    pkgs.vulkan-loader\n                    pkgs.onnxruntime\n                  ]\n                }\"\n              )\n            '';\n\n            meta = {\n              description = \"A free, open source, and extensible speech-to-text application that works completely offline\";\n              homepage = \"https://github.com/cjpais/Handy\";\n              license = lib.licenses.mit;\n              mainProgram = \"handy\";\n              platforms = supportedSystems;\n            };\n          };\n\n          default = self.packages.${system}.handy;\n        }\n      );\n\n      # NixOS module for system-level integration (udev, input group)\n      nixosModules.default =\n        { lib, pkgs, ... }:\n        {\n          imports = [ ./nix/module.nix ];\n          programs.handy.package = lib.mkDefault self.packages.${pkgs.stdenv.hostPlatform.system}.handy;\n        };\n\n      # Home-manager module for per-user service\n      homeManagerModules.default =\n        { lib, pkgs, ... }:\n        {\n          imports = [ ./nix/hm-module.nix ];\n          services.handy.package = lib.mkDefault self.packages.${pkgs.stdenv.hostPlatform.system}.handy;\n        };\n\n      # Development shell for building from source\n      devShells = forAllSystems (\n        system:\n        let\n          pkgs = import nixpkgs {\n            inherit system;\n            overlays = [ onnxruntimeOverlay ];\n          };\n        in\n        {\n          default = pkgs.mkShell {\n            buildInputs = commonNativeDeps pkgs ++ (with pkgs; [\n              # Rust toolchain\n              rustc\n              cargo\n              rust-analyzer\n              clippy\n              # Frontend\n              nodejs\n              bun\n              # Build tools\n              cargo-tauri\n              pkg-config\n              llvmPackages.libclang\n              cmake\n            ]);\n\n            inherit (commonEnv pkgs)\n              LIBCLANG_PATH\n              BINDGEN_EXTRA_CLANG_ARGS\n              ORT_LIB_LOCATION\n              ORT_PREFER_DYNAMIC_LINK\n              GST_PLUGIN_SYSTEM_PATH_1_0;\n\n            LD_LIBRARY_PATH = \"${pkgs.lib.makeLibraryPath [ pkgs.libayatana-appindicator pkgs.onnxruntime pkgs.vulkan-loader ]}\";\n\n            # Same as wrapGAppsHook4\n            XDG_DATA_DIRS = \"${pkgs.gsettings-desktop-schemas}/share/gsettings-schemas/${pkgs.gsettings-desktop-schemas.name}:${pkgs.gtk3}/share/gsettings-schemas/${pkgs.gtk3.name}:${pkgs.hicolor-icon-theme}/share\";\n\n            shellHook = ''\n              echo \"Handy development environment\"\n              bun install\n              echo \"Run 'bun run tauri dev' to start\"\n            '';\n          };\n        }\n      );\n    };\n}\n"
  },
  {
    "path": "index.html",
    "content": "<!doctype html>\n<html lang=\"en\" dir=\"ltr\">\n  <head>\n    <meta charset=\"UTF-8\" />\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n    <title>handy</title>\n  </head>\n\n  <body>\n    <div id=\"root\"></div>\n    <script type=\"module\" src=\"/src/main.tsx\"></script>\n  </body>\n</html>\n"
  },
  {
    "path": "nix/hm-module.nix",
    "content": "# Home-manager module for Handy speech-to-text\n#\n# Provides a systemd user service for autostart.\n# Usage: imports = [ handy.homeManagerModules.default ];\n#        services.handy.enable = true;\n{\n  config,\n  lib,\n  pkgs,\n  ...\n}:\nlet\n  cfg = config.services.handy;\nin\n{\n  options.services.handy = {\n    enable = lib.mkEnableOption \"Handy speech-to-text user service\";\n\n    package = lib.mkOption {\n      type = lib.types.package;\n      defaultText = lib.literalExpression \"handy.packages.\\${system}.handy\";\n      description = \"The Handy package to use.\";\n    };\n  };\n\n  config = lib.mkIf cfg.enable {\n    systemd.user.services.handy = {\n      Unit = {\n        Description = \"Handy speech-to-text\";\n        After = [ \"graphical-session.target\" ];\n        PartOf = [ \"graphical-session.target\" ];\n      };\n      Service = {\n        ExecStart = \"${cfg.package}/bin/handy\";\n        Restart = \"on-failure\";\n        RestartSec = 5;\n      };\n      Install.WantedBy = [ \"graphical-session.target\" ];\n    };\n  };\n}\n"
  },
  {
    "path": "nix/module.nix",
    "content": "# NixOS module for Handy speech-to-text\n#\n# Handles system-level configuration that the package wrapper cannot:\n#   - udev rule for /dev/uinput (rdev grab() needs it for virtual input)\n#\n# Note: users must add themselves to the \"input\" group for evdev hotkey access.\n#\n# Usage in your flake:\n#\n#   inputs.handy.url = \"github:cjpais/Handy\";\n#\n#   nixosConfigurations.myhost = nixpkgs.lib.nixosSystem {\n#     modules = [\n#       handy.nixosModules.default\n#       { programs.handy.enable = true; }\n#     ];\n#   };\n{\n  config,\n  lib,\n  pkgs,\n  ...\n}:\nlet\n  cfg = config.programs.handy;\nin\n{\n  options.programs.handy = {\n    enable = lib.mkEnableOption \"Handy offline speech-to-text\";\n\n    package = lib.mkOption {\n      type = lib.types.package;\n      defaultText = lib.literalExpression \"handy.packages.\\${system}.handy\";\n      description = \"The Handy package to use.\";\n    };\n  };\n\n  config = lib.mkIf cfg.enable {\n    environment.systemPackages = [ cfg.package ];\n\n    # rdev grab() creates virtual input devices via /dev/uinput.\n    # Default permissions are crw------- root root — open it to the input group.\n    services.udev.extraRules = ''\n      KERNEL==\"uinput\", GROUP=\"input\", MODE=\"0660\"\n    '';\n  };\n}\n"
  },
  {
    "path": "package.json",
    "content": "{\n  \"name\": \"handy-app\",\n  \"private\": true,\n  \"version\": \"0.7.12\",\n  \"type\": \"module\",\n  \"scripts\": {\n    \"dev\": \"vite\",\n    \"build\": \"tsc && vite build\",\n    \"preview\": \"vite preview\",\n    \"tauri\": \"tauri\",\n    \"lint\": \"eslint src\",\n    \"lint:fix\": \"eslint src --fix\",\n    \"format\": \"prettier --write . && cd src-tauri && cargo fmt\",\n    \"format:check\": \"prettier --check . && cd src-tauri && cargo fmt -- --check\",\n    \"format:frontend\": \"prettier --write .\",\n    \"format:backend\": \"cd src-tauri && cargo fmt\",\n    \"test:playwright\": \"playwright test\",\n    \"test:playwright:ui\": \"playwright test --ui\",\n    \"check:translations\": \"bun scripts/check-translations.ts\",\n    \"postinstall\": \"bun scripts/check-nix-deps.ts\"\n  },\n  \"dependencies\": {\n    \"@tailwindcss/vite\": \"^4.1.16\",\n    \"@tauri-apps/api\": \"^2.10.0\",\n    \"@tauri-apps/plugin-autostart\": \"~2.5.1\",\n    \"@tauri-apps/plugin-clipboard-manager\": \"~2.3.2\",\n    \"@tauri-apps/plugin-dialog\": \"~2.6\",\n    \"@tauri-apps/plugin-fs\": \"~2.4.4\",\n    \"@tauri-apps/plugin-global-shortcut\": \"~2.3.1\",\n    \"@tauri-apps/plugin-opener\": \"^2.5.2\",\n    \"@tauri-apps/plugin-os\": \"~2.3.2\",\n    \"@tauri-apps/plugin-process\": \"~2.3.1\",\n    \"@tauri-apps/plugin-sql\": \"~2.3.1\",\n    \"@tauri-apps/plugin-store\": \"~2.4.1\",\n    \"@tauri-apps/plugin-updater\": \"~2.10.0\",\n    \"i18next\": \"^25.7.2\",\n    \"immer\": \"^11.1.3\",\n    \"lucide-react\": \"^0.542.0\",\n    \"react\": \"^18.3.1\",\n    \"react-dom\": \"^18.3.1\",\n    \"react-i18next\": \"^16.4.1\",\n    \"react-select\": \"^5.8.0\",\n    \"sonner\": \"^2.0.7\",\n    \"tailwindcss\": \"^4.1.16\",\n    \"tauri-plugin-macos-permissions-api\": \"2.3.0\",\n    \"zod\": \"^3.25.76\",\n    \"zustand\": \"^5.0.8\"\n  },\n  \"devDependencies\": {\n    \"@playwright/test\": \"^1.58.0\",\n    \"@tauri-apps/cli\": \"^2.10.0\",\n    \"@types/node\": \"^24.9.1\",\n    \"@types/react\": \"^18.3.26\",\n    \"@types/react-dom\": \"^18.3.7\",\n    \"@types/react-select\": \"^5.0.1\",\n    \"@typescript-eslint/eslint-plugin\": \"^8.49.0\",\n    \"@typescript-eslint/parser\": \"^8.49.0\",\n    \"@vitejs/plugin-react\": \"^4.7.0\",\n    \"eslint\": \"^9.39.1\",\n    \"eslint-plugin-i18next\": \"^6.1.3\",\n    \"prettier\": \"^3.6.2\",\n    \"typescript\": \"~5.6.3\",\n    \"vite\": \"^6.4.1\"\n  }\n}\n"
  },
  {
    "path": "playwright.config.ts",
    "content": "import { defineConfig, devices } from \"@playwright/test\";\n\nexport default defineConfig({\n  testDir: \"./tests\",\n  fullyParallel: true,\n  forbidOnly: !!process.env.CI,\n  retries: process.env.CI ? 2 : 0,\n  workers: process.env.CI ? 1 : undefined,\n  reporter: \"html\",\n  use: {\n    baseURL: \"http://localhost:1420\",\n    trace: \"on-first-retry\",\n  },\n  projects: [\n    {\n      name: \"chromium\",\n      use: { ...devices[\"Desktop Chrome\"] },\n    },\n  ],\n  webServer: {\n    command: \"bunx vite dev\",\n    url: \"http://localhost:1420\",\n    reuseExistingServer: !process.env.CI,\n    timeout: 30000,\n  },\n});\n"
  },
  {
    "path": "scripts/check-nix-deps.ts",
    "content": "// scripts/check-nix-deps.ts — Keep .nix/bun.nix in sync with bun.lock\n//\n// Handy uses bun2nix to generate per-package Nix fetchurl expressions from\n// bun.lock. This replaces the old FOD (Fixed-Output Derivation) approach\n// where a single hash covered the entire node_modules — that hash would\n// break whenever the bun version in nixpkgs changed, even without any\n// dependency updates.\n//\n// How it works:\n//   1. Computes sha256 of bun.lock\n//   2. Compares with stored hash in .nix/bun-lock-hash\n//   3. If they match — nothing to do (~2ms)\n//   4. If they differ — runs `bunx bun2nix` to regenerate .nix/bun.nix\n//\n// When it runs:\n//   - Automatically via \"postinstall\" in package.json — triggers after every\n//     bun install / bun add / bun remove / bun update\n//   - Can also be run manually: bun scripts/check-nix-deps.ts\n//\n// What to commit:\n//   If the script regenerated .nix/bun.nix, commit it together with bun.lock:\n//     git add bun.lock .nix/bun.nix .nix/bun-lock-hash\n\nimport { existsSync, mkdirSync, readFileSync, writeFileSync } from \"fs\";\nimport { join, resolve } from \"path\";\n\nconst root = resolve(import.meta.dirname, \"..\");\nconst nixDir = join(root, \".nix\");\nconst lockFile = join(root, \"bun.lock\");\nconst hashFile = join(nixDir, \"bun-lock-hash\");\nconst nixFile = join(nixDir, \"bun.nix\");\n\n// Skip on Windows — bun2nix is Nix-only and hangs on Windows CI\nif (process.platform === \"win32\") process.exit(0);\n\n// No bun.lock — nothing to do\nif (!existsSync(lockFile)) process.exit(0);\n\n// Ensure .nix directory exists\nmkdirSync(nixDir, { recursive: true });\n\n// Compute sha256 of the current bun.lock\nconst currentHash = new Bun.CryptoHasher(\"sha256\")\n  .update(readFileSync(lockFile))\n  .digest(\"hex\");\n\n// Read the previously stored hash (empty if first run)\nconst storedHash = existsSync(hashFile)\n  ? readFileSync(hashFile, \"utf-8\").trim()\n  : \"\";\n\n// If hashes match, bun.nix is up to date — nothing to do\nif (currentHash === storedHash) process.exit(0);\n\n// bun.lock has changed — regenerate the Nix dependency file\nconsole.log(\n  `[check-nix-deps] bun.lock has changed, regenerating ${nixFile}...`,\n);\n\nconst result = Bun.spawnSync([\"bunx\", \"bun2nix\", \"-o\", nixFile], {\n  cwd: root,\n  stdio: [\"inherit\", \"inherit\", \"inherit\"],\n});\n\nif (result.exitCode !== 0) {\n  console.warn(\n    \"[check-nix-deps] Warning: bunx bun2nix failed. .nix/bun.nix may be outdated.\",\n  );\n  console.warn(\n    \"[check-nix-deps] Nix users: run `bunx bun2nix -o .nix/bun.nix` manually.\",\n  );\n  console.warn(\n    \"[check-nix-deps] Non-Nix users: this is safe to ignore, CI will catch it.\",\n  );\n  // Exit 0 so that `bun install` is not blocked for non-Nix developers.\n  // CI validates bun.nix independently.\n  process.exit(0);\n}\n\nwriteFileSync(hashFile, currentHash + \"\\n\");\nconsole.log(`[check-nix-deps] Updated ${nixFile}`);\nconsole.log(\n  \"[check-nix-deps] Don't forget to commit: .nix/bun.nix .nix/bun-lock-hash\",\n);\n"
  },
  {
    "path": "scripts/check-translations.ts",
    "content": "import fs from \"fs\";\nimport path from \"path\";\nimport { fileURLToPath } from \"url\";\n\nconst __dirname = path.dirname(fileURLToPath(import.meta.url));\n\n// Configuration\nconst LOCALES_DIR = path.join(__dirname, \"..\", \"src\", \"i18n\", \"locales\");\nconst REFERENCE_LANG = \"en\";\n\ntype TranslationData = Record<string, unknown>;\n\ninterface ValidationResult {\n  valid: boolean;\n  missing: string[][];\n  extra: string[][];\n}\n\nfunction getLanguages(): string[] {\n  const entries = fs.readdirSync(LOCALES_DIR, { withFileTypes: true });\n  return entries\n    .filter((entry) => entry.isDirectory() && entry.name !== REFERENCE_LANG)\n    .map((entry) => entry.name)\n    .sort();\n}\n\nconst LANGUAGES = getLanguages();\n\n// Colors for terminal output\nconst colors: Record<string, string> = {\n  reset: \"\\x1b[0m\",\n  red: \"\\x1b[31m\",\n  green: \"\\x1b[32m\",\n  yellow: \"\\x1b[33m\",\n  blue: \"\\x1b[34m\",\n};\n\nfunction colorize(text: string, color: string): string {\n  return `${colors[color]}${text}${colors.reset}`;\n}\n\nfunction getAllKeyPaths(\n  obj: TranslationData,\n  prefix: string[] = [],\n): string[][] {\n  let paths: string[][] = [];\n  for (const key in obj) {\n    if (!Object.hasOwn(obj, key)) continue;\n\n    const currentPath = prefix.concat([key]);\n    const value = obj[key];\n\n    if (typeof value === \"object\" && value !== null && !Array.isArray(value)) {\n      paths = paths.concat(\n        getAllKeyPaths(value as TranslationData, currentPath),\n      );\n    } else {\n      paths.push(currentPath);\n    }\n  }\n  return paths;\n}\n\nfunction hasKeyPath(obj: TranslationData, keyPath: string[]): boolean {\n  let current: unknown = obj;\n  for (const key of keyPath) {\n    if (\n      typeof current !== \"object\" ||\n      current === null ||\n      (current as Record<string, unknown>)[key] === undefined\n    ) {\n      return false;\n    }\n    current = (current as Record<string, unknown>)[key];\n  }\n  return true;\n}\n\nfunction loadTranslationFile(lang: string): TranslationData | null {\n  const filePath = path.join(LOCALES_DIR, lang, \"translation.json\");\n\n  try {\n    const content = fs.readFileSync(filePath, \"utf8\");\n    return JSON.parse(content) as TranslationData;\n  } catch (error) {\n    console.error(colorize(`✗ Error loading ${lang}/translation.json:`, \"red\"));\n    console.error(`  ${(error as Error).message}`);\n    return null;\n  }\n}\n\nfunction validateTranslations(): void {\n  console.log(colorize(\"\\n🌍 Translation Consistency Check\\n\", \"blue\"));\n\n  // Load reference file\n  console.log(`Loading reference language: ${REFERENCE_LANG}`);\n  const referenceData = loadTranslationFile(REFERENCE_LANG);\n\n  if (!referenceData) {\n    console.error(\n      colorize(`\\n✗ Failed to load reference file (${REFERENCE_LANG})`, \"red\"),\n    );\n    process.exit(1);\n  }\n\n  // Get all key paths from reference\n  const referenceKeyPaths = getAllKeyPaths(referenceData);\n  console.log(`Reference has ${referenceKeyPaths.length} keys\\n`);\n\n  // Track validation results\n  let hasErrors = false;\n  const results: Record<string, ValidationResult> = {};\n\n  // Validate each language\n  for (const lang of LANGUAGES) {\n    const langData = loadTranslationFile(lang);\n\n    if (!langData) {\n      hasErrors = true;\n      results[lang] = { valid: false, missing: [], extra: [] };\n      continue;\n    }\n\n    // Find missing keys\n    const missing = referenceKeyPaths.filter(\n      (keyPath) => !hasKeyPath(langData, keyPath),\n    );\n\n    // Find extra keys (keys in language but not in reference)\n    const langKeyPaths = getAllKeyPaths(langData);\n    const extra = langKeyPaths.filter(\n      (keyPath) => !hasKeyPath(referenceData, keyPath),\n    );\n\n    results[lang] = {\n      valid: missing.length === 0 && extra.length === 0,\n      missing,\n      extra,\n    };\n\n    if (missing.length > 0 || extra.length > 0) {\n      hasErrors = true;\n    }\n  }\n\n  // Print results\n  console.log(colorize(\"Results:\", \"blue\"));\n  console.log(\"─\".repeat(60));\n\n  for (const lang of LANGUAGES) {\n    const result = results[lang];\n\n    if (result.valid) {\n      console.log(\n        colorize(`✓ ${lang.toUpperCase()}: All keys present`, \"green\"),\n      );\n    } else {\n      console.log(colorize(`✗ ${lang.toUpperCase()}: Issues found`, \"red\"));\n\n      if (result.missing.length > 0) {\n        console.log(\n          colorize(`  Missing ${result.missing.length} keys:`, \"yellow\"),\n        );\n        result.missing.slice(0, 10).forEach((keyPath) => {\n          console.log(`    - ${keyPath.join(\".\")}`);\n        });\n        if (result.missing.length > 10) {\n          console.log(\n            colorize(\n              `    ... and ${result.missing.length - 10} more`,\n              \"yellow\",\n            ),\n          );\n        }\n      }\n\n      if (result.extra.length > 0) {\n        console.log(\n          colorize(\n            `  Extra ${result.extra.length} keys (not in reference):`,\n            \"yellow\",\n          ),\n        );\n        result.extra.slice(0, 10).forEach((keyPath) => {\n          console.log(`    - ${keyPath.join(\".\")}`);\n        });\n        if (result.extra.length > 10) {\n          console.log(\n            colorize(`    ... and ${result.extra.length - 10} more`, \"yellow\"),\n          );\n        }\n      }\n\n      console.log(\"\");\n    }\n  }\n\n  console.log(\"─\".repeat(60));\n\n  // Summary\n  const validCount = Object.values(results).filter((r) => r.valid).length;\n  const totalCount = LANGUAGES.length;\n\n  if (hasErrors) {\n    console.log(\n      colorize(\n        `\\n✗ Validation failed: ${validCount}/${totalCount} languages passed`,\n        \"red\",\n      ),\n    );\n    process.exit(1);\n  } else {\n    console.log(\n      colorize(\n        `\\n✓ All ${totalCount} languages have complete translations!`,\n        \"green\",\n      ),\n    );\n    process.exit(0);\n  }\n}\n\n// Run validation\nvalidateTranslations();\n"
  },
  {
    "path": "src/App.css",
    "content": "@import \"tailwindcss\";\n\n@theme {\n  /* Design tokens */\n  --color-text: #0f0f0f;\n  --color-background: #fbfbfb;\n  --color-background-ui: #da5893;\n  --color-logo-primary: #faa2ca;\n  --color-logo-stroke: #382731;\n  --color-text-stroke: #f6f6f6;\n  --color-mid-gray: #808080;\n}\n\n:root {\n  /* Typography */\n  font-size: 15px;\n  line-height: 24px;\n  font-weight: 400;\n\n  /* Colors - Light Theme */\n  /* --color-text: #0f0f0f;\n  --color-background: #fbfbfb;\n  --color-logo-primary: #FAA2CA;\n  --color-logo-stroke: #382731;\n  --color-text-stroke: #f6f6f6; */\n\n  /* Typography settings */\n  font-synthesis: none;\n  text-rendering: optimizeLegibility;\n  -webkit-font-smoothing: antialiased;\n  -moz-osx-font-smoothing: grayscale;\n  -webkit-text-size-adjust: 100%;\n\n  --scrollbar-thumb: color-mix(in srgb, var(--color-text), transparent 85%);\n  --scrollbar-thumb-hover: color-mix(\n    in srgb,\n    var(--color-text),\n    transparent 70%\n  );\n\n  /* Apply colors */\n  color: var(--color-text);\n  background-color: var(--color-background);\n}\n\n.container {\n  margin: 0;\n  padding-top: 10vh;\n  display: flex;\n  flex-direction: column;\n  justify-content: center;\n  text-align: center;\n}\n\n@media (prefers-color-scheme: dark) {\n  :root {\n    /* Colors - Dark Theme */\n    --color-text: #fbfbfb;\n    --color-background: #2c2b29;\n    --color-logo-primary: #f28cbb;\n    --color-logo-stroke: #fad1ed;\n  }\n}\n\n/* macOS - tint native overlay scrollbar thumb */\n:root[data-platform=\"macos\"] {\n  scrollbar-color: var(--scrollbar-thumb) transparent;\n}\n\n/* Custom Scrollbar - only on Windows/Linux; macOS uses native overlay scrollbars */\n:root:not([data-platform=\"macos\"]) ::-webkit-scrollbar {\n  width: 14px;\n  height: 14px;\n}\n\n:root:not([data-platform=\"macos\"]) ::-webkit-scrollbar-track {\n  background: transparent;\n}\n\n:root:not([data-platform=\"macos\"]) ::-webkit-scrollbar-thumb {\n  background-color: var(--scrollbar-thumb);\n  border-radius: 20px;\n  border: 3px solid transparent;\n  border-right-width: 4px;\n  border-left-width: 3px;\n  background-clip: content-box;\n  min-height: 32px;\n}\n\n:root:not([data-platform=\"macos\"]) ::-webkit-scrollbar-thumb:hover {\n  background-color: var(--scrollbar-thumb-hover);\n}\n\n@layer utilities {\n  .text-stroke {\n    -webkit-text-stroke: 2px var(--color-text-stroke);\n  }\n}\n\n.logo-primary {\n  fill: var(--color-logo-primary);\n}\n\n.logo-stroke {\n  fill: var(--color-logo-stroke);\n  stroke: var(--color-logo-stroke);\n  stroke-width: 1;\n}\n"
  },
  {
    "path": "src/App.tsx",
    "content": "import { useEffect, useState, useRef } from \"react\";\nimport { toast, Toaster } from \"sonner\";\nimport { useTranslation } from \"react-i18next\";\nimport { listen } from \"@tauri-apps/api/event\";\nimport { platform } from \"@tauri-apps/plugin-os\";\nimport {\n  checkAccessibilityPermission,\n  checkMicrophonePermission,\n} from \"tauri-plugin-macos-permissions-api\";\nimport { ModelStateEvent, RecordingErrorEvent } from \"./lib/types/events\";\nimport \"./App.css\";\nimport AccessibilityPermissions from \"./components/AccessibilityPermissions\";\nimport Footer from \"./components/footer\";\nimport Onboarding, { AccessibilityOnboarding } from \"./components/onboarding\";\nimport { Sidebar, SidebarSection, SECTIONS_CONFIG } from \"./components/Sidebar\";\nimport { useSettings } from \"./hooks/useSettings\";\nimport { useSettingsStore } from \"./stores/settingsStore\";\nimport { commands } from \"@/bindings\";\nimport { getLanguageDirection, initializeRTL } from \"@/lib/utils/rtl\";\n\ntype OnboardingStep = \"accessibility\" | \"model\" | \"done\";\n\nconst renderSettingsContent = (section: SidebarSection) => {\n  const ActiveComponent =\n    SECTIONS_CONFIG[section]?.component || SECTIONS_CONFIG.general.component;\n  return <ActiveComponent />;\n};\n\nfunction App() {\n  const { t, i18n } = useTranslation();\n  const [onboardingStep, setOnboardingStep] = useState<OnboardingStep | null>(\n    null,\n  );\n  // Track if this is a returning user who just needs to grant permissions\n  // (vs a new user who needs full onboarding including model selection)\n  const [isReturningUser, setIsReturningUser] = useState(false);\n  const [currentSection, setCurrentSection] =\n    useState<SidebarSection>(\"general\");\n  const { settings, updateSetting } = useSettings();\n  const direction = getLanguageDirection(i18n.language);\n  const refreshAudioDevices = useSettingsStore(\n    (state) => state.refreshAudioDevices,\n  );\n  const refreshOutputDevices = useSettingsStore(\n    (state) => state.refreshOutputDevices,\n  );\n  const hasCompletedPostOnboardingInit = useRef(false);\n\n  useEffect(() => {\n    checkOnboardingStatus();\n  }, []);\n\n  // Initialize RTL direction when language changes\n  useEffect(() => {\n    initializeRTL(i18n.language);\n  }, [i18n.language]);\n\n  // Initialize Enigo, shortcuts, and refresh audio devices when main app loads\n  useEffect(() => {\n    if (onboardingStep === \"done\" && !hasCompletedPostOnboardingInit.current) {\n      hasCompletedPostOnboardingInit.current = true;\n      Promise.all([\n        commands.initializeEnigo(),\n        commands.initializeShortcuts(),\n      ]).catch((e) => {\n        console.warn(\"Failed to initialize:\", e);\n      });\n      refreshAudioDevices();\n      refreshOutputDevices();\n    }\n  }, [onboardingStep, refreshAudioDevices, refreshOutputDevices]);\n\n  // Handle keyboard shortcuts for debug mode toggle\n  useEffect(() => {\n    const handleKeyDown = (event: KeyboardEvent) => {\n      // Check for Ctrl+Shift+D (Windows/Linux) or Cmd+Shift+D (macOS)\n      const isDebugShortcut =\n        event.shiftKey &&\n        event.key.toLowerCase() === \"d\" &&\n        (event.ctrlKey || event.metaKey);\n\n      if (isDebugShortcut) {\n        event.preventDefault();\n        const currentDebugMode = settings?.debug_mode ?? false;\n        updateSetting(\"debug_mode\", !currentDebugMode);\n      }\n    };\n\n    // Add event listener when component mounts\n    document.addEventListener(\"keydown\", handleKeyDown);\n\n    // Cleanup event listener when component unmounts\n    return () => {\n      document.removeEventListener(\"keydown\", handleKeyDown);\n    };\n  }, [settings?.debug_mode, updateSetting]);\n\n  // Listen for recording errors from the backend and show a toast\n  useEffect(() => {\n    const unlisten = listen<RecordingErrorEvent>(\"recording-error\", (event) => {\n      const { error_type, detail } = event.payload;\n\n      if (error_type === \"microphone_permission_denied\") {\n        const currentPlatform = platform();\n        const platformKey = `errors.micPermissionDenied.${currentPlatform}`;\n        const description = t(platformKey, {\n          defaultValue: t(\"errors.micPermissionDenied.generic\"),\n        });\n        toast.error(t(\"errors.micPermissionDeniedTitle\"), { description });\n      } else {\n        toast.error(\n          t(\"errors.recordingFailed\", { error: detail ?? \"Unknown error\" }),\n        );\n      }\n    });\n    return () => {\n      unlisten.then((fn) => fn());\n    };\n  }, [t]);\n\n  // Listen for model loading failures and show a toast\n  useEffect(() => {\n    const unlisten = listen<ModelStateEvent>(\"model-state-changed\", (event) => {\n      if (event.payload.event_type === \"loading_failed\") {\n        toast.error(\n          t(\"errors.modelLoadFailed\", {\n            model:\n              event.payload.model_name || t(\"errors.modelLoadFailedUnknown\"),\n          }),\n          {\n            description: event.payload.error,\n          },\n        );\n      }\n    });\n    return () => {\n      unlisten.then((fn) => fn());\n    };\n  }, [t]);\n\n  const revealMainWindowForPermissions = async () => {\n    try {\n      await commands.showMainWindowCommand();\n    } catch (e) {\n      console.warn(\"Failed to show main window for permission onboarding:\", e);\n    }\n  };\n\n  const checkOnboardingStatus = async () => {\n    try {\n      // Check if they have any models available\n      const result = await commands.hasAnyModelsAvailable();\n      const hasModels = result.status === \"ok\" && result.data;\n      const currentPlatform = platform();\n\n      if (hasModels) {\n        // Returning user - check if they need to grant permissions first\n        setIsReturningUser(true);\n\n        if (currentPlatform === \"macos\") {\n          try {\n            const [hasAccessibility, hasMicrophone] = await Promise.all([\n              checkAccessibilityPermission(),\n              checkMicrophonePermission(),\n            ]);\n            if (!hasAccessibility || !hasMicrophone) {\n              await revealMainWindowForPermissions();\n              setOnboardingStep(\"accessibility\");\n              return;\n            }\n          } catch (e) {\n            console.warn(\"Failed to check macOS permissions:\", e);\n            // If we can't check, proceed to main app and let them fix it there\n          }\n        }\n\n        if (currentPlatform === \"windows\") {\n          try {\n            const microphoneStatus =\n              await commands.getWindowsMicrophonePermissionStatus();\n            if (\n              microphoneStatus.supported &&\n              microphoneStatus.overall_access === \"denied\"\n            ) {\n              await revealMainWindowForPermissions();\n              setOnboardingStep(\"accessibility\");\n              return;\n            }\n          } catch (e) {\n            console.warn(\"Failed to check Windows microphone permissions:\", e);\n            // If we can't check, proceed to main app and let them fix it there\n          }\n        }\n\n        setOnboardingStep(\"done\");\n      } else {\n        // New user - start full onboarding\n        setIsReturningUser(false);\n        setOnboardingStep(\"accessibility\");\n      }\n    } catch (error) {\n      console.error(\"Failed to check onboarding status:\", error);\n      setOnboardingStep(\"accessibility\");\n    }\n  };\n\n  const handleAccessibilityComplete = () => {\n    // Returning users already have models, skip to main app\n    // New users need to select a model\n    setOnboardingStep(isReturningUser ? \"done\" : \"model\");\n  };\n\n  const handleModelSelected = () => {\n    // Transition to main app - user has started a download\n    setOnboardingStep(\"done\");\n  };\n\n  // Still checking onboarding status\n  if (onboardingStep === null) {\n    return null;\n  }\n\n  if (onboardingStep === \"accessibility\") {\n    return <AccessibilityOnboarding onComplete={handleAccessibilityComplete} />;\n  }\n\n  if (onboardingStep === \"model\") {\n    return <Onboarding onModelSelected={handleModelSelected} />;\n  }\n\n  return (\n    <div\n      dir={direction}\n      className=\"h-screen flex flex-col select-none cursor-default\"\n    >\n      <Toaster\n        theme=\"system\"\n        toastOptions={{\n          unstyled: true,\n          classNames: {\n            toast:\n              \"bg-background border border-mid-gray/20 rounded-lg shadow-lg px-4 py-3 flex items-center gap-3 text-sm\",\n            title: \"font-medium\",\n            description: \"text-mid-gray\",\n          },\n        }}\n      />\n      {/* Main content area that takes remaining space */}\n      <div className=\"flex-1 flex overflow-hidden\">\n        <Sidebar\n          activeSection={currentSection}\n          onSectionChange={setCurrentSection}\n        />\n        {/* Scrollable content area */}\n        <div className=\"flex-1 flex flex-col overflow-hidden\">\n          <div className=\"flex-1 overflow-y-auto\">\n            <div className=\"flex flex-col items-center p-4 gap-4\">\n              <AccessibilityPermissions />\n              {renderSettingsContent(currentSection)}\n            </div>\n          </div>\n        </div>\n      </div>\n      {/* Fixed footer at bottom */}\n      <Footer />\n    </div>\n  );\n}\n\nexport default App;\n"
  },
  {
    "path": "src/bindings.ts",
    "content": "\n// This file was generated by [tauri-specta](https://github.com/oscartbeaumont/tauri-specta). Do not edit this file manually.\n\n/** user-defined commands **/\n\n\nexport const commands = {\nasync changeBinding(id: string, binding: string) : Promise<Result<BindingResponse, string>> {\n    try {\n    return { status: \"ok\", data: await TAURI_INVOKE(\"change_binding\", { id, binding }) };\n} catch (e) {\n    if(e instanceof Error) throw e;\n    else return { status: \"error\", error: e  as any };\n}\n},\nasync resetBinding(id: string) : Promise<Result<BindingResponse, string>> {\n    try {\n    return { status: \"ok\", data: await TAURI_INVOKE(\"reset_binding\", { id }) };\n} catch (e) {\n    if(e instanceof Error) throw e;\n    else return { status: \"error\", error: e  as any };\n}\n},\nasync changePttSetting(enabled: boolean) : Promise<Result<null, string>> {\n    try {\n    return { status: \"ok\", data: await TAURI_INVOKE(\"change_ptt_setting\", { enabled }) };\n} catch (e) {\n    if(e instanceof Error) throw e;\n    else return { status: \"error\", error: e  as any };\n}\n},\nasync changeAudioFeedbackSetting(enabled: boolean) : Promise<Result<null, string>> {\n    try {\n    return { status: \"ok\", data: await TAURI_INVOKE(\"change_audio_feedback_setting\", { enabled }) };\n} catch (e) {\n    if(e instanceof Error) throw e;\n    else return { status: \"error\", error: e  as any };\n}\n},\nasync changeAudioFeedbackVolumeSetting(volume: number) : Promise<Result<null, string>> {\n    try {\n    return { status: \"ok\", data: await TAURI_INVOKE(\"change_audio_feedback_volume_setting\", { volume }) };\n} catch (e) {\n    if(e instanceof Error) throw e;\n    else return { status: \"error\", error: e  as any };\n}\n},\nasync changeSoundThemeSetting(theme: string) : Promise<Result<null, string>> {\n    try {\n    return { status: \"ok\", data: await TAURI_INVOKE(\"change_sound_theme_setting\", { theme }) };\n} catch (e) {\n    if(e instanceof Error) throw e;\n    else return { status: \"error\", error: e  as any };\n}\n},\nasync changeStartHiddenSetting(enabled: boolean) : Promise<Result<null, string>> {\n    try {\n    return { status: \"ok\", data: await TAURI_INVOKE(\"change_start_hidden_setting\", { enabled }) };\n} catch (e) {\n    if(e instanceof Error) throw e;\n    else return { status: \"error\", error: e  as any };\n}\n},\nasync changeAutostartSetting(enabled: boolean) : Promise<Result<null, string>> {\n    try {\n    return { status: \"ok\", data: await TAURI_INVOKE(\"change_autostart_setting\", { enabled }) };\n} catch (e) {\n    if(e instanceof Error) throw e;\n    else return { status: \"error\", error: e  as any };\n}\n},\nasync changeTranslateToEnglishSetting(enabled: boolean) : Promise<Result<null, string>> {\n    try {\n    return { status: \"ok\", data: await TAURI_INVOKE(\"change_translate_to_english_setting\", { enabled }) };\n} catch (e) {\n    if(e instanceof Error) throw e;\n    else return { status: \"error\", error: e  as any };\n}\n},\nasync changeSelectedLanguageSetting(language: string) : Promise<Result<null, string>> {\n    try {\n    return { status: \"ok\", data: await TAURI_INVOKE(\"change_selected_language_setting\", { language }) };\n} catch (e) {\n    if(e instanceof Error) throw e;\n    else return { status: \"error\", error: e  as any };\n}\n},\nasync changeOverlayPositionSetting(position: string) : Promise<Result<null, string>> {\n    try {\n    return { status: \"ok\", data: await TAURI_INVOKE(\"change_overlay_position_setting\", { position }) };\n} catch (e) {\n    if(e instanceof Error) throw e;\n    else return { status: \"error\", error: e  as any };\n}\n},\nasync changeDebugModeSetting(enabled: boolean) : Promise<Result<null, string>> {\n    try {\n    return { status: \"ok\", data: await TAURI_INVOKE(\"change_debug_mode_setting\", { enabled }) };\n} catch (e) {\n    if(e instanceof Error) throw e;\n    else return { status: \"error\", error: e  as any };\n}\n},\nasync changeWordCorrectionThresholdSetting(threshold: number) : Promise<Result<null, string>> {\n    try {\n    return { status: \"ok\", data: await TAURI_INVOKE(\"change_word_correction_threshold_setting\", { threshold }) };\n} catch (e) {\n    if(e instanceof Error) throw e;\n    else return { status: \"error\", error: e  as any };\n}\n},\nasync changeExtraRecordingBufferSetting(ms: number) : Promise<Result<null, string>> {\n    try {\n    return { status: \"ok\", data: await TAURI_INVOKE(\"change_extra_recording_buffer_setting\", { ms }) };\n} catch (e) {\n    if(e instanceof Error) throw e;\n    else return { status: \"error\", error: e  as any };\n}\n},\nasync changePasteMethodSetting(method: string) : Promise<Result<null, string>> {\n    try {\n    return { status: \"ok\", data: await TAURI_INVOKE(\"change_paste_method_setting\", { method }) };\n} catch (e) {\n    if(e instanceof Error) throw e;\n    else return { status: \"error\", error: e  as any };\n}\n},\nasync getAvailableTypingTools() : Promise<string[]> {\n    return await TAURI_INVOKE(\"get_available_typing_tools\");\n},\nasync changeTypingToolSetting(tool: string) : Promise<Result<null, string>> {\n    try {\n    return { status: \"ok\", data: await TAURI_INVOKE(\"change_typing_tool_setting\", { tool }) };\n} catch (e) {\n    if(e instanceof Error) throw e;\n    else return { status: \"error\", error: e  as any };\n}\n},\nasync changeExternalScriptPathSetting(path: string | null) : Promise<Result<null, string>> {\n    try {\n    return { status: \"ok\", data: await TAURI_INVOKE(\"change_external_script_path_setting\", { path }) };\n} catch (e) {\n    if(e instanceof Error) throw e;\n    else return { status: \"error\", error: e  as any };\n}\n},\nasync changeClipboardHandlingSetting(handling: string) : Promise<Result<null, string>> {\n    try {\n    return { status: \"ok\", data: await TAURI_INVOKE(\"change_clipboard_handling_setting\", { handling }) };\n} catch (e) {\n    if(e instanceof Error) throw e;\n    else return { status: \"error\", error: e  as any };\n}\n},\nasync changeAutoSubmitSetting(enabled: boolean) : Promise<Result<null, string>> {\n    try {\n    return { status: \"ok\", data: await TAURI_INVOKE(\"change_auto_submit_setting\", { enabled }) };\n} catch (e) {\n    if(e instanceof Error) throw e;\n    else return { status: \"error\", error: e  as any };\n}\n},\nasync changeAutoSubmitKeySetting(key: string) : Promise<Result<null, string>> {\n    try {\n    return { status: \"ok\", data: await TAURI_INVOKE(\"change_auto_submit_key_setting\", { key }) };\n} catch (e) {\n    if(e instanceof Error) throw e;\n    else return { status: \"error\", error: e  as any };\n}\n},\nasync changePostProcessEnabledSetting(enabled: boolean) : Promise<Result<null, string>> {\n    try {\n    return { status: \"ok\", data: await TAURI_INVOKE(\"change_post_process_enabled_setting\", { enabled }) };\n} catch (e) {\n    if(e instanceof Error) throw e;\n    else return { status: \"error\", error: e  as any };\n}\n},\nasync changeExperimentalEnabledSetting(enabled: boolean) : Promise<Result<null, string>> {\n    try {\n    return { status: \"ok\", data: await TAURI_INVOKE(\"change_experimental_enabled_setting\", { enabled }) };\n} catch (e) {\n    if(e instanceof Error) throw e;\n    else return { status: \"error\", error: e  as any };\n}\n},\nasync changePostProcessBaseUrlSetting(providerId: string, baseUrl: string) : Promise<Result<null, string>> {\n    try {\n    return { status: \"ok\", data: await TAURI_INVOKE(\"change_post_process_base_url_setting\", { providerId, baseUrl }) };\n} catch (e) {\n    if(e instanceof Error) throw e;\n    else return { status: \"error\", error: e  as any };\n}\n},\nasync changePostProcessApiKeySetting(providerId: string, apiKey: string) : Promise<Result<null, string>> {\n    try {\n    return { status: \"ok\", data: await TAURI_INVOKE(\"change_post_process_api_key_setting\", { providerId, apiKey }) };\n} catch (e) {\n    if(e instanceof Error) throw e;\n    else return { status: \"error\", error: e  as any };\n}\n},\nasync changePostProcessModelSetting(providerId: string, model: string) : Promise<Result<null, string>> {\n    try {\n    return { status: \"ok\", data: await TAURI_INVOKE(\"change_post_process_model_setting\", { providerId, model }) };\n} catch (e) {\n    if(e instanceof Error) throw e;\n    else return { status: \"error\", error: e  as any };\n}\n},\nasync setPostProcessProvider(providerId: string) : Promise<Result<null, string>> {\n    try {\n    return { status: \"ok\", data: await TAURI_INVOKE(\"set_post_process_provider\", { providerId }) };\n} catch (e) {\n    if(e instanceof Error) throw e;\n    else return { status: \"error\", error: e  as any };\n}\n},\nasync fetchPostProcessModels(providerId: string) : Promise<Result<string[], string>> {\n    try {\n    return { status: \"ok\", data: await TAURI_INVOKE(\"fetch_post_process_models\", { providerId }) };\n} catch (e) {\n    if(e instanceof Error) throw e;\n    else return { status: \"error\", error: e  as any };\n}\n},\nasync addPostProcessPrompt(name: string, prompt: string) : Promise<Result<LLMPrompt, string>> {\n    try {\n    return { status: \"ok\", data: await TAURI_INVOKE(\"add_post_process_prompt\", { name, prompt }) };\n} catch (e) {\n    if(e instanceof Error) throw e;\n    else return { status: \"error\", error: e  as any };\n}\n},\nasync updatePostProcessPrompt(id: string, name: string, prompt: string) : Promise<Result<null, string>> {\n    try {\n    return { status: \"ok\", data: await TAURI_INVOKE(\"update_post_process_prompt\", { id, name, prompt }) };\n} catch (e) {\n    if(e instanceof Error) throw e;\n    else return { status: \"error\", error: e  as any };\n}\n},\nasync deletePostProcessPrompt(id: string) : Promise<Result<null, string>> {\n    try {\n    return { status: \"ok\", data: await TAURI_INVOKE(\"delete_post_process_prompt\", { id }) };\n} catch (e) {\n    if(e instanceof Error) throw e;\n    else return { status: \"error\", error: e  as any };\n}\n},\nasync setPostProcessSelectedPrompt(id: string) : Promise<Result<null, string>> {\n    try {\n    return { status: \"ok\", data: await TAURI_INVOKE(\"set_post_process_selected_prompt\", { id }) };\n} catch (e) {\n    if(e instanceof Error) throw e;\n    else return { status: \"error\", error: e  as any };\n}\n},\nasync updateCustomWords(words: string[]) : Promise<Result<null, string>> {\n    try {\n    return { status: \"ok\", data: await TAURI_INVOKE(\"update_custom_words\", { words }) };\n} catch (e) {\n    if(e instanceof Error) throw e;\n    else return { status: \"error\", error: e  as any };\n}\n},\n/**\n * Temporarily unregister a binding while the user is editing it in the UI.\n * This avoids firing the action while keys are being recorded.\n */\nasync suspendBinding(id: string) : Promise<Result<null, string>> {\n    try {\n    return { status: \"ok\", data: await TAURI_INVOKE(\"suspend_binding\", { id }) };\n} catch (e) {\n    if(e instanceof Error) throw e;\n    else return { status: \"error\", error: e  as any };\n}\n},\n/**\n * Re-register the binding after the user has finished editing.\n */\nasync resumeBinding(id: string) : Promise<Result<null, string>> {\n    try {\n    return { status: \"ok\", data: await TAURI_INVOKE(\"resume_binding\", { id }) };\n} catch (e) {\n    if(e instanceof Error) throw e;\n    else return { status: \"error\", error: e  as any };\n}\n},\nasync changeMuteWhileRecordingSetting(enabled: boolean) : Promise<Result<null, string>> {\n    try {\n    return { status: \"ok\", data: await TAURI_INVOKE(\"change_mute_while_recording_setting\", { enabled }) };\n} catch (e) {\n    if(e instanceof Error) throw e;\n    else return { status: \"error\", error: e  as any };\n}\n},\nasync changeAppendTrailingSpaceSetting(enabled: boolean) : Promise<Result<null, string>> {\n    try {\n    return { status: \"ok\", data: await TAURI_INVOKE(\"change_append_trailing_space_setting\", { enabled }) };\n} catch (e) {\n    if(e instanceof Error) throw e;\n    else return { status: \"error\", error: e  as any };\n}\n},\nasync changeLazyStreamCloseSetting(enabled: boolean) : Promise<Result<null, string>> {\n    try {\n    return { status: \"ok\", data: await TAURI_INVOKE(\"change_lazy_stream_close_setting\", { enabled }) };\n} catch (e) {\n    if(e instanceof Error) throw e;\n    else return { status: \"error\", error: e  as any };\n}\n},\nasync changeAppLanguageSetting(language: string) : Promise<Result<null, string>> {\n    try {\n    return { status: \"ok\", data: await TAURI_INVOKE(\"change_app_language_setting\", { language }) };\n} catch (e) {\n    if(e instanceof Error) throw e;\n    else return { status: \"error\", error: e  as any };\n}\n},\nasync changeUpdateChecksSetting(enabled: boolean) : Promise<Result<null, string>> {\n    try {\n    return { status: \"ok\", data: await TAURI_INVOKE(\"change_update_checks_setting\", { enabled }) };\n} catch (e) {\n    if(e instanceof Error) throw e;\n    else return { status: \"error\", error: e  as any };\n}\n},\n/**\n * Change the keyboard implementation with runtime switching.\n * This will unregister all shortcuts from the old implementation,\n * validate shortcuts for the new implementation (resetting invalid ones to defaults),\n * and register them with the new implementation.\n */\nasync changeKeyboardImplementationSetting(implementation: string) : Promise<Result<ImplementationChangeResult, string>> {\n    try {\n    return { status: \"ok\", data: await TAURI_INVOKE(\"change_keyboard_implementation_setting\", { implementation }) };\n} catch (e) {\n    if(e instanceof Error) throw e;\n    else return { status: \"error\", error: e  as any };\n}\n},\n/**\n * Get the current keyboard implementation\n */\nasync getKeyboardImplementation() : Promise<string> {\n    return await TAURI_INVOKE(\"get_keyboard_implementation\");\n},\nasync changeShowTrayIconSetting(enabled: boolean) : Promise<Result<null, string>> {\n    try {\n    return { status: \"ok\", data: await TAURI_INVOKE(\"change_show_tray_icon_setting\", { enabled }) };\n} catch (e) {\n    if(e instanceof Error) throw e;\n    else return { status: \"error\", error: e  as any };\n}\n},\nasync changeWhisperAcceleratorSetting(accelerator: WhisperAcceleratorSetting) : Promise<Result<null, string>> {\n    try {\n    return { status: \"ok\", data: await TAURI_INVOKE(\"change_whisper_accelerator_setting\", { accelerator }) };\n} catch (e) {\n    if(e instanceof Error) throw e;\n    else return { status: \"error\", error: e  as any };\n}\n},\nasync changeOrtAcceleratorSetting(accelerator: OrtAcceleratorSetting) : Promise<Result<null, string>> {\n    try {\n    return { status: \"ok\", data: await TAURI_INVOKE(\"change_ort_accelerator_setting\", { accelerator }) };\n} catch (e) {\n    if(e instanceof Error) throw e;\n    else return { status: \"error\", error: e  as any };\n}\n},\n/**\n * Return which ORT accelerators are compiled into this build.\n */\nasync getAvailableAccelerators() : Promise<AvailableAccelerators> {\n    return await TAURI_INVOKE(\"get_available_accelerators\");\n},\n/**\n * Start key recording mode\n */\nasync startHandyKeysRecording(bindingId: string) : Promise<Result<null, string>> {\n    try {\n    return { status: \"ok\", data: await TAURI_INVOKE(\"start_handy_keys_recording\", { bindingId }) };\n} catch (e) {\n    if(e instanceof Error) throw e;\n    else return { status: \"error\", error: e  as any };\n}\n},\n/**\n * Stop key recording mode\n */\nasync stopHandyKeysRecording() : Promise<Result<null, string>> {\n    try {\n    return { status: \"ok\", data: await TAURI_INVOKE(\"stop_handy_keys_recording\") };\n} catch (e) {\n    if(e instanceof Error) throw e;\n    else return { status: \"error\", error: e  as any };\n}\n},\nasync triggerUpdateCheck() : Promise<Result<null, string>> {\n    try {\n    return { status: \"ok\", data: await TAURI_INVOKE(\"trigger_update_check\") };\n} catch (e) {\n    if(e instanceof Error) throw e;\n    else return { status: \"error\", error: e  as any };\n}\n},\nasync showMainWindowCommand() : Promise<Result<null, string>> {\n    try {\n    return { status: \"ok\", data: await TAURI_INVOKE(\"show_main_window_command\") };\n} catch (e) {\n    if(e instanceof Error) throw e;\n    else return { status: \"error\", error: e  as any };\n}\n},\nasync cancelOperation() : Promise<void> {\n    await TAURI_INVOKE(\"cancel_operation\");\n},\nasync getAppDirPath() : Promise<Result<string, string>> {\n    try {\n    return { status: \"ok\", data: await TAURI_INVOKE(\"get_app_dir_path\") };\n} catch (e) {\n    if(e instanceof Error) throw e;\n    else return { status: \"error\", error: e  as any };\n}\n},\nasync getAppSettings() : Promise<Result<AppSettings, string>> {\n    try {\n    return { status: \"ok\", data: await TAURI_INVOKE(\"get_app_settings\") };\n} catch (e) {\n    if(e instanceof Error) throw e;\n    else return { status: \"error\", error: e  as any };\n}\n},\nasync getDefaultSettings() : Promise<Result<AppSettings, string>> {\n    try {\n    return { status: \"ok\", data: await TAURI_INVOKE(\"get_default_settings\") };\n} catch (e) {\n    if(e instanceof Error) throw e;\n    else return { status: \"error\", error: e  as any };\n}\n},\nasync getLogDirPath() : Promise<Result<string, string>> {\n    try {\n    return { status: \"ok\", data: await TAURI_INVOKE(\"get_log_dir_path\") };\n} catch (e) {\n    if(e instanceof Error) throw e;\n    else return { status: \"error\", error: e  as any };\n}\n},\nasync setLogLevel(level: LogLevel) : Promise<Result<null, string>> {\n    try {\n    return { status: \"ok\", data: await TAURI_INVOKE(\"set_log_level\", { level }) };\n} catch (e) {\n    if(e instanceof Error) throw e;\n    else return { status: \"error\", error: e  as any };\n}\n},\nasync openRecordingsFolder() : Promise<Result<null, string>> {\n    try {\n    return { status: \"ok\", data: await TAURI_INVOKE(\"open_recordings_folder\") };\n} catch (e) {\n    if(e instanceof Error) throw e;\n    else return { status: \"error\", error: e  as any };\n}\n},\nasync openLogDir() : Promise<Result<null, string>> {\n    try {\n    return { status: \"ok\", data: await TAURI_INVOKE(\"open_log_dir\") };\n} catch (e) {\n    if(e instanceof Error) throw e;\n    else return { status: \"error\", error: e  as any };\n}\n},\nasync openAppDataDir() : Promise<Result<null, string>> {\n    try {\n    return { status: \"ok\", data: await TAURI_INVOKE(\"open_app_data_dir\") };\n} catch (e) {\n    if(e instanceof Error) throw e;\n    else return { status: \"error\", error: e  as any };\n}\n},\n/**\n * Check if Apple Intelligence is available on this device.\n * Called by the frontend when the user selects Apple Intelligence provider.\n */\nasync checkAppleIntelligenceAvailable() : Promise<boolean> {\n    return await TAURI_INVOKE(\"check_apple_intelligence_available\");\n},\n/**\n * Try to initialize Enigo (keyboard/mouse simulation).\n * On macOS, this will return an error if accessibility permissions are not granted.\n */\nasync initializeEnigo() : Promise<Result<null, string>> {\n    try {\n    return { status: \"ok\", data: await TAURI_INVOKE(\"initialize_enigo\") };\n} catch (e) {\n    if(e instanceof Error) throw e;\n    else return { status: \"error\", error: e  as any };\n}\n},\n/**\n * Initialize keyboard shortcuts.\n * On macOS, this should be called after accessibility permissions are granted.\n * This is idempotent - calling it multiple times is safe.\n */\nasync initializeShortcuts() : Promise<Result<null, string>> {\n    try {\n    return { status: \"ok\", data: await TAURI_INVOKE(\"initialize_shortcuts\") };\n} catch (e) {\n    if(e instanceof Error) throw e;\n    else return { status: \"error\", error: e  as any };\n}\n},\nasync getAvailableModels() : Promise<Result<ModelInfo[], string>> {\n    try {\n    return { status: \"ok\", data: await TAURI_INVOKE(\"get_available_models\") };\n} catch (e) {\n    if(e instanceof Error) throw e;\n    else return { status: \"error\", error: e  as any };\n}\n},\nasync getModelInfo(modelId: string) : Promise<Result<ModelInfo | null, string>> {\n    try {\n    return { status: \"ok\", data: await TAURI_INVOKE(\"get_model_info\", { modelId }) };\n} catch (e) {\n    if(e instanceof Error) throw e;\n    else return { status: \"error\", error: e  as any };\n}\n},\nasync downloadModel(modelId: string) : Promise<Result<null, string>> {\n    try {\n    return { status: \"ok\", data: await TAURI_INVOKE(\"download_model\", { modelId }) };\n} catch (e) {\n    if(e instanceof Error) throw e;\n    else return { status: \"error\", error: e  as any };\n}\n},\nasync deleteModel(modelId: string) : Promise<Result<null, string>> {\n    try {\n    return { status: \"ok\", data: await TAURI_INVOKE(\"delete_model\", { modelId }) };\n} catch (e) {\n    if(e instanceof Error) throw e;\n    else return { status: \"error\", error: e  as any };\n}\n},\nasync cancelDownload(modelId: string) : Promise<Result<null, string>> {\n    try {\n    return { status: \"ok\", data: await TAURI_INVOKE(\"cancel_download\", { modelId }) };\n} catch (e) {\n    if(e instanceof Error) throw e;\n    else return { status: \"error\", error: e  as any };\n}\n},\nasync setActiveModel(modelId: string) : Promise<Result<null, string>> {\n    try {\n    return { status: \"ok\", data: await TAURI_INVOKE(\"set_active_model\", { modelId }) };\n} catch (e) {\n    if(e instanceof Error) throw e;\n    else return { status: \"error\", error: e  as any };\n}\n},\nasync getCurrentModel() : Promise<Result<string, string>> {\n    try {\n    return { status: \"ok\", data: await TAURI_INVOKE(\"get_current_model\") };\n} catch (e) {\n    if(e instanceof Error) throw e;\n    else return { status: \"error\", error: e  as any };\n}\n},\nasync getTranscriptionModelStatus() : Promise<Result<string | null, string>> {\n    try {\n    return { status: \"ok\", data: await TAURI_INVOKE(\"get_transcription_model_status\") };\n} catch (e) {\n    if(e instanceof Error) throw e;\n    else return { status: \"error\", error: e  as any };\n}\n},\nasync isModelLoading() : Promise<Result<boolean, string>> {\n    try {\n    return { status: \"ok\", data: await TAURI_INVOKE(\"is_model_loading\") };\n} catch (e) {\n    if(e instanceof Error) throw e;\n    else return { status: \"error\", error: e  as any };\n}\n},\nasync hasAnyModelsAvailable() : Promise<Result<boolean, string>> {\n    try {\n    return { status: \"ok\", data: await TAURI_INVOKE(\"has_any_models_available\") };\n} catch (e) {\n    if(e instanceof Error) throw e;\n    else return { status: \"error\", error: e  as any };\n}\n},\nasync hasAnyModelsOrDownloads() : Promise<Result<boolean, string>> {\n    try {\n    return { status: \"ok\", data: await TAURI_INVOKE(\"has_any_models_or_downloads\") };\n} catch (e) {\n    if(e instanceof Error) throw e;\n    else return { status: \"error\", error: e  as any };\n}\n},\nasync updateMicrophoneMode(alwaysOn: boolean) : Promise<Result<null, string>> {\n    try {\n    return { status: \"ok\", data: await TAURI_INVOKE(\"update_microphone_mode\", { alwaysOn }) };\n} catch (e) {\n    if(e instanceof Error) throw e;\n    else return { status: \"error\", error: e  as any };\n}\n},\nasync getMicrophoneMode() : Promise<Result<boolean, string>> {\n    try {\n    return { status: \"ok\", data: await TAURI_INVOKE(\"get_microphone_mode\") };\n} catch (e) {\n    if(e instanceof Error) throw e;\n    else return { status: \"error\", error: e  as any };\n}\n},\nasync getWindowsMicrophonePermissionStatus() : Promise<WindowsMicrophonePermissionStatus> {\n    return await TAURI_INVOKE(\"get_windows_microphone_permission_status\");\n},\nasync openMicrophonePrivacySettings() : Promise<Result<null, string>> {\n    try {\n    return { status: \"ok\", data: await TAURI_INVOKE(\"open_microphone_privacy_settings\") };\n} catch (e) {\n    if(e instanceof Error) throw e;\n    else return { status: \"error\", error: e  as any };\n}\n},\nasync getAvailableMicrophones() : Promise<Result<AudioDevice[], string>> {\n    try {\n    return { status: \"ok\", data: await TAURI_INVOKE(\"get_available_microphones\") };\n} catch (e) {\n    if(e instanceof Error) throw e;\n    else return { status: \"error\", error: e  as any };\n}\n},\nasync setSelectedMicrophone(deviceName: string) : Promise<Result<null, string>> {\n    try {\n    return { status: \"ok\", data: await TAURI_INVOKE(\"set_selected_microphone\", { deviceName }) };\n} catch (e) {\n    if(e instanceof Error) throw e;\n    else return { status: \"error\", error: e  as any };\n}\n},\nasync getSelectedMicrophone() : Promise<Result<string, string>> {\n    try {\n    return { status: \"ok\", data: await TAURI_INVOKE(\"get_selected_microphone\") };\n} catch (e) {\n    if(e instanceof Error) throw e;\n    else return { status: \"error\", error: e  as any };\n}\n},\nasync getAvailableOutputDevices() : Promise<Result<AudioDevice[], string>> {\n    try {\n    return { status: \"ok\", data: await TAURI_INVOKE(\"get_available_output_devices\") };\n} catch (e) {\n    if(e instanceof Error) throw e;\n    else return { status: \"error\", error: e  as any };\n}\n},\nasync setSelectedOutputDevice(deviceName: string) : Promise<Result<null, string>> {\n    try {\n    return { status: \"ok\", data: await TAURI_INVOKE(\"set_selected_output_device\", { deviceName }) };\n} catch (e) {\n    if(e instanceof Error) throw e;\n    else return { status: \"error\", error: e  as any };\n}\n},\nasync getSelectedOutputDevice() : Promise<Result<string, string>> {\n    try {\n    return { status: \"ok\", data: await TAURI_INVOKE(\"get_selected_output_device\") };\n} catch (e) {\n    if(e instanceof Error) throw e;\n    else return { status: \"error\", error: e  as any };\n}\n},\nasync playTestSound(soundType: string) : Promise<void> {\n    await TAURI_INVOKE(\"play_test_sound\", { soundType });\n},\nasync checkCustomSounds() : Promise<CustomSounds> {\n    return await TAURI_INVOKE(\"check_custom_sounds\");\n},\nasync setClamshellMicrophone(deviceName: string) : Promise<Result<null, string>> {\n    try {\n    return { status: \"ok\", data: await TAURI_INVOKE(\"set_clamshell_microphone\", { deviceName }) };\n} catch (e) {\n    if(e instanceof Error) throw e;\n    else return { status: \"error\", error: e  as any };\n}\n},\nasync getClamshellMicrophone() : Promise<Result<string, string>> {\n    try {\n    return { status: \"ok\", data: await TAURI_INVOKE(\"get_clamshell_microphone\") };\n} catch (e) {\n    if(e instanceof Error) throw e;\n    else return { status: \"error\", error: e  as any };\n}\n},\nasync isRecording() : Promise<boolean> {\n    return await TAURI_INVOKE(\"is_recording\");\n},\nasync setModelUnloadTimeout(timeout: ModelUnloadTimeout) : Promise<void> {\n    await TAURI_INVOKE(\"set_model_unload_timeout\", { timeout });\n},\nasync getModelLoadStatus() : Promise<Result<ModelLoadStatus, string>> {\n    try {\n    return { status: \"ok\", data: await TAURI_INVOKE(\"get_model_load_status\") };\n} catch (e) {\n    if(e instanceof Error) throw e;\n    else return { status: \"error\", error: e  as any };\n}\n},\nasync unloadModelManually() : Promise<Result<null, string>> {\n    try {\n    return { status: \"ok\", data: await TAURI_INVOKE(\"unload_model_manually\") };\n} catch (e) {\n    if(e instanceof Error) throw e;\n    else return { status: \"error\", error: e  as any };\n}\n},\nasync getHistoryEntries() : Promise<Result<HistoryEntry[], string>> {\n    try {\n    return { status: \"ok\", data: await TAURI_INVOKE(\"get_history_entries\") };\n} catch (e) {\n    if(e instanceof Error) throw e;\n    else return { status: \"error\", error: e  as any };\n}\n},\nasync toggleHistoryEntrySaved(id: number) : Promise<Result<null, string>> {\n    try {\n    return { status: \"ok\", data: await TAURI_INVOKE(\"toggle_history_entry_saved\", { id }) };\n} catch (e) {\n    if(e instanceof Error) throw e;\n    else return { status: \"error\", error: e  as any };\n}\n},\nasync getAudioFilePath(fileName: string) : Promise<Result<string, string>> {\n    try {\n    return { status: \"ok\", data: await TAURI_INVOKE(\"get_audio_file_path\", { fileName }) };\n} catch (e) {\n    if(e instanceof Error) throw e;\n    else return { status: \"error\", error: e  as any };\n}\n},\nasync deleteHistoryEntry(id: number) : Promise<Result<null, string>> {\n    try {\n    return { status: \"ok\", data: await TAURI_INVOKE(\"delete_history_entry\", { id }) };\n} catch (e) {\n    if(e instanceof Error) throw e;\n    else return { status: \"error\", error: e  as any };\n}\n},\nasync updateHistoryLimit(limit: number) : Promise<Result<null, string>> {\n    try {\n    return { status: \"ok\", data: await TAURI_INVOKE(\"update_history_limit\", { limit }) };\n} catch (e) {\n    if(e instanceof Error) throw e;\n    else return { status: \"error\", error: e  as any };\n}\n},\nasync updateRecordingRetentionPeriod(period: string) : Promise<Result<null, string>> {\n    try {\n    return { status: \"ok\", data: await TAURI_INVOKE(\"update_recording_retention_period\", { period }) };\n} catch (e) {\n    if(e instanceof Error) throw e;\n    else return { status: \"error\", error: e  as any };\n}\n},\n/**\n * Checks if the Mac is a laptop by detecting battery presence\n * \n * This uses pmset to check for battery information.\n * Returns true if a battery is detected (laptop), false otherwise (desktop)\n */\nasync isLaptop() : Promise<Result<boolean, string>> {\n    try {\n    return { status: \"ok\", data: await TAURI_INVOKE(\"is_laptop\") };\n} catch (e) {\n    if(e instanceof Error) throw e;\n    else return { status: \"error\", error: e  as any };\n}\n}\n}\n\n/** user-defined events **/\n\n\n\n/** user-defined constants **/\n\n\n\n/** user-defined types **/\n\nexport type AppSettings = { bindings: Partial<{ [key in string]: ShortcutBinding }>; push_to_talk: boolean; audio_feedback: boolean; audio_feedback_volume?: number; sound_theme?: SoundTheme; start_hidden?: boolean; autostart_enabled?: boolean; update_checks_enabled?: boolean; selected_model?: string; always_on_microphone?: boolean; selected_microphone?: string | null; clamshell_microphone?: string | null; selected_output_device?: string | null; translate_to_english?: boolean; selected_language?: string; overlay_position?: OverlayPosition; debug_mode?: boolean; log_level?: LogLevel; custom_words?: string[]; model_unload_timeout?: ModelUnloadTimeout; word_correction_threshold?: number; history_limit?: number; recording_retention_period?: RecordingRetentionPeriod; paste_method?: PasteMethod; clipboard_handling?: ClipboardHandling; auto_submit?: boolean; auto_submit_key?: AutoSubmitKey; post_process_enabled?: boolean; post_process_provider_id?: string; post_process_providers?: PostProcessProvider[]; post_process_api_keys?: Partial<{ [key in string]: string }>; post_process_models?: Partial<{ [key in string]: string }>; post_process_prompts?: LLMPrompt[]; post_process_selected_prompt_id?: string | null; mute_while_recording?: boolean; append_trailing_space?: boolean; app_language?: string; experimental_enabled?: boolean; lazy_stream_close?: boolean; keyboard_implementation?: KeyboardImplementation; show_tray_icon?: boolean; paste_delay_ms?: number; typing_tool?: TypingTool; external_script_path: string | null; custom_filler_words?: string[] | null; whisper_accelerator?: WhisperAcceleratorSetting; ort_accelerator?: OrtAcceleratorSetting; extra_recording_buffer_ms?: number }\nexport type AudioDevice = { index: string; name: string; is_default: boolean }\nexport type AutoSubmitKey = \"enter\" | \"ctrl_enter\" | \"cmd_enter\"\nexport type AvailableAccelerators = { whisper: string[]; ort: string[] }\nexport type BindingResponse = { success: boolean; binding: ShortcutBinding | null; error: string | null }\nexport type ClipboardHandling = \"dont_modify\" | \"copy_to_clipboard\"\nexport type CustomSounds = { start: boolean; stop: boolean }\nexport type EngineType = \"Whisper\" | \"Parakeet\" | \"Moonshine\" | \"MoonshineStreaming\" | \"SenseVoice\" | \"GigaAM\" | \"Canary\"\nexport type HistoryEntry = { id: number; file_name: string; timestamp: number; saved: boolean; title: string; transcription_text: string; post_processed_text: string | null; post_process_prompt: string | null }\n/**\n * Result of changing keyboard implementation\n */\nexport type ImplementationChangeResult = { success: boolean; \n/**\n * List of binding IDs that were reset to defaults due to incompatibility\n */\nreset_bindings: string[] }\nexport type KeyboardImplementation = \"tauri\" | \"handy_keys\"\nexport type LLMPrompt = { id: string; name: string; prompt: string }\nexport type LogLevel = \"trace\" | \"debug\" | \"info\" | \"warn\" | \"error\"\nexport type ModelInfo = { id: string; name: string; description: string; filename: string; url: string | null; size_mb: number; is_downloaded: boolean; is_downloading: boolean; partial_size: number; is_directory: boolean; engine_type: EngineType; accuracy_score: number; speed_score: number; supports_translation: boolean; is_recommended: boolean; supported_languages: string[]; supports_language_selection: boolean; is_custom: boolean }\nexport type ModelLoadStatus = { is_loaded: boolean; current_model: string | null }\nexport type ModelUnloadTimeout = \"never\" | \"immediately\" | \"min_2\" | \"min_5\" | \"min_10\" | \"min_15\" | \"hour_1\" | \"sec_15\"\nexport type OrtAcceleratorSetting = \"auto\" | \"cpu\" | \"cuda\" | \"directml\" | \"rocm\"\nexport type OverlayPosition = \"none\" | \"top\" | \"bottom\"\nexport type PasteMethod = \"ctrl_v\" | \"direct\" | \"none\" | \"shift_insert\" | \"ctrl_shift_v\" | \"external_script\"\nexport type PermissionAccess = \"allowed\" | \"denied\" | \"unknown\"\nexport type PostProcessProvider = { id: string; label: string; base_url: string; allow_base_url_edit?: boolean; models_endpoint?: string | null; supports_structured_output?: boolean }\nexport type RecordingRetentionPeriod = \"never\" | \"preserve_limit\" | \"days_3\" | \"weeks_2\" | \"months_3\"\nexport type ShortcutBinding = { id: string; name: string; description: string; default_binding: string; current_binding: string }\nexport type SoundTheme = \"marimba\" | \"pop\" | \"custom\"\nexport type TypingTool = \"auto\" | \"wtype\" | \"kwtype\" | \"dotool\" | \"ydotool\" | \"xdotool\"\nexport type WhisperAcceleratorSetting = \"auto\" | \"cpu\" | \"gpu\"\nexport type WindowsMicrophonePermissionStatus = { supported: boolean; overall_access: PermissionAccess; device_access: PermissionAccess; app_access: PermissionAccess; desktop_app_access: PermissionAccess }\n\n/** tauri-specta globals **/\n\nimport {\n\tinvoke as TAURI_INVOKE,\n\tChannel as TAURI_CHANNEL,\n} from \"@tauri-apps/api/core\";\nimport * as TAURI_API_EVENT from \"@tauri-apps/api/event\";\nimport { type WebviewWindow as __WebviewWindow__ } from \"@tauri-apps/api/webviewWindow\";\n\ntype __EventObj__<T> = {\n\tlisten: (\n\t\tcb: TAURI_API_EVENT.EventCallback<T>,\n\t) => ReturnType<typeof TAURI_API_EVENT.listen<T>>;\n\tonce: (\n\t\tcb: TAURI_API_EVENT.EventCallback<T>,\n\t) => ReturnType<typeof TAURI_API_EVENT.once<T>>;\n\temit: null extends T\n\t\t? (payload?: T) => ReturnType<typeof TAURI_API_EVENT.emit>\n\t\t: (payload: T) => ReturnType<typeof TAURI_API_EVENT.emit>;\n};\n\nexport type Result<T, E> =\n\t| { status: \"ok\"; data: T }\n\t| { status: \"error\"; error: E };\n\nfunction __makeEvents__<T extends Record<string, any>>(\n\tmappings: Record<keyof T, string>,\n) {\n\treturn new Proxy(\n\t\t{} as unknown as {\n\t\t\t[K in keyof T]: __EventObj__<T[K]> & {\n\t\t\t\t(handle: __WebviewWindow__): __EventObj__<T[K]>;\n\t\t\t};\n\t\t},\n\t\t{\n\t\t\tget: (_, event) => {\n\t\t\t\tconst name = mappings[event as keyof T];\n\n\t\t\t\treturn new Proxy((() => {}) as any, {\n\t\t\t\t\tapply: (_, __, [window]: [__WebviewWindow__]) => ({\n\t\t\t\t\t\tlisten: (arg: any) => window.listen(name, arg),\n\t\t\t\t\t\tonce: (arg: any) => window.once(name, arg),\n\t\t\t\t\t\temit: (arg: any) => window.emit(name, arg),\n\t\t\t\t\t}),\n\t\t\t\t\tget: (_, command: keyof __EventObj__<any>) => {\n\t\t\t\t\t\tswitch (command) {\n\t\t\t\t\t\t\tcase \"listen\":\n\t\t\t\t\t\t\t\treturn (arg: any) => TAURI_API_EVENT.listen(name, arg);\n\t\t\t\t\t\t\tcase \"once\":\n\t\t\t\t\t\t\t\treturn (arg: any) => TAURI_API_EVENT.once(name, arg);\n\t\t\t\t\t\t\tcase \"emit\":\n\t\t\t\t\t\t\t\treturn (arg: any) => TAURI_API_EVENT.emit(name, arg);\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t});\n\t\t\t},\n\t\t},\n\t);\n}\n"
  },
  {
    "path": "src/components/AccessibilityPermissions.tsx",
    "content": "import { useEffect, useState } from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { type } from \"@tauri-apps/plugin-os\";\nimport {\n  checkAccessibilityPermission,\n  requestAccessibilityPermission,\n} from \"tauri-plugin-macos-permissions-api\";\n\n// Define permission state type\ntype PermissionState = \"request\" | \"verify\" | \"granted\";\n\n// Define button configuration type\ninterface ButtonConfig {\n  text: string;\n  className: string;\n}\n\nconst AccessibilityPermissions: React.FC = () => {\n  const { t } = useTranslation();\n  const [hasAccessibility, setHasAccessibility] = useState<boolean>(false);\n  const [permissionState, setPermissionState] =\n    useState<PermissionState>(\"request\");\n\n  // Accessibility permissions are only required on macOS\n  const isMacOS = type() === \"macos\";\n\n  // Check permissions without requesting\n  const checkPermissions = async (): Promise<boolean> => {\n    const hasPermissions: boolean = await checkAccessibilityPermission();\n    setHasAccessibility(hasPermissions);\n    setPermissionState(hasPermissions ? \"granted\" : \"verify\");\n    return hasPermissions;\n  };\n\n  // Handle the unified button action based on current state\n  const handleButtonClick = async (): Promise<void> => {\n    if (permissionState === \"request\") {\n      try {\n        await requestAccessibilityPermission();\n        // After system prompt, transition to verification state\n        setPermissionState(\"verify\");\n      } catch (error) {\n        console.error(\"Error requesting permissions:\", error);\n        setPermissionState(\"verify\");\n      }\n    } else if (permissionState === \"verify\") {\n      // State is \"verify\" - check if permission was granted\n      await checkPermissions();\n    }\n  };\n\n  // On app boot - check permissions (only on macOS)\n  useEffect(() => {\n    if (!isMacOS) return;\n\n    const initialSetup = async (): Promise<void> => {\n      const hasPermissions: boolean = await checkAccessibilityPermission();\n      setHasAccessibility(hasPermissions);\n      setPermissionState(hasPermissions ? \"granted\" : \"request\");\n    };\n\n    initialSetup();\n  }, [isMacOS]);\n\n  // Skip rendering on non-macOS platforms or if permission is already granted\n  if (!isMacOS || hasAccessibility) {\n    return null;\n  }\n\n  // Configure button text and style based on state\n  const buttonConfig: Record<PermissionState, ButtonConfig | null> = {\n    request: {\n      text: t(\"accessibility.openSettings\"),\n      className:\n        \"px-2 py-1 text-sm font-semibold bg-mid-gray/10 border  border-mid-gray/80 hover:bg-logo-primary/10 rounded cursor-pointer hover:border-logo-primary\",\n    },\n    verify: {\n      text: t(\"accessibility.openSettings\"),\n      className:\n        \"bg-gray-100 hover:bg-gray-200 text-gray-800 font-medium py-1 px-3 rounded-md text-sm flex items-center justify-center cursor-pointer\",\n    },\n    granted: null,\n  };\n\n  const config = buttonConfig[permissionState] as ButtonConfig;\n\n  return (\n    <div className=\"p-4 w-full rounded-lg border border-mid-gray\">\n      <div className=\"flex justify-between items-center gap-2\">\n        <div className=\"\">\n          <p className=\"text-sm font-medium\">\n            {t(\"accessibility.permissionsDescription\")}\n          </p>\n        </div>\n        <button\n          onClick={handleButtonClick}\n          className={`min-h-10 ${config.className}`}\n        >\n          {config.text}\n        </button>\n      </div>\n    </div>\n  );\n};\n\nexport default AccessibilityPermissions;\n"
  },
  {
    "path": "src/components/Sidebar.tsx",
    "content": "import React from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { Cog, FlaskConical, History, Info, Sparkles, Cpu } from \"lucide-react\";\nimport HandyTextLogo from \"./icons/HandyTextLogo\";\nimport HandyHand from \"./icons/HandyHand\";\nimport { useSettings } from \"../hooks/useSettings\";\nimport {\n  GeneralSettings,\n  AdvancedSettings,\n  HistorySettings,\n  DebugSettings,\n  AboutSettings,\n  PostProcessingSettings,\n  ModelsSettings,\n} from \"./settings\";\n\nexport type SidebarSection = keyof typeof SECTIONS_CONFIG;\n\ninterface IconProps {\n  width?: number | string;\n  height?: number | string;\n  size?: number | string;\n  className?: string;\n  [key: string]: any;\n}\n\ninterface SectionConfig {\n  labelKey: string;\n  icon: React.ComponentType<IconProps>;\n  component: React.ComponentType;\n  enabled: (settings: any) => boolean;\n}\n\nexport const SECTIONS_CONFIG = {\n  general: {\n    labelKey: \"sidebar.general\",\n    icon: HandyHand,\n    component: GeneralSettings,\n    enabled: () => true,\n  },\n  models: {\n    labelKey: \"sidebar.models\",\n    icon: Cpu,\n    component: ModelsSettings,\n    enabled: () => true,\n  },\n  advanced: {\n    labelKey: \"sidebar.advanced\",\n    icon: Cog,\n    component: AdvancedSettings,\n    enabled: () => true,\n  },\n  postprocessing: {\n    labelKey: \"sidebar.postProcessing\",\n    icon: Sparkles,\n    component: PostProcessingSettings,\n    enabled: (settings) => settings?.post_process_enabled ?? false,\n  },\n  history: {\n    labelKey: \"sidebar.history\",\n    icon: History,\n    component: HistorySettings,\n    enabled: () => true,\n  },\n  debug: {\n    labelKey: \"sidebar.debug\",\n    icon: FlaskConical,\n    component: DebugSettings,\n    enabled: (settings) => settings?.debug_mode ?? false,\n  },\n  about: {\n    labelKey: \"sidebar.about\",\n    icon: Info,\n    component: AboutSettings,\n    enabled: () => true,\n  },\n} as const satisfies Record<string, SectionConfig>;\n\ninterface SidebarProps {\n  activeSection: SidebarSection;\n  onSectionChange: (section: SidebarSection) => void;\n}\n\nexport const Sidebar: React.FC<SidebarProps> = ({\n  activeSection,\n  onSectionChange,\n}) => {\n  const { t } = useTranslation();\n  const { settings } = useSettings();\n\n  const availableSections = Object.entries(SECTIONS_CONFIG)\n    .filter(([_, config]) => config.enabled(settings))\n    .map(([id, config]) => ({ id: id as SidebarSection, ...config }));\n\n  return (\n    <div className=\"flex flex-col w-40 h-full border-e border-mid-gray/20 items-center px-2\">\n      <HandyTextLogo width={120} className=\"m-4\" />\n      <div className=\"flex flex-col w-full items-center gap-1 pt-2 border-t border-mid-gray/20\">\n        {availableSections.map((section) => {\n          const Icon = section.icon;\n          const isActive = activeSection === section.id;\n\n          return (\n            <div\n              key={section.id}\n              className={`flex gap-2 items-center p-2 w-full rounded-lg cursor-pointer transition-colors ${\n                isActive\n                  ? \"bg-logo-primary/80\"\n                  : \"hover:bg-mid-gray/20 hover:opacity-100 opacity-85\"\n              }`}\n              onClick={() => onSectionChange(section.id)}\n            >\n              <Icon width={24} height={24} className=\"shrink-0\" />\n              <p\n                className=\"text-sm font-medium truncate\"\n                title={t(section.labelKey)}\n              >\n                {t(section.labelKey)}\n              </p>\n            </div>\n          );\n        })}\n      </div>\n    </div>\n  );\n};\n"
  },
  {
    "path": "src/components/footer/Footer.tsx",
    "content": "import React, { useState, useEffect } from \"react\";\nimport { getVersion } from \"@tauri-apps/api/app\";\n\nimport ModelSelector from \"../model-selector\";\nimport UpdateChecker from \"../update-checker\";\n\nconst Footer: React.FC = () => {\n  const [version, setVersion] = useState(\"\");\n\n  useEffect(() => {\n    const fetchVersion = async () => {\n      try {\n        const appVersion = await getVersion();\n        setVersion(appVersion);\n      } catch (error) {\n        console.error(\"Failed to get app version:\", error);\n        setVersion(\"0.1.2\");\n      }\n    };\n\n    fetchVersion();\n  }, []);\n\n  return (\n    <div className=\"w-full border-t border-mid-gray/20 pt-3\">\n      <div className=\"flex justify-between items-center text-xs px-4 pb-3 text-text/60\">\n        <div className=\"flex items-center gap-4\">\n          <ModelSelector />\n        </div>\n\n        {/* Update Status */}\n        <div className=\"flex items-center gap-1\">\n          <UpdateChecker />\n          <span>•</span>\n          {/* eslint-disable-next-line i18next/no-literal-string */}\n          <span>v{version}</span>\n        </div>\n      </div>\n    </div>\n  );\n};\n\nexport default Footer;\n"
  },
  {
    "path": "src/components/footer/index.ts",
    "content": "export { default } from \"./Footer\";\n"
  },
  {
    "path": "src/components/icons/CancelIcon.tsx",
    "content": "import React from \"react\";\n\ninterface CancelIconProps {\n  width?: number;\n  height?: number;\n  color?: string;\n  className?: string;\n}\n\nconst CancelIcon: React.FC<CancelIconProps> = ({\n  width = 24,\n  height = 24,\n  color = \"#FAA2CA\",\n  className = \"\",\n}) => {\n  return (\n    <svg\n      width={width}\n      height={height}\n      viewBox=\"0 0 24 24\"\n      fill=\"none\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      className={className}\n    >\n      <g fill={color}>\n        <path d=\"m14.293 8.29297c.3905-.39052 1.0235-.39052 1.414 0s.3905 1.02354 0 1.41406l-5.99998 5.99997c-.39053.3906-1.02354.3906-1.41407 0-.39052-.3905-.39052-1.0235 0-1.414z\" />\n        <path d=\"m8.29295 8.29297c.39053-.39052 1.02354-.39052 1.41407 0l5.99998 6.00003c.3905.3905.3905 1.0235 0 1.414-.3905.3906-1.0235.3906-1.414 0l-6.00005-5.99997c-.39052-.39052-.39052-1.02354 0-1.41406z\" />\n        <path\n          d=\"m20 12c0-4.41828-3.5817-8-8-8-4.41828 0-8 3.58172-8 8 0 4.4183 3.58172 8 8 8 4.4183 0 8-3.5817 8-8zm2 0c0 5.5228-4.4772 10-10 10-5.52285 0-10-4.4772-10-10 0-5.52285 4.47715-10 10-10 5.5228 0 10 4.47715 10 10z\"\n          opacity=\".4\"\n        />\n      </g>\n    </svg>\n  );\n};\n\nexport default CancelIcon;\n"
  },
  {
    "path": "src/components/icons/HandyHand.tsx",
    "content": "const HandyHand = ({\n  width,\n  height,\n}: {\n  width?: number | string;\n  height?: number | string;\n}) => (\n  <svg\n    width={width || 126}\n    height={height || 135}\n    viewBox=\"0 0 126 135\"\n    className=\"fill-text stroke-text\"\n    xmlns=\"http://www.w3.org/2000/svg\"\n  >\n    <path\n      fillRule=\"evenodd\"\n      clipRule=\"evenodd\"\n      d=\"M59.037 128.521c-1.917-.168-4.593-.512-5.947-.766a83 83 0 0 1-5.164-1.192c-1.486-.402-3.495-1.032-4.465-1.4-.97-.369-2.695-1.149-3.833-1.734s-2.7-1.562-3.472-2.171-1.995-1.859-2.72-2.779c-.723-.92-1.547-2.199-1.831-2.842s-.621-1.904-.75-2.802c-.189-1.316-.173-1.972.083-3.384.214-1.18.57-2.28 1.093-3.37.54-1.129.74-1.756.66-2.07-.063-.248-.714-1.021-1.447-1.718s-2.017-2.176-2.853-3.286c-1.112-1.477-2.206-3.376-4.082-7.088-1.41-2.789-3.192-6.138-3.96-7.444a108 108 0 0 0-3.107-4.883c-.94-1.38-2.437-3.498-3.327-4.707A98 98 0 0 1 10.78 70.3c-1.033-1.626-1.784-3.122-2.36-4.696-.464-1.27-.914-2.818-1-3.44a27 27 0 0 1-.194-2.486c-.022-.84.093-1.859.301-2.676.185-.726.601-1.911.925-2.635.323-.723 1.077-1.904 1.675-2.623s1.627-1.727 2.286-2.24c.659-.51 1.698-1.185 2.308-1.497s1.694-.766 2.408-1.008c.714-.243 1.997-.524 2.85-.625.852-.101 2.266-.102 3.14-.001s2.157.365 2.85.588c.692.223 2.103.948 3.136 1.612s2.41 1.76 3.06 2.435c.651.676 1.578 1.803 2.06 2.505a70 70 0 0 1 2.028 3.22c.904 1.528 1.255 1.955 1.641 2 .449.05.497-.016.546-.743.029-.439-.417-2.99-.992-5.668-.574-2.678-1.587-6.822-2.252-9.21s-1.764-6.297-2.442-8.687c-.68-2.39-1.555-5.8-1.946-7.578s-.814-4.186-.94-5.35c-.177-1.641-.16-2.707.073-4.74.215-1.869.448-3.002.81-3.94.279-.723.778-1.736 1.11-2.251s1.044-1.4 1.582-1.964c.54-.566 1.466-1.33 2.06-1.7.593-.368 1.56-.854 2.148-1.08a20 20 0 0 1 2.245-.676c.646-.147 1.833-.293 2.636-.325.817-.033 2.418.1 3.632.299 1.505.247 2.536.533 3.357.933.652.317 1.68.983 2.284 1.48.603.496 1.43 1.33 1.836 1.853.406.524 1.085 1.641 1.51 2.485.424.843 1.012 2.359 1.307 3.368.294 1.01.712 2.988.928 4.396s.524 4.118.684 6.024.441 5.016.624 6.911c.182 1.895.51 4.689.727 6.207.218 1.519.582 3.48.809 4.358.227.879.636 1.899.908 2.267.371.502.672.69 1.195.75.567.066.841-.051 1.453-.62.415-.385.955-1.115 1.2-1.622s.707-1.616 1.025-2.466c.32-.849 1-3.215 1.513-5.257a451 451 0 0 0 1.766-7.339c.458-1.994 1.134-4.677 1.501-5.963.368-1.285.941-3.075 1.274-3.977s.953-2.307 1.378-3.122c.424-.816 1.281-2.083 1.904-2.817.624-.734 1.582-1.662 2.13-2.062s1.589-1.02 2.313-1.378 1.914-.763 2.645-.9c.73-.137 2.174-.255 3.21-.264 1.412-.01 2.274.088 3.456.396.867.226 2.18.702 2.917 1.057.737.356 1.802 1 2.366 1.432s1.47 1.336 2.015 2.008c.545.673 1.22 1.812 1.503 2.533s.59 1.75.683 2.289c.093.538.175 1.7.182 2.583.008 1.007-.148 2.412-.42 3.774a57 57 0 0 1-1.1 4.381c-.367 1.217-1.3 3.871-2.072 5.898s-2.074 5.416-2.892 7.531c-.818 2.116-1.9 5.152-2.403 6.748-.576 1.825-.988 3.539-1.113 4.623-.17 1.485-.148 1.78.167 2.139.355.404.383.407 1.104.12.406-.162 1.18-.76 1.718-1.327s1.466-1.688 2.063-2.49 1.867-2.547 2.823-3.875c.955-1.329 2.203-2.939 2.773-3.578s1.49-1.568 2.045-2.065a24 24 0 0 1 2.124-1.656c.614-.414 1.871-1.024 2.794-1.356.933-.336 2.385-.665 3.269-.74 1.304-.111 1.852-.066 3.042.253.798.214 1.939.627 2.536.917s1.501.882 2.008 1.315a15.6 15.6 0 0 1 1.696 1.755 14 14 0 0 1 1.341 2.119c.314.634.694 1.673.846 2.31.179.75.229 1.846.142 3.122-.096 1.398-.269 2.326-.601 3.219-.257.69-.964 2.026-1.571 2.968s-1.851 2.502-2.765 3.463a201 201 0 0 1-4.115 4.154c-1.35 1.323-2.931 2.968-3.513 3.656s-1.35 1.855-1.706 2.594-.89 2.367-1.186 3.62-.825 4.327-1.174 6.832-.823 5.392-1.053 6.416a81 81 0 0 1-.95 3.713c-.292 1.018-.959 2.882-1.483 4.144-.525 1.26-1.473 3.213-2.108 4.338s-1.701 2.749-2.37 3.608c-.667.86-1.928 2.257-2.8 3.106s-1.8 1.83-2.06 2.18c-.476.636-.476.636-.04 1.771.24.623.515 1.575.612 2.114.097.542.083 1.789-.032 2.792-.115.997-.405 2.33-.646 2.962-.24.631-.84 1.781-1.334 2.554-.689 1.078-1.34 1.753-2.795 2.897-1.16.912-2.566 1.791-3.616 2.26a37 37 0 0 1-3.67 1.356c-1.072.324-3.068.761-4.436.972a59 59 0 0 1-4.993.508c-1.378.069-3.817.107-5.419.086s-4.48-.176-6.397-.343Zm.715-2.726c2.899.29 6.13.53 7.18.533 1.051.003 3.193-.123 4.76-.279s3.803-.48 4.97-.719 2.904-.697 3.862-1.019 2.308-.882 3-1.246 1.872-1.17 2.621-1.792c.749-.621 1.65-1.568 2.004-2.103.353-.535.834-1.469 1.069-2.076.234-.606.478-1.553.54-2.104.063-.55.06-1.343-.006-1.762s-.323-1.068-.57-1.441c-.385-.581-.556-.679-1.172-.673-.398.004-1.905.453-3.35.998-1.447.545-3.695 1.205-4.997 1.467a82 82 0 0 1-4.309.733 61 61 0 0 1-3.864.363c-1.057.058-3.362.088-5.12.065-2.526-.031-4.264-.19-8.262-.754-2.785-.393-5.723-.882-6.529-1.088s-2.544-.728-3.861-1.159-3.313-1.187-4.434-1.678-3.19-1.496-4.6-2.233c-1.66-.869-2.728-1.319-3.036-1.278-.31.04-.641.347-.958.885-.265.453-.597 1.339-.736 1.97-.139.63-.215 1.694-.17 2.363.046.669.285 1.785.532 2.48.25.702.902 1.84 1.466 2.558.56.712 1.497 1.707 2.083 2.212a20 20 0 0 0 2.227 1.629c.639.391 1.923 1.027 2.854 1.415.93.387 2.538.976 3.57 1.309 1.034.333 2.761.805 3.84 1.049s2.447.534 3.043.645 3.455.44 6.353.73Zm-.352-14.434c1.472.183 4.027.396 5.676.473s3.883.109 4.963.071c1.08-.037 3.05-.17 4.377-.294 1.328-.125 3.288-.41 4.355-.633a65 65 0 0 0 4.128-1.05c1.202-.355 2.582-.841 3.067-1.08.484-.24 1.849-1.14 3.031-1.999s2.732-2.144 3.443-2.853c.71-.708 1.832-2.028 2.491-2.932.66-.903 1.63-2.447 2.156-3.43.526-.982 1.384-2.92 1.907-4.306.522-1.386 1.205-3.442 1.517-4.568.375-1.353.882-4.277 1.496-8.63.676-4.787 1.071-7.02 1.448-8.18a23 23 0 0 1 1.272-3.019c.414-.784 1.3-2.056 1.969-2.826.668-.771 2.641-2.786 4.384-4.477s3.78-3.837 4.527-4.767c.843-1.051 1.623-2.294 2.06-3.282.602-1.36.723-1.862.836-3.456.114-1.604.064-2.058-.36-3.243a7.9 7.9 0 0 0-1.465-2.498c-.589-.676-1.405-1.342-2.065-1.682-.6-.31-1.639-.726-2.308-.924-.913-.27-1.576-.34-2.664-.28-.824.046-1.979.29-2.682.566-.734.29-1.847 1-2.75 1.755-.835.698-2.205 2.089-3.046 3.091-.842 1.002-2.306 2.91-3.256 4.239s-2.209 3.028-2.799 3.775-1.685 1.88-2.434 2.517-1.737 1.28-2.197 1.43c-.46.148-1.174.233-1.587.188s-1.03-.237-1.368-.427c-.34-.19-.812-.64-1.05-1a6 6 0 0 1-.64-1.357c-.143-.489-.117-1.404.083-2.985.23-1.813.514-2.94 1.39-5.508.607-1.776 1.933-5.29 2.947-7.808a471 471 0 0 0 2.974-7.568c.62-1.644 1.431-4.026 1.801-5.295.426-1.459.774-3.192.948-4.719.157-1.376.21-2.852.124-3.435a11.6 11.6 0 0 0-.502-1.96c-.193-.515-.811-1.516-1.374-2.225-.634-.8-1.473-1.567-2.207-2.02a17 17 0 0 0-2.462-1.204c-.935-.348-1.882-.523-3.545-.652-2.026-.159-2.444-.126-3.917.309-1.27.375-2.014.76-3.237 1.681-1.182.89-1.808 1.542-2.45 2.552-.475.747-1.123 1.965-1.44 2.71-.316.743-.866 2.354-1.221 3.58a126 126 0 0 0-1.256 4.774c-.335 1.4-.995 4.311-1.466 6.47s-1.167 5.004-1.545 6.323c-.379 1.319-.945 3.064-1.26 3.878-.314.814-.91 2.042-1.325 2.73-.415.686-1.058 1.54-1.428 1.897s-1.11.842-1.645 1.078c-.697.307-1.233.395-1.893.309-.513-.067-1.248-.34-1.658-.614-.405-.271-1.027-.874-1.384-1.34-.356-.465-.887-1.573-1.18-2.462s-.714-2.794-.937-4.232-.557-4.442-.742-6.676-.499-5.839-.697-8.011-.547-5.064-.774-6.426c-.228-1.361-.749-3.451-1.159-4.643-.618-1.801-.96-2.437-2.022-3.766-1.078-1.35-1.5-1.713-2.702-2.327-1.063-.542-1.908-.809-3.325-1.048-1.368-.23-2.328-.275-3.43-.16-.843.089-2.272.42-3.178.738-.905.318-2.163.946-2.794 1.396a7.2 7.2 0 0 0-1.88 1.987c-.402.642-.911 1.799-1.131 2.57-.255.892-.439 2.17-.506 3.516-.064 1.274.024 3.046.22 4.463.18 1.293.773 4.092 1.32 6.222.545 2.13 1.474 5.557 2.064 7.616.59 2.06 1.51 5.424 2.046 7.477s1.408 5.802 1.938 8.331 1.138 5.84 1.35 7.357c.277 1.995.335 3.185.207 4.295-.097.845-.373 2.046-.614 2.668s-.779 1.625-1.194 2.228c-.416.602-1.156 1.456-1.645 1.897s-1.192.98-1.563 1.197c-.37.216-.878.43-1.126.475-.316.058-.563-.058-.821-.383-.26-.328-.315-.59-.183-.886.102-.231.668-.865 1.257-1.407s1.309-1.3 1.6-1.685c.372-.493.52-.894.498-1.36-.017-.362-.603-1.806-1.303-3.208s-1.8-3.51-2.448-4.683-1.806-2.949-2.577-3.944-1.89-2.205-2.488-2.688-1.566-1.118-2.151-1.41c-.686-.343-1.745-.643-2.978-.843-1.357-.22-2.363-.263-3.46-.148-.851.09-2.306.439-3.233.777a13.4 13.4 0 0 0-3.12 1.677c-.788.584-1.73 1.403-2.093 1.819s-.907 1.283-1.21 1.927c-.372.791-.607 1.673-.725 2.717-.096.85-.112 2.05-.036 2.667s.296 1.645.489 2.284.63 1.795.973 2.57 1.131 2.186 1.752 3.137c.62.95 2.34 3.394 3.821 5.43 1.48 2.036 3.31 4.642 4.066 5.793a75 75 0 0 1 2.588 4.314c.668 1.223 2.177 4.141 3.352 6.485 1.733 3.456 2.488 4.716 3.995 6.666 1.022 1.322 2.501 3.017 3.288 3.766s2.283 1.899 3.327 2.556a62 62 0 0 0 3.605 2.083c.94.489 2.614 1.274 3.722 1.746 1.108.471 2.624 1.058 3.37 1.305s2.238.66 3.314.917 2.665.615 3.53.795 2.776.478 4.249.662Z\"\n      strokeWidth=\"6\"\n    />\n  </svg>\n);\n\nexport default HandyHand;\n"
  },
  {
    "path": "src/components/icons/HandyTextLogo.tsx",
    "content": "import React from \"react\";\n\nconst HandyTextLogo = ({\n  width,\n  height,\n  className,\n}: {\n  width?: number;\n  height?: number;\n  className?: string;\n}) => {\n  return (\n    <svg\n      width={width}\n      height={height}\n      className={className}\n      viewBox=\"0 0 930 328\"\n      fill=\"none\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n    >\n      <path\n        fillRule=\"evenodd\"\n        clipRule=\"evenodd\"\n        d=\"M160.305 286.202c-2.75.244-7.301.106-10.113-.306s-7.326-1.484-10.031-2.383c-2.706-.898-6.564-2.99-8.575-4.647s-4.806-5.446-6.213-8.418c-1.406-2.973-3.292-8.251-4.19-11.729-1.309-5.067-1.712-13.081-2.026-40.324-.216-18.7-.643-35.543-.951-37.429-.329-2.022-1.921-4.791-3.88-6.75-2.377-2.377-4.23-3.321-6.521-3.321-2.289 0-4.142.943-6.51 3.311-1.891 1.891-3.825 5.214-4.51 7.75-.778 2.875-1.014 8.491-.67 15.939.29 6.325 1.068 16.091 1.726 21.703.915 7.801.927 13.254.05 23.169-.63 7.133-1.82 15.234-2.642 18.004s-2.962 6.959-4.754 9.309c-1.793 2.35-5.269 5.512-7.725 7.027s-7.475 3.495-11.154 4.4c-4.566 1.123-9.33 1.507-15.012 1.208-6.786-.356-9.313-.975-13.675-3.349-2.944-1.602-6.872-4.768-8.73-7.034-1.857-2.267-4.66-7.005-6.23-10.529s-3.576-9.108-4.46-12.408-2.284-10.725-3.114-16.5c-.829-5.775-1.978-18.15-2.554-27.5s-1.55-31.85-2.167-50-.854-41.55-.528-52c.327-10.45 1.52-27.1 2.65-37 1.132-9.9 2.94-21.55 4.02-25.89s3.125-9.872 4.543-12.293c1.42-2.42 4.433-5.744 6.7-7.385 2.265-1.641 6.581-3.7 9.59-4.575 3.794-1.103 7.957-1.454 13.565-1.145 6.135.339 9.322 1.077 13.181 3.053 2.8 1.434 6.457 4.164 8.128 6.067s4.243 6.006 5.715 9.118 3.522 8.964 4.556 13.007 2.36 11.65 2.945 16.907c.587 5.256 1.066 15.425 1.066 22.597 0 11.814.193 13.232 2.051 15.09 1.388 1.388 2.722 1.839 4.125 1.393 1.14-.361 5.504-2.42 9.698-4.576s10.326-4.405 13.626-5 8.539-.82 11.641-.501 8.181 1.53 11.285 2.691c3.646 1.365 7.781 4.091 11.686 7.707 3.324 3.077 7.985 8.326 10.359 11.665s6.592 10.345 9.374 15.57 6.441 13.738 8.132 18.918 4.168 15.344 5.506 22.588 3.352 23.007 4.475 35.03 2.039 25.934 2.035 30.912c-.005 4.979-.47 11.977-1.034 15.552s-1.926 9.25-3.027 12.612-3.61 7.989-5.575 10.284c-1.964 2.295-5.309 5.059-7.431 6.142-2.123 1.083-6.012 2.387-8.643 2.898s-7.033 1.128-9.783 1.371\"\n        className=\"logo-primary\"\n      />\n      <path\n        fillRule=\"evenodd\"\n        clipRule=\"evenodd\"\n        d=\"M161.628 301.144c3.262-.289 8.177-.978 11.32-1.588 3.968-.771 9.189-2.521 12.599-4.261 3.988-2.035 8.837-6.043 12.011-9.75 3.296-3.851 6.743-10.211 8.433-15.368 1.352-4.127 2.902-10.582 3.59-14.944.697-4.414 1.212-12.17 1.217-17.877.004-5.576-.936-19.857-2.1-32.32-1.161-12.432-3.228-28.612-4.659-36.359-1.451-7.857-4.083-18.654-5.998-24.519-1.966-6.024-5.969-15.335-9.151-21.312-3.063-5.754-7.602-13.294-10.389-17.213-2.986-4.2-8.332-10.22-12.393-13.98-5.243-4.855-10.982-8.639-16.621-10.749-4.284-1.603-10.611-3.112-15.007-3.564-4.528-.465-11.176-.179-15.836.661-3.27.59-7.69 2.008-11.881 3.714-.134-7.049-.574-15.257-1.116-20.119-.659-5.914-2.103-14.2-3.321-18.961-1.261-4.93-3.625-11.682-5.529-15.705-2.03-4.293-5.33-9.556-8.002-12.6-2.994-3.41-8.163-7.268-12.563-9.521C70.314 1.779 65.122.576 57.04.13c-7.18-.396-12.991.094-18.577 1.718-4.62 1.343-10.5 4.147-14.204 6.831-3.949 2.86-8.373 7.738-10.84 11.95-2.244 3.827-4.76 10.636-6.159 16.253-1.275 5.126-3.167 17.312-4.366 27.81C1.715 75.009.494 92.07.154 102.927c-.339 10.83-.099 34.53.528 52.979.621 18.275 1.602 40.897 2.188 50.411.598 9.723 1.783 22.479 2.678 28.711.907 6.314 2.41 14.282 3.472 18.247 1.094 4.085 3.366 10.408 5.247 14.631 2.082 4.677 5.558 10.551 8.33 13.934 3.13 3.818 8.572 8.204 13.16 10.702 6.51 3.543 11.186 4.688 20.06 5.154 7.088.372 13.313-.129 19.383-1.623 5.106-1.256 11.559-3.801 15.445-6.199 4.03-2.485 8.877-6.894 11.777-10.697 2.219-2.909 4.488-7.018 6.03-10.818 1.05 3.078 2.275 6.206 3.362 8.504 2.301 4.864 6.405 10.425 10.231 13.579 3.472 2.861 8.966 5.839 13.388 7.308 3.536 1.174 8.905 2.449 12.586 2.989 3.96.58 9.684.753 13.609.406m-11.436-15.248c2.812.412 7.363.55 10.113.306 2.75-.243 7.153-.86 9.783-1.371s6.52-1.815 8.643-2.898c2.122-1.083 5.467-3.847 7.431-6.142s4.473-6.923 5.575-10.285c1.101-3.361 2.463-9.036 3.028-12.611.564-3.575 1.028-10.573 1.033-15.552s-.912-18.889-2.035-30.912-3.137-27.786-4.475-35.03-3.816-17.409-5.506-22.588-5.35-13.693-8.132-18.918-7-12.232-9.374-15.57-7.035-8.588-10.359-11.666c-3.904-3.615-8.04-6.341-11.686-7.706-3.104-1.161-8.182-2.372-11.285-2.691-3.102-.32-8.341-.094-11.641.501s-9.431 2.845-13.625 5-8.559 4.215-9.7 4.576c-1.402.446-2.736-.005-4.124-1.393-1.858-1.858-2.05-3.276-2.05-15.09 0-7.172-.48-17.34-1.067-22.597-.586-5.257-1.911-12.865-2.945-16.907-1.034-4.043-3.084-9.896-4.556-13.007-1.472-3.112-4.044-7.215-5.715-9.118-1.67-1.903-5.328-4.633-8.128-6.067-3.86-1.976-7.046-2.714-13.181-3.053-5.608-.309-9.771.042-13.564 1.145-3.01.875-7.326 2.934-9.592 4.575-2.265 1.641-5.28 4.964-6.698 7.385-1.42 2.421-3.463 7.953-4.543 12.293s-2.89 15.99-4.02 25.89c-1.131 9.9-2.324 26.55-2.65 37s-.09 33.85.527 52 1.592 40.65 2.167 50c.576 9.35 1.725 21.725 2.554 27.5.83 5.775 2.231 13.2 3.115 16.5.883 3.3 2.89 8.884 4.46 12.408s4.372 8.262 6.23 10.529c1.857 2.266 5.785 5.432 8.729 7.034 4.362 2.374 6.89 2.993 13.675 3.349 5.682.299 10.446-.085 15.012-1.208 3.68-.905 8.699-2.885 11.155-4.4s5.932-4.677 7.724-7.027 3.932-6.539 4.754-9.309c.823-2.77 2.012-10.872 2.642-18.004.877-9.915.865-15.368-.05-23.169-.658-5.612-1.435-15.378-1.727-21.703-.343-7.448-.107-13.064.67-15.939.686-2.536 2.62-5.859 4.511-7.75 2.368-2.368 4.221-3.311 6.51-3.311 2.291 0 4.144.944 6.521 3.321 1.959 1.959 3.551 4.728 3.88 6.75.308 1.886.736 18.729.951 37.429.314 27.243.717 35.257 2.026 40.324.898 3.478 2.784 8.756 4.19 11.729s4.202 6.761 6.213 8.418 5.869 3.749 8.575 4.647 7.219 1.971 10.031 2.383\"\n        className=\"logo-stroke\"\n      />\n      <path\n        fillRule=\"evenodd\"\n        clipRule=\"evenodd\"\n        d=\"M342.305 282.303c-2.75.267-6.794.045-8.987-.494-2.193-.54-5.343-1.913-7-3.051s-4.532-4.054-6.389-6.479-4.518-4.696-5.914-5.046c-1.9-.477-4.79.568-11.519 4.163-4.94 2.64-11.729 5.662-15.087 6.716-3.357 1.054-10.086 2.18-14.953 2.504-5.317.353-11.481.103-15.438-.626-3.623-.667-9.063-2.385-12.089-3.816-3.249-1.537-8.042-5.181-11.706-8.9-4.635-4.704-7.668-9.153-11.989-17.588-3.182-6.21-6.81-14.603-8.063-18.652-1.254-4.048-3.048-11.923-3.988-17.5-.954-5.657-1.709-15.443-1.709-22.139 0-6.766.757-16.491 1.735-22.297.955-5.663 2.637-13.088 3.738-16.5 1.102-3.411 3.742-9.83 5.867-14.262 2.594-5.411 6.067-10.439 10.566-15.298 3.686-3.982 9.521-9.251 12.967-11.71s9.121-5.756 12.612-7.327 9.946-3.699 14.346-4.73c5.526-1.296 11.711-1.87 20-1.854 8.977.016 14.461.602 21.768 2.326 5.372 1.267 12.878 3.86 16.68 5.762s9.234 5.546 12.073 8.098c2.838 2.553 6.709 6.925 8.601 9.716 1.892 2.792 4.312 8.001 5.379 11.576s3.071 13.7 4.454 22.5c1.384 8.8 3.441 23.65 4.571 33 1.131 9.35 2.919 21.95 3.975 28s2.636 15.159 3.512 20.243c1.248 7.248 1.364 10.471.539 14.935-.579 3.13-1.973 7.594-3.099 9.919-1.125 2.325-3.841 6.267-6.035 8.759s-6.335 5.664-9.203 7.049c-2.868 1.384-7.465 2.735-10.215 3.003m-54.101-61.699c4.787-.29 6.269-.815 7.865-2.783 1.678-2.07 1.865-3.306 1.275-8.426-.38-3.3-.994-15.378-1.365-26.839-.666-20.602-.702-20.863-3.151-22.845-1.814-1.467-3.377-1.858-5.843-1.458-1.851.301-4.889 1.708-6.75 3.128-1.862 1.42-4.482 4.479-5.822 6.798s-3.15 6.66-4.022 9.648c-.908 3.11-1.581 9.093-1.574 14 .007 4.712.638 10.919 1.403 13.793s2.337 6.756 3.494 8.628 3.563 4.148 5.347 5.059c2.259 1.152 5.036 1.546 9.143 1.297\"\n        className=\"logo-primary\"\n      />\n      <path\n        fillRule=\"evenodd\"\n        clipRule=\"evenodd\"\n        d=\"M343.758 297.232c4.578-.445 10.875-2.297 15.282-4.423 4.692-2.265 10.439-6.666 13.942-10.647 3.042-3.455 6.519-8.502 8.277-12.134 1.745-3.603 3.537-9.34 4.348-13.729 1.194-6.456 1.017-11.359-.507-20.207-.877-5.091-2.459-14.21-3.517-20.276-1.005-5.759-2.759-18.111-3.86-27.221-1.152-9.525-3.233-24.551-4.645-33.53-1.481-9.42-3.589-20.07-4.898-24.458-1.494-5.007-4.562-11.609-7.336-15.703-2.597-3.831-7.273-9.114-10.989-12.455-3.867-3.477-10.418-7.87-15.391-10.359-4.917-2.46-13.506-5.427-19.947-6.946-8.481-2.002-15.104-2.71-25.185-2.727-9.366-.017-16.669.66-23.45 2.25-5.302 1.242-12.669 3.671-17.078 5.655-4.414 1.986-10.941 5.778-15.17 8.796-4.335 3.093-10.918 9.038-15.261 13.729-5.495 5.935-9.825 12.204-13.085 19.004-2.397 5.002-5.288 12.028-6.615 16.138-1.356 4.2-3.19 12.295-4.255 18.617-1.123 6.66-1.944 17.214-1.944 24.789 0 7.502.819 18.117 1.917 24.632 1.044 6.195 2.984 14.707 4.451 19.443 1.547 4.998 5.498 14.138 9.042 21.056 4.967 9.696 8.836 15.372 14.654 21.276 4.809 4.88 10.964 9.56 15.976 11.931 4.231 2.002 10.914 4.111 15.786 5.009 5.278.973 12.666 1.272 19.15.841 5.977-.397 13.853-1.716 18.452-3.16 4.369-1.371 11.966-4.753 17.663-7.797q.403-.216.78-.414c2.395 2.706 5.163 5.318 7.478 6.909 3.195 2.195 8.011 4.294 11.912 5.253 3.932.967 9.663 1.283 14.023.858m-10.44-15.423c2.193.539 6.237.761 8.987.494 2.75-.268 7.347-1.619 10.215-3.003s7.009-4.556 9.203-7.049c2.194-2.492 4.91-6.434 6.035-8.759s2.52-6.789 3.099-9.919c.825-4.464.709-7.687-.539-14.935-.876-5.084-2.456-14.193-3.512-20.243s-2.844-18.65-3.975-28c-1.13-9.35-3.187-24.2-4.571-33s-3.388-18.925-4.454-22.5c-1.067-3.575-3.487-8.784-5.379-11.576s-5.763-7.163-8.601-9.716-8.272-6.196-12.073-8.098-11.308-4.495-16.68-5.762c-7.307-1.724-12.791-2.31-21.768-2.326-8.289-.015-14.474.558-20 1.853-4.4 1.032-10.856 3.161-14.346 4.731-3.491 1.571-9.166 4.868-12.612 7.327s-9.281 7.728-12.967 11.71c-4.499 4.859-7.972 9.887-10.566 15.298-2.125 4.432-4.765 10.851-5.867 14.262-1.101 3.412-2.783 10.837-3.738 16.5-.978 5.806-1.735 15.531-1.735 22.297 0 6.696.755 16.482 1.709 22.139.94 5.577 2.734 13.452 3.988 17.5 1.253 4.049 4.881 12.442 8.063 18.652 4.321 8.435 7.354 12.884 11.989 17.588 3.664 3.719 8.457 7.363 11.706 8.9 3.026 1.431 8.466 3.149 12.089 3.816 3.957.729 10.121.979 15.438.626 4.867-.324 11.596-1.45 14.953-2.504 3.358-1.054 10.147-4.076 15.087-6.716 6.729-3.595 9.619-4.64 11.519-4.163 1.396.35 4.057 2.621 5.914 5.046s4.732 5.34 6.389 6.479c1.657 1.138 4.807 2.511 7 3.051m-37.249-63.988c-1.596 1.968-3.078 2.493-7.865 2.783-4.107.249-6.884-.145-9.143-1.297-1.784-.911-4.19-3.187-5.347-5.059s-2.729-5.754-3.494-8.628-1.396-9.081-1.403-13.793c-.007-4.907.666-10.89 1.574-14 .872-2.988 2.682-7.329 4.022-9.648s3.96-5.378 5.822-6.798c1.861-1.42 4.899-2.827 6.75-3.128 2.466-.4 4.029-.009 5.843 1.458 2.449 1.982 2.485 2.243 3.151 22.845.371 11.461.985 23.539 1.365 26.839.59 5.12.403 6.356-1.275 8.426\"\n        className=\"logo-stroke\"\n      />\n      <path\n        fillRule=\"evenodd\"\n        clipRule=\"evenodd\"\n        d=\"M425.018 287.825c-3.692-.039-8.395-.688-10.45-1.444-2.056-.755-5.754-2.76-8.218-4.455s-6.549-5.981-9.077-9.524-5.475-8.941-6.549-11.997c-1.074-3.057-2.375-8.36-2.891-11.784-.515-3.424-1.456-20.176-2.09-37.226-.635-17.05-.906-41.8-.603-55 .348-15.203 1.117-26.997 2.098-32.178.851-4.497 2.618-11.127 3.927-14.731 1.308-3.605 3.874-8.502 5.701-10.882s5.201-5.77 7.499-7.534c2.298-1.763 6.374-3.965 9.059-4.893 2.741-.947 7.95-1.692 11.881-1.7 4.423-.008 8.636.644 11.444 1.772 2.444.982 6.124 3.116 8.177 4.742s5.795 6.087 8.316 9.914c3.209 4.87 5.396 7.161 7.29 7.636 2.121.533 4.657-.375 11.739-4.205 4.969-2.686 11.959-5.659 15.534-6.605s9.251-1.746 12.614-1.778c3.362-.032 8.535.401 11.495.963 2.961.561 8.14 2.311 11.51 3.888s7.872 4.45 10.005 6.385c2.132 1.934 5.335 5.937 7.117 8.895s4.546 8.963 6.141 13.345c1.595 4.381 3.652 12.016 4.57 16.966 1.187 6.393 1.844 17.402 2.267 38 .377 18.325.158 35.626-.594 47-.654 9.9-1.846 21.359-2.648 25.464-.802 4.106-2.153 9.555-3.001 12.11-.849 2.554-3.074 6.434-4.946 8.621-2.103 2.457-5.815 5.062-9.717 6.822-4.765 2.148-8.316 2.954-14.484 3.286-6.395.344-9.686-.02-15.135-1.675-5.101-1.55-8.286-3.304-11.906-6.559-2.718-2.444-6.393-7.165-8.166-10.491s-3.921-9.317-4.773-13.313c-1.104-5.174-1.379-9.999-.958-16.765.326-5.225 1.721-16.371 3.101-24.768s2.508-17.975 2.508-21.282c0-3.308-.613-7.462-1.362-9.232s-2.315-4.067-3.479-5.104c-1.344-1.198-3.615-1.887-6.217-1.887-2.486 0-5.151.768-6.771 1.951-1.469 1.073-3.546 3.715-4.615 5.871-1.739 3.505-1.963 6.357-2.113 26.936-.093 12.658-.424 28.865-.735 36.015-.401 9.193-1.196 15.014-2.717 19.877-1.681 5.379-3.18 7.908-6.879 11.607-3.084 3.083-6.724 5.476-10.458 6.873-3.891 1.455-7.883 2.121-12.441 2.073\"\n        className=\"logo-primary\"\n      />\n      <path\n        fillRule=\"evenodd\"\n        clipRule=\"evenodd\"\n        d=\"M424.86 302.824c6.365.067 12.191-.904 17.855-3.023 5.792-2.167 11.241-5.749 15.808-10.315 4.99-4.99 7.717-9.303 9.978-15.87a46 46 0 0 0 1.189 2.412c2.589 4.857 7.345 10.968 11.374 14.59 5.294 4.76 10.42 7.584 17.575 9.757 7.175 2.179 12.211 2.737 20.301 2.301 7.948-.428 13.31-1.644 19.844-4.59 5.79-2.611 11.293-6.474 14.946-10.742 3.114-3.638 6.314-9.216 7.786-13.647 1.076-3.239 2.573-9.279 3.488-13.962.955-4.892 2.2-16.857 2.893-27.35.785-11.866 1.009-29.575.624-48.298-.44-21.431-1.128-32.954-2.515-40.429-1.068-5.75-3.34-14.185-5.224-19.36-1.905-5.234-5.064-12.099-7.388-15.956-2.505-4.157-6.618-9.297-9.886-12.262-3.322-3.014-9.074-6.685-13.726-8.862-4.452-2.083-10.81-4.231-15.073-5.04-3.956-.75-10.11-1.265-14.432-1.224-4.642.043-11.556 1.018-16.31 2.277-4.863 1.287-12.899 4.704-18.83 7.91l-.286.155c-3.231-4.66-7.41-9.528-10.612-12.064-3.185-2.523-8.118-5.383-11.898-6.902-4.795-1.926-10.859-2.865-17.066-2.853-5.483.011-12.304.987-16.748 2.522-4.218 1.457-9.724 4.431-13.296 7.173-3.345 2.568-7.647 6.89-10.264 10.298-2.826 3.682-6.098 9.927-7.902 14.898-1.597 4.398-3.57 11.802-4.565 17.06-1.183 6.248-1.989 18.611-2.356 34.623-.311 13.522-.036 38.568.609 55.902.662 17.79 1.61 34.664 2.248 38.901.653 4.343 2.179 10.562 3.572 14.524 1.581 4.5 5.193 11.117 8.49 15.737 3.468 4.86 8.724 10.375 12.784 13.168 3.432 2.362 8.211 4.953 11.549 6.179 3.914 1.438 10.207 2.307 15.464 2.362m-10.292-16.443c2.055.756 6.758 1.405 10.45 1.444 4.558.048 8.55-.618 12.441-2.073 3.734-1.397 7.374-3.79 10.458-6.873 3.699-3.699 5.198-6.228 6.879-11.607 1.521-4.863 2.316-10.684 2.717-19.877.311-7.15.642-23.357.735-36.015.15-20.579.374-23.431 2.113-26.936 1.069-2.156 3.146-4.798 4.615-5.871 1.62-1.183 4.285-1.951 6.771-1.951 2.602 0 4.873.689 6.217 1.887 1.164 1.037 2.73 3.334 3.479 5.104s1.362 5.924 1.362 9.232-1.129 12.884-2.508 21.282-2.775 19.543-3.101 24.768c-.421 6.766-.145 11.591.958 16.765.853 3.996 3 9.987 4.773 13.313s5.448 8.047 8.166 10.491c3.62 3.255 6.805 5.009 11.906 6.559 5.449 1.655 8.74 2.019 15.135 1.675 6.169-.332 9.719-1.138 14.485-3.286 3.901-1.76 7.613-4.365 9.716-6.822 1.872-2.187 4.097-6.067 4.946-8.621s2.199-8.004 3.001-12.11 1.994-15.564 2.648-25.464c.752-11.374.971-28.675.594-47-.423-20.598-1.08-31.607-2.267-38-.918-4.95-2.975-12.585-4.57-16.966s-4.358-10.387-6.141-13.345c-1.782-2.958-4.985-6.961-7.117-8.895-2.133-1.935-6.634-4.808-10.005-6.385-3.37-1.577-8.549-3.327-11.51-3.888-2.96-.561-8.133-.995-11.495-.963-3.363.031-9.039.832-12.614 1.778s-10.565 3.919-15.534 6.605c-7.082 3.83-9.618 4.738-11.739 4.205-1.894-.475-4.081-2.766-7.29-7.636-2.521-3.827-6.263-8.288-8.316-9.914s-5.733-3.76-8.177-4.742c-2.808-1.128-7.021-1.78-11.444-1.772-3.931.008-9.139.753-11.881 1.7-2.685.928-6.761 3.13-9.059 4.893-2.298 1.764-5.672 5.155-7.499 7.534-1.827 2.38-4.393 7.277-5.701 10.882-1.309 3.604-3.076 10.234-3.927 14.731-.981 5.181-1.75 16.975-2.098 32.178-.303 13.2-.032 37.95.603 55 .634 17.05 1.575 33.802 2.09 37.226.516 3.424 1.817 8.727 2.891 11.784 1.074 3.056 4.022 8.455 6.549 11.997 2.528 3.543 6.612 7.828 9.077 9.524s6.162 3.7 8.218 4.455\"\n        className=\"logo-stroke\"\n      />\n      <path\n        fillRule=\"evenodd\"\n        clipRule=\"evenodd\"\n        d=\"M646.483 270.798c-2.298-.053-8.228-.725-13.178-1.494s-11.925-2.334-15.5-3.479-9.356-3.668-12.846-5.608-9.166-6.294-12.612-9.675-8.329-9.341-10.853-13.244-5.903-10.429-7.51-14.5c-1.607-4.072-3.642-10.328-4.522-13.903s-2.043-10.437-2.584-15.25a78.7 78.7 0 0 1 .01-17.5c.546-4.812 1.654-11.607 2.464-15.1.809-3.492 2.62-9.792 4.024-14 1.405-4.207 3.94-10.125 5.633-13.15s5.128-8.116 7.635-11.314c2.506-3.197 7.28-7.878 10.609-10.402s9.732-6.326 14.229-8.45c4.498-2.124 12.373-4.868 17.5-6.098 7.8-1.87 11.692-2.197 23.823-1.996 14.121.233 14.555.178 16.589-2.119 1.612-1.82 2.381-4.84 3.367-13.24.703-5.984 2.355-14.706 3.672-19.38 1.316-4.676 4.63-13.06 7.364-18.633 3.881-7.912 6.188-11.245 10.527-15.21 3.717-3.398 7.317-5.634 10.874-6.756 3.842-1.21 7.507-1.545 13.212-1.204 5.34.318 9.233 1.16 12.03 2.598 2.274 1.17 5.611 3.604 7.417 5.41 1.805 1.805 4.341 5.596 5.636 8.424s2.907 8.905 3.583 13.506c.676 4.6 1.218 16.464 1.205 26.364-.014 9.9-.592 45.675-1.286 79.5s-1.712 65.325-2.263 70-1.91 11.862-3.021 15.97-3.208 9.652-4.66 12.319-4.217 6.235-6.147 7.929c-1.929 1.694-6.003 4.224-9.053 5.621-4.009 1.838-7.772 2.659-13.582 2.966-7.322.386-8.701.139-15.5-2.772-5.466-2.34-8.4-3.054-10.964-2.668-1.925.29-6.246 1.419-9.602 2.508s-9.576 2.463-13.822 3.053c-4.246.591-9.6 1.031-11.898.977m3.98-67.903c1.967 0 4.361-.986 6.247-2.574 1.683-1.415 3.586-4.331 4.229-6.479.758-2.528.954-7.354.558-13.677-.337-5.373-1.298-12.046-2.134-14.829-.837-2.782-2.59-6.062-3.896-7.289s-3.955-2.487-5.887-2.801c-2.298-.373-4.778.031-7.177 1.17-2.3 1.091-4.637 3.395-6.271 6.184-1.895 3.235-2.741 6.353-3.106 11.454-.276 3.855.031 9.334.682 12.175s2.402 6.919 3.891 9.061 4.274 4.73 6.191 5.75 4.919 1.855 6.673 1.855\"\n        className=\"logo-primary\"\n      />\n      <path\n        fillRule=\"evenodd\"\n        clipRule=\"evenodd\"\n        d=\"M646.135 285.794c3.256.076 9.335-.423 14.312-1.116 5.079-.706 12.15-2.268 16.386-3.643a87 87 0 0 1 6.295-1.756c.803.244 2.026.706 3.737 1.438 8.905 3.813 12.574 4.469 22.194 3.962 7.586-.4 13.177-1.621 19.041-4.308 4.298-1.97 9.594-5.259 12.701-7.986 3.316-2.912 7.164-7.875 9.426-12.03 2.066-3.797 4.567-10.41 5.964-15.574 1.308-4.836 2.804-12.744 3.438-18.131.638-5.416 1.651-36.757 2.363-71.447.696-33.926 1.275-69.756 1.289-79.788.014-10.578-.557-23.069-1.364-28.565-.867-5.899-2.83-13.3-4.785-17.57-2.005-4.377-5.532-9.65-8.669-12.786-2.905-2.905-7.506-6.26-11.161-8.141-4.951-2.547-10.758-3.802-17.998-4.234-7.404-.442-12.851.055-18.616 1.873-5.822 1.835-11.257 5.21-16.484 9.988-5.89 5.383-9.23 10.21-13.874 19.677-3.113 6.345-6.756 15.564-8.336 21.173-1.542 5.478-3.34 14.966-4.13 21.697q-.14 1.186-.268 2.176a947 947 0 0 1-4.543-.066c-13.296-.22-18.394.207-27.569 2.408-6.115 1.467-14.965 4.55-20.408 7.12-5.398 2.55-12.69 6.88-16.885 10.061-4.348 3.296-10.046 8.883-13.353 13.102-2.966 3.784-6.832 9.513-8.919 13.241-2.184 3.903-5.084 10.674-6.771 15.727-1.547 4.635-3.487 11.384-4.409 15.364-.95 4.098-2.149 11.447-2.756 16.794a93.7 93.7 0 0 0-.011 20.866c.608 5.419 1.878 12.909 2.925 17.162 1.049 4.258 3.29 11.15 5.134 15.822 1.977 5.008 5.813 12.414 8.866 17.137 3.13 4.842 8.717 11.66 12.945 15.808 4.365 4.283 11.102 9.451 15.83 12.079 4.341 2.412 11.028 5.332 15.559 6.783 4.378 1.401 12.099 3.134 17.772 4.015 5.547.862 12.025 1.596 15.132 1.668m-12.83-16.49c4.95.769 10.88 1.441 13.178 1.494 2.298.054 7.652-.386 11.898-.977 4.246-.59 10.466-1.964 13.822-3.053s7.677-2.218 9.602-2.508c2.564-.386 5.498.328 10.964 2.668 6.799 2.911 8.178 3.158 15.5 2.772 5.81-.307 9.573-1.128 13.582-2.966 3.05-1.397 7.124-3.927 9.053-5.621s4.696-5.262 6.147-7.929 3.549-8.211 4.66-12.319 2.471-11.295 3.021-15.97c.551-4.675 1.569-36.175 2.263-70s1.272-69.6 1.286-79.5c.013-9.9-.529-21.764-1.205-26.364s-2.288-10.678-3.583-13.506-3.831-6.62-5.636-8.425-5.143-4.24-7.417-5.41c-2.797-1.438-6.69-2.279-12.03-2.597-5.705-.34-9.37-.007-13.212 1.204-3.557 1.122-7.157 3.358-10.874 6.755-4.339 3.966-6.646 7.299-10.527 15.21-2.734 5.574-6.048 13.958-7.364 18.633-1.317 4.675-2.969 13.397-3.672 19.382-.986 8.398-1.755 11.42-3.367 13.24-2.034 2.296-2.468 2.351-16.589 2.118-12.131-.2-16.023.125-23.823 1.996-5.127 1.23-13.002 3.974-17.5 6.098s-10.9 5.927-14.229 8.45c-3.329 2.524-8.103 7.205-10.609 10.402-2.507 3.198-5.942 8.289-7.635 11.314s-4.228 8.943-5.633 13.151c-1.404 4.207-3.215 10.507-4.024 14-.81 3.492-1.918 10.287-2.464 15.099a78.7 78.7 0 0 0-.01 17.5c.541 4.813 1.703 11.675 2.584 15.25.88 3.575 2.915 9.831 4.522 13.903 1.607 4.071 4.986 10.596 7.51 14.5s7.408 9.863 10.853 13.244c3.446 3.381 9.121 7.735 12.612 9.675s9.271 4.463 12.846 5.608 10.55 2.71 15.5 3.479m23.405-68.983c-1.886 1.588-4.28 2.574-6.247 2.574-1.754 0-4.757-.835-6.673-1.855s-4.703-3.607-6.192-5.75-3.239-6.22-3.89-9.061-.958-8.32-.682-12.175c.365-5.101 1.211-8.219 3.106-11.454 1.634-2.789 3.971-5.093 6.271-6.184 2.399-1.139 4.879-1.543 7.177-1.17 1.932.314 4.581 1.574 5.887 2.801s3.059 4.507 3.896 7.289 1.797 9.456 2.134 14.829c.396 6.323.2 11.149-.558 13.677-.643 2.148-2.546 5.064-4.229 6.479\"\n        className=\"logo-stroke\"\n      />\n      <path\n        fillRule=\"evenodd\"\n        clipRule=\"evenodd\"\n        d=\"M794.072 312.303c-4.822.265-10.949.087-13.616-.394-2.667-.482-7.247-2.05-10.178-3.483-3.125-1.53-7.596-5.057-10.815-8.534-3.498-3.778-6.292-8.026-7.706-11.712-1.256-3.276-2.427-9.057-2.701-13.329-.404-6.292-.114-8.403 1.746-12.727 1.27-2.952 4.15-6.953 6.691-9.296 2.454-2.261 6.792-5.179 9.64-6.482 2.847-1.304 9.226-3.323 14.175-4.487s10.063-2.803 11.366-3.641c2.04-1.313 2.296-2.1 1.848-5.674-.286-2.282-2.975-9.553-5.974-16.159s-7.501-17.406-10.003-24-6.165-17.165-8.141-23.49-4.955-18.25-6.62-26.5c-1.664-8.25-3.308-18.931-3.651-23.736-.368-5.144-.144-11.653.546-15.827.644-3.9 2.244-9.194 3.555-11.764 1.312-2.57 3.664-5.86 5.228-7.313s4.701-3.57 6.973-4.709c2.271-1.137 6.788-2.497 10.038-3.02 3.512-.566 7.809-.597 10.59-.077 2.575.48 6.44 1.772 8.59 2.868 2.15 1.097 5.685 4.012 7.856 6.479 2.171 2.466 5.424 7.689 7.23 11.605 2.207 4.79 4.372 12.532 6.612 23.65 1.844 9.149 4.363 18.582 5.642 21.129 1.272 2.53 3.094 5.07 4.048 5.644 1.348.81 2.216.564 3.877-1.097 1.829-1.829 2.476-4.884 4.446-20.986 1.268-10.365 2.97-21.996 3.782-25.846s2.442-9.566 3.622-12.702c1.191-3.167 4.131-7.91 6.613-10.671 2.458-2.733 6.396-5.903 8.752-7.043s6.967-2.355 10.248-2.697c3.38-.354 8.445-.11 11.685.563 3.146.654 7.858 2.62 10.473 4.369 2.877 1.925 5.982 5.24 7.866 8.396 1.711 2.868 3.736 8.043 4.498 11.5.782 3.544 1.346 11.313 1.295 17.814-.05 6.34-.646 18.04-1.325 26-.678 7.959-2.112 19.871-3.186 26.471s-3.335 18.053-5.025 25.451c-1.689 7.397-4.846 18.647-7.015 25-2.169 6.352-7.098 17.974-10.954 25.826s-9.713 18.316-13.016 23.253c-3.302 4.937-9.634 12.812-14.071 17.5-4.437 4.687-11.671 11.237-16.075 14.556s-11.529 7.804-15.834 9.969c-4.306 2.164-11.66 5.046-16.343 6.403-4.802 1.392-12.339 2.678-17.282 2.95\"\n        className=\"logo-primary\"\n      />\n      <path\n        fillRule=\"evenodd\"\n        clipRule=\"evenodd\"\n        d=\"M794.895 327.28c6.081-.334 14.743-1.812 20.634-3.52 5.564-1.612 13.776-4.83 18.906-7.409 5.138-2.583 13.023-7.547 18.123-11.391 5.1-3.842 12.95-10.951 17.941-16.224 5.005-5.287 11.865-13.818 15.646-19.471 3.714-5.551 9.884-16.575 14.012-24.981 4.121-8.393 9.296-20.592 11.686-27.592 2.343-6.862 5.64-18.614 7.443-26.507 1.757-7.697 4.08-19.458 5.206-26.38 1.139-6.995 2.618-19.283 3.327-27.608s1.325-20.404 1.378-27.155c.06-7.529-.579-16.327-1.646-21.163-1.104-5.005-3.717-11.686-6.266-15.957-3.006-5.036-7.649-9.992-12.404-13.174-4.228-2.83-10.739-5.546-15.766-6.59-4.766-.989-11.385-1.308-16.293-.795-4.921.514-11.203 2.168-15.225 4.115-4.279 2.07-9.702 6.436-13.37 10.515-3.703 4.118-7.633 10.46-9.499 15.418-1.111 2.953-2.44 7.392-3.437 11.357a53 53 0 0 0-.93-2.146c-2.378-5.159-6.389-11.597-9.593-15.237-3.34-3.795-8.313-7.896-12.299-9.93-3.458-1.763-8.691-3.511-12.651-4.251-4.587-.857-10.601-.813-15.73.013-4.673.752-10.636 2.547-14.372 4.418-3.492 1.75-7.805 4.662-10.461 7.128-2.963 2.751-6.322 7.45-8.382 11.487-2.076 4.07-4.115 10.813-4.994 16.138-.892 5.399-1.156 13.075-.708 19.341.397 5.547 2.129 16.81 3.91 25.633 1.762 8.732 4.864 21.153 7.005 28.006 2.066 6.615 5.828 17.468 8.435 24.338 2.615 6.894 7.239 17.988 10.369 24.881a142 142 0 0 1 2.103 4.882c-5.67 1.388-12.419 3.551-16.105 5.238-4.308 1.972-9.964 5.776-13.561 9.092-4.063 3.745-8.185 9.472-10.304 14.398-2.873 6.678-3.481 11.112-2.936 19.614.366 5.714 1.837 12.972 3.664 17.738 2.149 5.605 5.984 11.434 10.705 16.533 4.395 4.747 10.295 9.403 15.23 11.818 4.121 2.016 10.005 4.029 14.103 4.77 4.017.725 11.218.934 17.106.61m-14.439-15.371c2.667.482 8.794.659 13.616.394 4.943-.272 12.48-1.558 17.281-2.95 4.684-1.357 12.038-4.239 16.344-6.403 4.305-2.165 11.43-6.651 15.834-9.969 4.404-3.319 11.638-9.869 16.075-14.556 4.437-4.688 10.769-12.562 14.071-17.5 3.303-4.937 9.16-15.401 13.016-23.253s8.785-19.474 10.954-25.826 5.326-17.602 7.015-25 3.951-18.851 5.025-25.451 2.508-18.512 3.186-26.471c.679-7.96 1.275-19.66 1.325-26 .051-6.501-.513-14.27-1.295-17.814-.762-3.457-2.787-8.632-4.498-11.5-1.884-3.156-4.989-6.47-7.866-8.396-2.615-1.75-7.327-3.715-10.473-4.368-3.24-.673-8.305-.918-11.685-.564-3.281.343-7.892 1.556-10.248 2.697-2.356 1.14-6.294 4.31-8.752 7.043-2.482 2.76-5.422 7.504-6.614 10.671-1.179 3.136-2.809 8.852-3.621 12.702s-2.514 15.481-3.782 25.846c-1.97 16.102-2.617 19.157-4.446 20.986-1.661 1.661-2.529 1.907-3.877 1.097-.955-.574-2.776-3.114-4.048-5.644-1.279-2.547-3.798-11.98-5.642-21.129-2.24-11.118-4.405-18.86-6.612-23.65-1.806-3.916-5.059-9.139-7.23-11.605s-5.706-5.382-7.856-6.479c-2.15-1.096-6.015-2.387-8.59-2.868-2.781-.52-7.078-.489-10.59.077-3.25.523-7.767 1.883-10.039 3.02s-5.408 3.257-6.972 4.71c-1.564 1.451-3.916 4.742-5.228 7.312s-2.911 7.864-3.555 11.764c-.69 4.175-.914 10.683-.546 15.827.343 4.805 1.987 15.486 3.651 23.736 1.665 8.25 4.644 20.175 6.62 26.5s5.639 16.896 8.141 23.49c2.502 6.595 7.003 17.395 10.003 24s5.688 13.877 5.974 16.159c.448 3.574.192 4.361-1.848 5.674-1.303.838-6.418 2.477-11.366 3.641-4.949 1.164-11.328 3.183-14.176 4.487-2.847 1.303-7.185 4.221-9.639 6.483-2.541 2.342-5.421 6.343-6.691 9.295-1.86 4.324-2.15 6.435-1.746 12.727.274 4.272 1.445 10.053 2.701 13.329 1.414 3.686 4.208 7.934 7.705 11.712 3.22 3.477 7.69 7.004 10.816 8.534 2.931 1.433 7.511 3.001 10.178 3.483\"\n        className=\"logo-stroke\"\n      />\n      <path\n        fillRule=\"evenodd\"\n        clipRule=\"evenodd\"\n        d=\"M846.568 271.613c-1.936.361-2.92.035-3.361-1.112-.436-1.137 2.043-4.41 8.277-10.932 4.895-5.12 11.039-12.556 13.653-16.523s6.518-11.018 8.675-15.665c2.157-4.648 6.215-14.84 9.016-22.648s6.158-18.588 7.457-23.954c1.3-5.366 2.924-15.003 3.609-21.416.846-7.916 1.677-11.825 2.591-12.175.74-.284 2.015.039 2.833.718.883.733 1.48 2.91 1.47 5.362-.01 2.27-.653 8.001-1.429 12.736-.777 4.735-2.331 12.385-3.455 17s-3.442 12.666-5.15 17.891-4.936 14-7.174 19.5c-2.237 5.5-5.528 12.925-7.313 16.5-1.784 3.575-5.269 9.2-7.744 12.5s-7.812 9.534-11.859 13.853c-4.706 5.023-8.346 8.038-10.096 8.365m-310.634-8.718c-1.053 0-2.186-.707-2.518-1.572s-.185-3.452.325-5.75c.511-2.298 1.376-6.674 1.922-9.726.547-3.051 1.547-11.826 2.223-19.5.811-9.207 1.021-22.284.616-38.452-.392-15.647-1.189-27.571-2.206-33-.875-4.675-2.458-11.42-3.516-14.989-1.732-5.84-1.77-6.641-.385-8 1.375-1.35 1.732-1.276 3.347.693.995 1.211 2.762 5.711 3.929 10s2.908 14.096 3.872 21.796c1.068 8.525 1.755 20.844 1.758 31.5.002 9.625-.565 23.666-1.26 31.202s-1.935 17.661-2.755 22.5-1.929 9.811-2.464 11.048c-.536 1.238-1.835 2.25-2.888 2.25m-294.265-13.209c-.994.19-2.496-1.077-3.822-3.223-1.202-1.945-2.04-4.556-1.864-5.802.177-1.246.979-2.266 1.782-2.266s2.51 1.557 3.792 3.46 2.201 4.374 2.04 5.49c-.16 1.116-1.028 2.17-1.928 2.341M41.805 238.895c-.415 0-1.236-.58-1.825-1.289s-1.518-3.282-2.065-5.717c-.686-3.056-.665-4.825.07-5.711.586-.705 1.65-1.283 2.365-1.283s1.742 1.463 2.282 3.25c.54 1.788 1.232 4.352 1.536 5.698s.067 3.034-.527 3.75-1.42 1.302-1.836 1.302m547.25-10.182c-1.564.224-2.557-.398-3.323-2.079-.601-1.319-.943-3.708-.76-5.31.208-1.822.995-3.037 2.102-3.247 1.242-.236 2.226.759 3.304 3.339.845 2.022 1.394 4.419 1.221 5.327s-1.318 1.795-2.544 1.97m141.035-9.003c-1.707.243-2.374-.321-2.911-2.461-.383-1.526-.395-28.218-.027-59.315s1.121-61.939 1.673-68.539c.553-6.6 1.43-14.884 1.951-18.408.52-3.525 1.366-6.83 1.88-7.343.579-.579 1.738-.504 3.047.197 1.761.942 2.017 1.75 1.536 4.842-.318 2.042-1.004 7.312-1.524 11.712s-1.161 28.7-1.426 54-.819 54.775-1.232 65.5c-.737 19.105-.797 19.506-2.967 19.815m-504.528-1.815c-1.63 0-2.814-.83-3.578-2.507-.628-1.379-1.889-6.591-2.803-11.581-1.255-6.862-1.511-12.043-1.049-21.243.353-7.034 1.405-14.984 2.493-18.839 1.035-3.669 3.462-9.969 5.392-14s5.564-10.098 8.074-13.481c2.511-3.384 7.326-8.449 10.7-11.257 3.374-2.809 10.27-7.09 15.325-9.515 5.054-2.424 12.42-5.119 16.368-5.988 3.949-.869 7.886-1.309 8.75-.978.864.332 1.571 1.465 1.571 2.518s-1.012 2.337-2.25 2.853c-1.237.517-5.621 1.909-9.742 3.093-4.121 1.185-9.628 3.185-12.239 4.444-2.611 1.26-7.703 4.399-11.315 6.977-3.612 2.577-8.582 7.233-11.044 10.345s-5.801 8.359-7.418 11.659c-1.618 3.3-4.114 10.14-5.547 15.199-1.842 6.507-2.654 11.92-2.775 18.5-.093 5.116.464 12.676 1.24 16.801.775 4.125 1.606 9.638 1.846 12.25.413 4.486.302 4.75-1.999 4.75M32.566 201.708c-1.076.204-2.09-.555-2.66-1.992-.507-1.276-1.632-7.496-2.5-13.821-.87-6.325-2.039-16.45-2.6-22.5s-1.311-20.544-1.668-32.21c-.413-13.501-.206-27.131.57-37.5.67-8.96 1.892-21.185 2.715-27.169.823-5.983 2.585-14.308 3.915-18.5 1.33-4.191 4.004-9.964 5.94-12.827 2.053-3.036 5.386-6.3 7.996-7.829 2.462-1.442 5.276-2.468 6.253-2.28s1.778 1.091 1.778 2.006c0 .916-1.866 3.016-4.147 4.668-2.28 1.653-5.285 4.498-6.675 6.323s-3.657 6.693-5.038 10.818c-1.38 4.125-3.201 12.45-4.048 18.5-.846 6.05-2 16.762-2.565 23.805s-1.027 17.905-1.027 24.139.46 19.372 1.024 29.195c.562 9.824 1.934 25.046 3.047 33.827s1.89 17.553 1.727 19.492c-.203 2.405-.85 3.63-2.037 3.855m548.162-2.813c-.373 0-1.247-.685-1.941-1.522-.946-1.14-1.118-4.848-.684-14.75.411-9.371 1.328-16.144 3.145-23.228 1.411-5.5 4.417-13.705 6.679-18.234 2.453-4.91 6.547-10.889 10.142-14.813 3.316-3.618 8.55-8.199 11.632-10.181s8.754-5.06 12.604-6.842c3.85-1.781 10.273-4.229 14.272-5.438 5.823-1.762 7.567-1.956 8.75-.974.813.675 1.478 1.792 1.478 2.483s-1.237 1.714-2.75 2.272c-1.512.559-5.555 1.985-8.983 3.169s-8.603 3.203-11.5 4.487c-2.897 1.283-8.096 4.462-11.553 7.064s-8.366 7.268-10.907 10.369-5.852 8.338-7.358 11.638c-1.507 3.3-3.715 8.925-4.907 12.5s-2.815 9.802-3.605 13.838-1.437 11.483-1.437 16.55c0 5.832-.44 9.652-1.2 10.412-.66.66-1.505 1.2-1.877 1.2m203.323-35.18c-1.24.176-2.468-.274-2.728-1s-.752-3.142-1.091-5.368c-.47-3.081-.225-4.375 1.028-5.414 1.403-1.165 1.886-1.125 3.281.269 1.032 1.033 1.659 3.396 1.699 6.414.058 4.221-.198 4.817-2.189 5.099m-385.154-1.82c-1.076 0-2.21-.798-2.52-1.773-.309-.975-.122-3.307.415-5.182.538-1.876 1.361-3.793 1.828-4.26.468-.468 1.601-.563 2.518-.211 1.012.389 1.667 1.751 1.667 3.468 0 1.555-.439 3.982-.975 5.393-.589 1.548-1.752 2.565-2.933 2.565m3.788-25.507c-.858.329-2.119-.077-2.804-.901-.962-1.16-1-3.07-.17-8.426.592-3.81 1.937-9.694 2.989-13.077 1.053-3.382 3.135-7.75 4.626-9.705s4.121-4.414 5.845-5.465 4.324-1.913 5.777-1.915c1.547-.002 2.908.687 3.283 1.663.352.917.287 2.014-.143 2.437-.431.424-1.959 1.214-3.397 1.757s-4.022 2.694-5.742 4.781c-1.721 2.087-3.795 5.834-4.61 8.326s-1.99 7.907-2.612 12.032-1.211 7.589-1.307 7.697c-.097.109-.877.467-1.735.796m374.183-3.493c-1.096 0-2.539-1.012-3.206-2.25s-1.409-6.975-1.647-12.75c-.25-6.041.037-12.241.676-14.599.61-2.254 2.134-5.619 3.386-7.476s3.824-4.421 5.717-5.697c1.923-1.295 4.557-2.189 5.968-2.023 1.42.166 2.666 1.036 2.847 1.987.199 1.052-.793 2.268-2.623 3.214-1.618.837-4.109 2.909-5.535 4.603-1.426 1.695-3.123 4.851-3.771 7.013-.803 2.681-.961 7.758-.498 15.955.654 11.57.604 12.023-1.314 12.023\"\n        fill=\"#F9C5E8\"\n      />\n    </svg>\n  );\n};\n\nexport default HandyTextLogo;\n"
  },
  {
    "path": "src/components/icons/MicrophoneIcon.tsx",
    "content": "import React from \"react\";\n\ninterface MicrophoneIconProps {\n  width?: number;\n  height?: number;\n  color?: string;\n  className?: string;\n}\n\nconst MicrophoneIcon: React.FC<MicrophoneIconProps> = ({\n  width = 24,\n  height = 24,\n  color = \"#FAA2CA\",\n  className = \"\",\n}) => {\n  return (\n    <svg\n      width={width}\n      height={height}\n      viewBox=\"0 0 24 24\"\n      fill=\"none\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      className={className}\n    >\n      <path\n        d=\"M17.1562 10.2C17.1562 8.83247 16.613 7.52099 15.646 6.554C14.679 5.58702 13.3675 5.04375 12 5.04375C10.6325 5.04375 9.32099 5.58702 8.354 6.554C7.38702 7.52099 6.84375 8.83247 6.84375 10.2C6.84375 11.1586 6.99743 11.7554 7.18689 12.1629C7.37547 12.5685 7.62633 12.848 7.94019 13.1553C8.23392 13.443 8.67357 13.8299 8.99524 14.3488C9.34195 14.9081 9.54381 15.5869 9.54382 16.4999C9.54382 17.1513 9.80245 17.7762 10.2631 18.2369C10.7237 18.6975 11.3486 18.9561 12 18.9561C12.7207 18.9561 13.268 18.6453 13.7494 18.0625C14.0462 17.7033 14.5781 17.6526 14.9374 17.9494C15.2967 18.2461 15.3473 18.7781 15.0505 19.1374C14.3214 20.0201 13.3283 20.6436 12 20.6436C10.901 20.6436 9.84705 20.2071 9.06995 19.43C8.29287 18.6529 7.85632 17.5989 7.85632 16.4999C7.85631 15.8572 7.72032 15.4953 7.56079 15.238C7.37622 14.9402 7.14072 14.7342 6.75952 14.3609C6.39843 14.0073 5.97449 13.5575 5.65686 12.8744C5.34008 12.1931 5.15625 11.3413 5.15625 10.2C5.15625 8.38492 5.87744 6.64434 7.16089 5.36089C8.44434 4.07743 10.1849 3.35625 12 3.35625C13.8151 3.35625 15.5557 4.07743 16.8391 5.36089C18.1226 6.64434 18.8438 8.38492 18.8438 10.2C18.8437 10.666 18.466 11.0437 18 11.0437C17.534 11.0437 17.1563 10.666 17.1562 10.2Z\"\n        fill={color}\n      />\n      <path\n        d=\"M14.1562 10.2C14.1562 9.62812 13.9289 9.07984 13.5245 8.67546C13.1454 8.29636 12.6399 8.07275 12.1069 8.04631L12 8.04375C11.4281 8.04375 10.8798 8.27109 10.4755 8.67546C10.0711 9.07984 9.84375 9.62812 9.84375 10.2C9.84375 10.666 9.46599 11.0437 9 11.0437C8.53401 11.0437 8.15625 10.666 8.15625 10.2C8.15625 9.18057 8.56114 8.20282 9.28198 7.48198C10.0028 6.76114 10.9806 6.35625 12 6.35625L12.1904 6.36101C13.1405 6.4081 14.0422 6.80615 14.718 7.48198C15.4389 8.20282 15.8438 9.18057 15.8438 10.2C15.8438 11.4145 15.2126 12.223 14.7751 12.8063C14.3126 13.423 14.0438 13.8146 14.0438 14.4001C14.0438 14.4785 14.0697 14.555 14.1174 14.6172C14.1652 14.6795 14.2321 14.7244 14.3079 14.7447C14.3836 14.7649 14.4639 14.7597 14.5364 14.7297C14.6088 14.6996 14.6693 14.6464 14.7085 14.5784C14.9413 14.1748 15.4573 14.0363 15.861 14.269C16.2646 14.5018 16.4032 15.0178 16.1704 15.4214C15.9456 15.8113 15.5984 16.1163 15.1827 16.2886C14.767 16.4609 14.3057 16.4911 13.871 16.3747C13.4363 16.2582 13.0521 16.0015 12.7782 15.6445C12.5043 15.2874 12.3562 14.8497 12.3563 14.3997C12.3564 13.1854 12.9875 12.377 13.4249 11.7937C13.8875 11.177 14.1562 10.7855 14.1562 10.2Z\"\n        fill={color}\n      />\n    </svg>\n  );\n};\n\nexport default MicrophoneIcon;\n"
  },
  {
    "path": "src/components/icons/ResetIcon.tsx",
    "content": "import React from \"react\";\n\ninterface ResetIconProps {\n  width?: number;\n  height?: number;\n  color?: string;\n  className?: string;\n}\n\nconst ResetIcon: React.FC<ResetIconProps> = ({\n  width = 20,\n  height = 20,\n  className = \"\",\n}) => {\n  return (\n    <svg\n      width={width}\n      height={height}\n      viewBox=\"0 0 20 20\"\n      fill=\"none\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      className={className}\n    >\n      <g\n        stroke={\"currentColor\"}\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n        strokeWidth=\"1.5\"\n      >\n        <path d=\"m13.5 8.5h3v-3\" />\n        <path d=\"m13.775 14c-.7863.7419-1.7737 1.2356-2.8389 1.4196-1.06527.1839-2.16109.0498-3.15057-.3855s-1.82875-1.1525-2.41293-2.0621c-.58419-.9095-.88739-1.9711-.87172-3.05197.01567-1.08089.34952-2.13319.95982-3.02543.61031-.89224 1.47001-1.58485 2.4717-1.99129s2.1009-.50868 3.1604-.29396c1.0595.21473 2.0322.7369 2.7965 1.50127l2.6107 2.38938\" />\n      </g>\n    </svg>\n  );\n};\n\nexport default ResetIcon;\n"
  },
  {
    "path": "src/components/icons/TranscriptionIcon.tsx",
    "content": "import React from \"react\";\n\ninterface TranscriptionIconProps {\n  width?: number;\n  height?: number;\n  color?: string;\n  className?: string;\n}\n\nconst TranscriptionIcon: React.FC<TranscriptionIconProps> = ({\n  width = 24,\n  height = 24,\n  color = \"#FAA2CA\",\n  className = \"\",\n}) => {\n  return (\n    <svg\n      width={width}\n      height={height}\n      viewBox=\"0 0 24 24\"\n      fill=\"none\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n      className={className}\n    >\n      <path\n        d=\"M8.99996 11.85C9.74164 11.85 10.4666 12.07 11.0833 12.4821C11.7 12.8941 12.1809 13.4796 12.4647 14.1648C12.7485 14.85 12.8225 15.6043 12.6778 16.3317C12.5331 17.0591 12.1761 17.7273 11.6517 18.2517C11.1273 18.7762 10.459 19.1331 9.73165 19.2779C9.00422 19.4226 8.25 19.3486 7.56478 19.0647C6.87958 18.7809 6.29409 18.3 5.88204 17.6834C5.46998 17.0667 5.24996 16.3417 5.24996 15.6V15.0954C5.24996 14.6812 5.58575 14.3454 5.99996 14.3454C6.41417 14.3454 6.74996 14.6812 6.74996 15.0954V15.6C6.74996 16.0449 6.88183 16.4799 7.12899 16.8499C7.37622 17.2199 7.72786 17.5083 8.139 17.6786C8.55013 17.8489 9.0026 17.8936 9.43905 17.8068C9.87545 17.72 10.2761 17.5055 10.5908 17.1908C10.9054 16.8762 11.1199 16.4755 11.2067 16.0391C11.2936 15.6026 11.2489 15.1502 11.0786 14.739C10.9083 14.3279 10.6198 13.9763 10.2498 13.729C9.87987 13.4819 9.44489 13.35 8.99996 13.35C8.58575 13.35 8.24996 13.0142 8.24996 12.6C8.24996 12.1858 8.58575 11.85 8.99996 11.85Z\"\n        fill={color}\n      />\n      <path\n        d=\"M15 11.85C15.4142 11.85 15.75 12.1858 15.75 12.6C15.75 13.0142 15.4142 13.35 15 13.35C14.555 13.35 14.1201 13.4819 13.7501 13.729C13.3801 13.9763 13.0916 14.3279 12.9213 14.739C12.7511 15.1502 12.7064 15.6026 12.7932 16.0391C12.88 16.4755 13.0945 16.8762 13.4091 17.1908C13.7238 17.5055 14.1245 17.72 14.5609 17.8068C14.9973 17.8936 15.4498 17.8489 15.8609 17.6786C16.2721 17.5083 16.6237 17.2199 16.8709 16.8499C17.1181 16.4799 17.25 16.0449 17.25 15.6V15.0954C17.25 14.6812 17.5857 14.3454 18 14.3454C18.4142 14.3454 18.75 14.6812 18.75 15.0954V15.6C18.75 16.3417 18.5299 17.0667 18.1179 17.6834C17.7058 18.3 17.1203 18.7809 16.4351 19.0647C15.7499 19.3486 14.9957 19.4226 14.2683 19.2779C13.5409 19.1331 12.8726 18.7762 12.3482 18.2517C11.8238 17.7273 11.4668 17.0591 11.3221 16.3317C11.1774 15.6043 11.2514 14.85 11.5352 14.1648C11.8191 13.4796 12.2999 12.8941 12.9166 12.4821C13.5333 12.07 14.2583 11.85 15 11.85Z\"\n        fill={color}\n      />\n      <path\n        d=\"M11.2498 15.5999V7.8C11.2498 7.20332 11.0129 6.63113 10.591 6.20918C10.169 5.78723 9.59655 5.55 8.99981 5.55C8.40313 5.55004 7.83091 5.78726 7.40899 6.20918C6.98709 6.63113 6.74981 7.2033 6.74981 7.8V8.30464C6.74981 8.62274 6.54921 8.90641 6.2492 9.01216C5.61476 9.23582 5.07998 9.67683 4.73932 10.2569C4.39869 10.837 4.27406 11.5187 4.38775 12.1817C4.5015 12.8448 4.84623 13.4464 5.36078 13.8798C5.87528 14.3132 6.52647 14.5506 7.19915 14.55H7.80011C8.21426 14.5501 8.55011 14.8858 8.55011 15.3C8.55011 15.7142 8.21426 16.0499 7.80011 16.05H7.19989C6.17333 16.0507 5.17951 15.6885 4.39434 15.0272C3.60898 14.3657 3.08297 13.4475 2.90936 12.4355C2.73575 11.4234 2.92586 10.3825 3.44586 9.49702C3.87347 8.76895 4.50162 8.18474 5.24981 7.81026V7.8C5.24981 6.80544 5.64518 5.85153 6.34845 5.14827C7.05166 4.44511 8.00536 4.05004 8.99981 4.05C9.99434 4.05 10.9483 4.44506 11.6515 5.14827C12.3548 5.85153 12.7498 6.80544 12.7498 7.8V15.5999C12.7498 16.0141 12.414 16.3499 11.9998 16.3499C11.5857 16.3498 11.2498 16.0141 11.2498 15.5999Z\"\n        fill={color}\n      />\n      <path\n        d=\"M11.2498 7.8C11.2498 6.80544 11.645 5.85153 12.3482 5.14827C13.0515 4.44501 14.0054 4.05 15 4.05C15.9945 4.05 16.9484 4.44501 17.6517 5.14827C18.355 5.85153 18.75 6.80544 18.75 7.8V7.81026C19.4982 8.18475 20.1266 8.76886 20.5543 9.49702C21.0743 10.3825 21.264 11.4234 21.0904 12.4355C20.9168 13.4475 20.3908 14.3657 19.6054 15.0272C18.8202 15.6885 17.8265 16.0504 16.7999 16.0496L16.2 16.05C15.7858 16.05 15.45 15.7142 15.45 15.3C15.45 14.8858 15.7858 14.55 16.2 14.55H16.8006C17.4734 14.5506 18.1248 14.3132 18.6394 13.8798C19.1539 13.4464 19.4983 12.8448 19.612 12.1817C19.7257 11.5187 19.6014 10.837 19.2608 10.2569C18.9201 9.6768 18.3851 9.23581 17.7506 9.01216C17.4506 8.9064 17.25 8.62273 17.25 8.30464V7.8C17.25 7.20327 17.0127 6.63114 16.5908 6.20918C16.1688 5.78723 15.5967 5.55 15 5.55C14.4032 5.55 13.8311 5.78723 13.4091 6.20918C12.9872 6.63114 12.7498 7.20327 12.7498 7.8C12.7498 8.21422 12.4142 8.55 12 8.55C11.5857 8.55 11.2498 8.21422 11.2498 7.8Z\"\n        fill={color}\n      />\n      <path\n        d=\"M14.25 8.69993V8.4C14.25 7.98579 14.5857 7.65 15 7.65C15.4142 7.65 15.75 7.98579 15.75 8.4V8.69993C15.75 9.05797 15.8923 9.40147 16.1455 9.65464C16.3986 9.90773 16.7419 10.0501 17.0998 10.0501H17.4001C17.8143 10.0502 18.1501 10.386 18.1501 10.8001C18.15 11.2142 17.8142 11.5501 17.4001 11.5501H17.0998C16.344 11.5501 15.619 11.2496 15.0846 10.7152C14.5501 10.1807 14.25 9.45574 14.25 8.69993Z\"\n        fill={color}\n      />\n      <path\n        d=\"M8.25011 8.69993V8.4C8.25011 7.98579 8.58589 7.65 9.00011 7.65C9.41425 7.65008 9.75011 7.98584 9.75011 8.4V8.69993C9.75011 9.4558 9.44962 10.1807 8.91514 10.7152C8.38067 11.2497 7.65575 11.5501 6.89989 11.5501H6.59996C6.18579 11.5501 5.85004 11.2143 5.84996 10.8001C5.84996 10.3859 6.18575 10.0501 6.59996 10.0501H6.89989C7.25793 10.0501 7.60142 9.90782 7.8546 9.65464C8.10777 9.40147 8.25011 9.05797 8.25011 8.69993Z\"\n        fill={color}\n      />\n    </svg>\n  );\n};\n\nexport default TranscriptionIcon;\n"
  },
  {
    "path": "src/components/icons/index.ts",
    "content": "export { default as MicrophoneIcon } from \"./MicrophoneIcon\";\nexport { default as TranscriptionIcon } from \"./TranscriptionIcon\";\nexport { default as CancelIcon } from \"./CancelIcon\";\n"
  },
  {
    "path": "src/components/model-selector/DownloadProgressDisplay.tsx",
    "content": "import React from \"react\";\nimport { ProgressBar, ProgressData } from \"../shared\";\n\ninterface DownloadProgress {\n  model_id: string;\n  downloaded: number;\n  total: number;\n  percentage: number;\n}\n\ninterface DownloadStats {\n  startTime: number;\n  lastUpdate: number;\n  totalDownloaded: number;\n  speed: number;\n}\n\ninterface DownloadProgressDisplayProps {\n  downloadProgress: Record<string, DownloadProgress>;\n  downloadStats: Record<string, DownloadStats>;\n  className?: string;\n}\n\nconst DownloadProgressDisplay: React.FC<DownloadProgressDisplayProps> = ({\n  downloadProgress,\n  downloadStats,\n  className = \"\",\n}) => {\n  const progressValues = Object.values(downloadProgress);\n  if (progressValues.length === 0) {\n    return null;\n  }\n\n  const progressData: ProgressData[] = progressValues.map((progress) => {\n    const stats = downloadStats[progress.model_id];\n    return {\n      id: progress.model_id,\n      percentage: progress.percentage,\n      speed: stats?.speed,\n    };\n  });\n\n  return (\n    <ProgressBar\n      progress={progressData}\n      className={className}\n      showSpeed={progressValues.length === 1}\n      size=\"medium\"\n    />\n  );\n};\n\nexport default DownloadProgressDisplay;\n"
  },
  {
    "path": "src/components/model-selector/ModelDropdown.tsx",
    "content": "import React from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport type { ModelInfo } from \"@/bindings\";\nimport {\n  getTranslatedModelName,\n  getTranslatedModelDescription,\n} from \"../../lib/utils/modelTranslation\";\n\ninterface ModelDropdownProps {\n  models: ModelInfo[];\n  currentModelId: string;\n  onModelSelect: (modelId: string) => void;\n}\n\nconst ModelDropdown: React.FC<ModelDropdownProps> = ({\n  models,\n  currentModelId,\n  onModelSelect,\n}) => {\n  const { t } = useTranslation();\n  const downloadedModels = models.filter((m) => m.is_downloaded);\n\n  const handleModelClick = (modelId: string) => {\n    onModelSelect(modelId);\n  };\n\n  return (\n    <div className=\"absolute bottom-full start-0 mb-2 w-64 max-h-[60vh] overflow-y-auto bg-background border border-mid-gray/20 rounded-lg shadow-lg py-2 z-50\">\n      {downloadedModels.length > 0 ? (\n        <div>\n          {downloadedModels.map((model) => (\n            <div\n              key={model.id}\n              onClick={() => handleModelClick(model.id)}\n              onKeyDown={(e) => {\n                if (e.key === \"Enter\" || e.key === \" \") {\n                  e.preventDefault();\n                  handleModelClick(model.id);\n                }\n              }}\n              tabIndex={0}\n              role=\"button\"\n              className={`w-full px-3 py-2 text-start hover:bg-mid-gray/10 transition-colors cursor-pointer focus:outline-none ${\n                currentModelId === model.id\n                  ? \"bg-logo-primary/10 text-logo-primary\"\n                  : \"\"\n              }`}\n            >\n              <div className=\"flex items-center justify-between\">\n                <div>\n                  <div className=\"text-sm text-text/80\">\n                    {getTranslatedModelName(model, t)}\n                    {model.is_custom && (\n                      <span className=\"ms-1.5 text-[10px] font-medium text-text/40 uppercase\">\n                        {t(\"modelSelector.custom\")}\n                      </span>\n                    )}\n                  </div>\n                  <div className=\"text-xs text-text/40 italic pe-4\">\n                    {getTranslatedModelDescription(model, t)}\n                  </div>\n                </div>\n                {currentModelId === model.id && (\n                  <div className=\"text-xs text-logo-primary\">\n                    {t(\"modelSelector.active\")}\n                  </div>\n                )}\n              </div>\n            </div>\n          ))}\n        </div>\n      ) : (\n        <div className=\"px-3 py-2 text-sm text-text/60\">\n          {t(\"modelSelector.noModelsAvailable\")}\n        </div>\n      )}\n    </div>\n  );\n};\n\nexport default ModelDropdown;\n"
  },
  {
    "path": "src/components/model-selector/ModelSelector.tsx",
    "content": "import React, { useState, useRef, useEffect } from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { listen } from \"@tauri-apps/api/event\";\nimport { commands } from \"@/bindings\";\nimport { getTranslatedModelName } from \"../../lib/utils/modelTranslation\";\nimport { useModelStore } from \"../../stores/modelStore\";\nimport ModelStatusButton from \"./ModelStatusButton\";\nimport ModelDropdown from \"./ModelDropdown\";\nimport DownloadProgressDisplay from \"./DownloadProgressDisplay\";\n\nimport { ModelStateEvent } from \"@/lib/types/events\";\n\ntype ModelStatus =\n  | \"ready\"\n  | \"loading\"\n  | \"downloading\"\n  | \"extracting\"\n  | \"error\"\n  | \"unloaded\"\n  | \"none\";\n\ninterface ModelSelectorProps {\n  onError?: (error: string) => void;\n}\n\nconst ModelSelector: React.FC<ModelSelectorProps> = ({ onError }) => {\n  const { t } = useTranslation();\n  const {\n    models,\n    currentModel,\n    downloadProgress,\n    downloadStats,\n    extractingModels,\n    selectModel,\n  } = useModelStore();\n\n  const [modelStatus, setModelStatus] = useState<ModelStatus>(\"unloaded\");\n  const [modelError, setModelError] = useState<string | null>(null);\n  const [showModelDropdown, setShowModelDropdown] = useState(false);\n  // Track pending model switch for optimistic display\n  const [pendingModelId, setPendingModelId] = useState<string | null>(null);\n\n  const dropdownRef = useRef<HTMLDivElement>(null);\n\n  const displayModelId = pendingModelId || currentModel;\n\n  // Check model status when currentModel changes\n  useEffect(() => {\n    const checkStatus = async () => {\n      if (currentModel) {\n        try {\n          const statusResult = await commands.getTranscriptionModelStatus();\n          if (statusResult.status === \"ok\") {\n            setModelStatus(\n              statusResult.data === currentModel ? \"ready\" : \"unloaded\",\n            );\n          }\n        } catch {\n          setModelStatus(\"error\");\n          setModelError(\"Failed to check model status\");\n        }\n      } else {\n        setModelStatus(\"none\");\n      }\n    };\n    checkStatus();\n  }, [currentModel]);\n\n  useEffect(() => {\n    // Listen for model loading lifecycle events\n    const modelStateUnlisten = listen<ModelStateEvent>(\n      \"model-state-changed\",\n      (event) => {\n        const { event_type, error } = event.payload;\n        switch (event_type) {\n          case \"loading_started\":\n            setModelStatus(\"loading\");\n            setModelError(null);\n            break;\n          case \"loading_completed\":\n            setModelStatus(\"ready\");\n            setModelError(null);\n            setPendingModelId(null);\n            break;\n          case \"loading_failed\":\n            setModelStatus(\"error\");\n            setModelError(error || \"Failed to load model\");\n            setPendingModelId(null);\n            break;\n          case \"unloaded\":\n            setModelStatus(\"unloaded\");\n            setModelError(null);\n            break;\n        }\n      },\n    );\n\n    // Auto-select model when download completes (fires after extraction too)\n    const downloadCompleteUnlisten = listen<string>(\n      \"model-download-complete\",\n      (event) => {\n        const modelId = event.payload;\n        setTimeout(async () => {\n          try {\n            const isRecording = await commands.isRecording();\n            if (!isRecording) {\n              setPendingModelId(modelId);\n              setModelError(null);\n              setShowModelDropdown(false);\n              const success = await selectModel(modelId);\n              if (!success) {\n                setPendingModelId(null);\n              }\n            }\n          } catch {\n            // Ignore errors in auto-select\n          }\n        }, 500);\n      },\n    );\n\n    // Click outside to close dropdown\n    const handleClickOutside = (event: MouseEvent) => {\n      if (\n        dropdownRef.current &&\n        !dropdownRef.current.contains(event.target as Node)\n      ) {\n        setShowModelDropdown(false);\n      }\n    };\n\n    document.addEventListener(\"mousedown\", handleClickOutside);\n\n    return () => {\n      document.removeEventListener(\"mousedown\", handleClickOutside);\n      modelStateUnlisten.then((fn) => fn());\n      downloadCompleteUnlisten.then((fn) => fn());\n    };\n  }, [selectModel]);\n\n  const handleModelSelect = async (modelId: string) => {\n    setPendingModelId(modelId);\n    setModelError(null);\n    setShowModelDropdown(false);\n    const success = await selectModel(modelId);\n    if (!success) {\n      setPendingModelId(null);\n      setModelStatus(\"error\");\n      setModelError(\"Failed to switch model\");\n      onError?.(\"Failed to switch model\");\n    }\n  };\n\n  const getModelDisplayText = (): string => {\n    const extractingKeys = Object.keys(extractingModels);\n    if (extractingKeys.length > 0) {\n      if (extractingKeys.length === 1) {\n        const modelId = extractingKeys[0];\n        const model = models.find((m) => m.id === modelId);\n        const modelName = model\n          ? getTranslatedModelName(model, t)\n          : t(\"modelSelector.extractingGeneric\").replace(\"...\", \"\");\n        return t(\"modelSelector.extracting\", { modelName });\n      } else {\n        return t(\"modelSelector.extractingMultiple\", {\n          count: extractingKeys.length,\n        });\n      }\n    }\n\n    const progressValues = Object.values(downloadProgress);\n    if (progressValues.length > 0) {\n      if (progressValues.length === 1) {\n        const progress = progressValues[0];\n        const percentage = Math.max(\n          0,\n          Math.min(100, Math.round(progress.percentage)),\n        );\n        return t(\"modelSelector.downloading\", { percentage });\n      } else {\n        return t(\"modelSelector.downloadingMultiple\", {\n          count: progressValues.length,\n        });\n      }\n    }\n\n    const currentModelInfo = models.find((m) => m.id === displayModelId);\n\n    switch (modelStatus) {\n      case \"ready\":\n        return currentModelInfo\n          ? getTranslatedModelName(currentModelInfo, t)\n          : t(\"modelSelector.modelReady\");\n      case \"loading\":\n        return currentModelInfo\n          ? t(\"modelSelector.loading\", {\n              modelName: getTranslatedModelName(currentModelInfo, t),\n            })\n          : t(\"modelSelector.loadingGeneric\");\n      case \"extracting\":\n        return currentModelInfo\n          ? t(\"modelSelector.extracting\", {\n              modelName: getTranslatedModelName(currentModelInfo, t),\n            })\n          : t(\"modelSelector.extractingGeneric\");\n      case \"error\":\n        return modelError || t(\"modelSelector.modelError\");\n      case \"unloaded\":\n        return currentModelInfo\n          ? getTranslatedModelName(currentModelInfo, t)\n          : t(\"modelSelector.modelUnloaded\");\n      case \"none\":\n        return t(\"modelSelector.noModelDownloadRequired\");\n      default:\n        return currentModelInfo\n          ? getTranslatedModelName(currentModelInfo, t)\n          : t(\"modelSelector.modelUnloaded\");\n    }\n  };\n\n  // Derive display status from model status + store state\n  const getDisplayStatus = (): ModelStatus => {\n    if (Object.keys(extractingModels).length > 0) return \"extracting\";\n    if (Object.keys(downloadProgress).length > 0) return \"downloading\";\n    return modelStatus;\n  };\n\n  return (\n    <>\n      {/* Model Status and Switcher */}\n      <div className=\"relative\" ref={dropdownRef}>\n        <ModelStatusButton\n          status={getDisplayStatus()}\n          displayText={getModelDisplayText()}\n          isDropdownOpen={showModelDropdown}\n          onClick={() => setShowModelDropdown(!showModelDropdown)}\n        />\n\n        {/* Model Dropdown */}\n        {showModelDropdown && (\n          <ModelDropdown\n            models={models}\n            currentModelId={displayModelId}\n            onModelSelect={handleModelSelect}\n          />\n        )}\n      </div>\n\n      {/* Download Progress Bar for Models */}\n      <DownloadProgressDisplay\n        downloadProgress={downloadProgress}\n        downloadStats={downloadStats}\n      />\n    </>\n  );\n};\n\nexport default ModelSelector;\n"
  },
  {
    "path": "src/components/model-selector/ModelStatusButton.tsx",
    "content": "import React from \"react\";\n\ntype ModelStatus =\n  | \"ready\"\n  | \"loading\"\n  | \"downloading\"\n  | \"extracting\"\n  | \"error\"\n  | \"unloaded\"\n  | \"none\";\n\ninterface ModelStatusButtonProps {\n  status: ModelStatus;\n  displayText: string;\n  isDropdownOpen: boolean;\n  onClick: () => void;\n  className?: string;\n}\n\nconst ModelStatusButton: React.FC<ModelStatusButtonProps> = ({\n  status,\n  displayText,\n  isDropdownOpen,\n  onClick,\n  className = \"\",\n}) => {\n  const getStatusColor = (status: ModelStatus): string => {\n    switch (status) {\n      case \"ready\":\n        return \"bg-green-400\";\n      case \"loading\":\n        return \"bg-yellow-400 animate-pulse\";\n      case \"downloading\":\n        return \"bg-logo-primary animate-pulse\";\n      case \"extracting\":\n        return \"bg-orange-400 animate-pulse\";\n      case \"error\":\n        return \"bg-red-400\";\n      case \"unloaded\":\n        return \"bg-mid-gray/60\";\n      case \"none\":\n        return \"bg-red-400\";\n      default:\n        return \"bg-mid-gray/60\";\n    }\n  };\n\n  return (\n    <button\n      onClick={onClick}\n      className={`flex items-center gap-2 hover:text-text/80 transition-colors ${className}`}\n      title={`Model status: ${displayText}`}\n    >\n      <div className={`w-2 h-2 rounded-full ${getStatusColor(status)}`} />\n      <span className=\"max-w-28 truncate\">{displayText}</span>\n      <svg\n        className={`w-3 h-3 transition-transform ${isDropdownOpen ? \"rotate-180\" : \"\"}`}\n        fill=\"none\"\n        stroke=\"currentColor\"\n        viewBox=\"0 0 24 24\"\n      >\n        <path\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth={2}\n          d=\"M19 9l-7 7-7-7\"\n        />\n      </svg>\n    </button>\n  );\n};\n\nexport default ModelStatusButton;\n"
  },
  {
    "path": "src/components/model-selector/index.ts",
    "content": "export { default } from \"./ModelSelector\";\nexport { default as ModelStatusButton } from \"./ModelStatusButton\";\nexport { default as ModelDropdown } from \"./ModelDropdown\";\nexport { default as DownloadProgressDisplay } from \"./DownloadProgressDisplay\";\n"
  },
  {
    "path": "src/components/onboarding/AccessibilityOnboarding.tsx",
    "content": "import { useEffect, useState, useCallback, useRef } from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { platform } from \"@tauri-apps/plugin-os\";\nimport {\n  checkAccessibilityPermission,\n  requestAccessibilityPermission,\n  checkMicrophonePermission,\n  requestMicrophonePermission,\n} from \"tauri-plugin-macos-permissions-api\";\nimport { toast } from \"sonner\";\nimport { commands } from \"@/bindings\";\nimport { useSettingsStore } from \"@/stores/settingsStore\";\nimport HandyTextLogo from \"../icons/HandyTextLogo\";\nimport { Keyboard, Mic, Check, Loader2 } from \"lucide-react\";\n\ninterface AccessibilityOnboardingProps {\n  onComplete: () => void;\n}\n\ntype PermissionStatus = \"checking\" | \"needed\" | \"waiting\" | \"granted\";\ntype PermissionPlatform = \"macos\" | \"windows\" | \"other\";\n\ninterface PermissionsState {\n  accessibility: PermissionStatus;\n  microphone: PermissionStatus;\n}\n\nconst AccessibilityOnboarding: React.FC<AccessibilityOnboardingProps> = ({\n  onComplete,\n}) => {\n  const { t } = useTranslation();\n  const refreshAudioDevices = useSettingsStore(\n    (state) => state.refreshAudioDevices,\n  );\n  const refreshOutputDevices = useSettingsStore(\n    (state) => state.refreshOutputDevices,\n  );\n  const [permissionPlatform, setPermissionPlatform] =\n    useState<PermissionPlatform | null>(null);\n  const [permissions, setPermissions] = useState<PermissionsState>({\n    accessibility: \"checking\",\n    microphone: \"checking\",\n  });\n  const pollingRef = useRef<ReturnType<typeof setInterval> | null>(null);\n  const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);\n  const errorCountRef = useRef<number>(0);\n  const MAX_POLLING_ERRORS = 3;\n\n  const isMacOS = permissionPlatform === \"macos\";\n  const isWindows = permissionPlatform === \"windows\";\n  const showMicrophonePermission = isMacOS || isWindows;\n  const showAccessibilityPermission = isMacOS;\n\n  const allGranted = isMacOS\n    ? permissions.accessibility === \"granted\" &&\n      permissions.microphone === \"granted\"\n    : isWindows\n      ? permissions.microphone === \"granted\"\n      : true;\n\n  const completeOnboarding = useCallback(async () => {\n    await Promise.all([refreshAudioDevices(), refreshOutputDevices()]);\n    timeoutRef.current = setTimeout(() => onComplete(), 300);\n  }, [onComplete, refreshAudioDevices, refreshOutputDevices]);\n\n  const hasWindowsMicrophoneAccess = useCallback(async (): Promise<boolean> => {\n    const microphoneStatus =\n      await commands.getWindowsMicrophonePermissionStatus();\n\n    if (!microphoneStatus.supported) {\n      return true;\n    }\n\n    return microphoneStatus.overall_access !== \"denied\";\n  }, []);\n\n  // Check platform and permission status on mount\n  useEffect(() => {\n    const currentPlatform = platform();\n    const nextPlatform: PermissionPlatform =\n      currentPlatform === \"macos\"\n        ? \"macos\"\n        : currentPlatform === \"windows\"\n          ? \"windows\"\n          : \"other\";\n\n    setPermissionPlatform(nextPlatform);\n\n    // Skip immediately on unsupported platforms\n    if (nextPlatform === \"other\") {\n      onComplete();\n      return;\n    }\n\n    const checkInitial = async () => {\n      if (nextPlatform === \"macos\") {\n        try {\n          const [accessibilityGranted, microphoneGranted] = await Promise.all([\n            checkAccessibilityPermission(),\n            checkMicrophonePermission(),\n          ]);\n\n          // If accessibility is granted, initialize Enigo and shortcuts\n          if (accessibilityGranted) {\n            try {\n              await Promise.all([\n                commands.initializeEnigo(),\n                commands.initializeShortcuts(),\n              ]);\n            } catch (e) {\n              console.warn(\"Failed to initialize after permission grant:\", e);\n            }\n          }\n\n          const newState: PermissionsState = {\n            accessibility: accessibilityGranted ? \"granted\" : \"needed\",\n            microphone: microphoneGranted ? \"granted\" : \"needed\",\n          };\n\n          setPermissions(newState);\n\n          if (accessibilityGranted && microphoneGranted) {\n            await completeOnboarding();\n          }\n        } catch (error) {\n          console.error(\"Failed to check macOS permissions:\", error);\n          toast.error(t(\"onboarding.permissions.errors.checkFailed\"));\n          setPermissions({\n            accessibility: \"needed\",\n            microphone: \"needed\",\n          });\n        }\n\n        return;\n      }\n\n      try {\n        const microphoneGranted = await hasWindowsMicrophoneAccess();\n\n        setPermissions({\n          accessibility: \"granted\",\n          microphone: microphoneGranted ? \"granted\" : \"needed\",\n        });\n\n        if (microphoneGranted) {\n          await completeOnboarding();\n        }\n      } catch (error) {\n        console.warn(\"Failed to check Windows microphone permissions:\", error);\n        setPermissions({\n          accessibility: \"granted\",\n          microphone: \"granted\",\n        });\n        await completeOnboarding();\n      }\n    };\n\n    checkInitial();\n  }, [completeOnboarding, hasWindowsMicrophoneAccess, onComplete, t]);\n\n  // Polling for permissions after user clicks a button\n  const startPolling = useCallback(() => {\n    if (pollingRef.current || permissionPlatform === null) return;\n\n    pollingRef.current = setInterval(async () => {\n      try {\n        if (permissionPlatform === \"windows\") {\n          const microphoneGranted = await hasWindowsMicrophoneAccess();\n\n          if (microphoneGranted) {\n            setPermissions((prev) => ({ ...prev, microphone: \"granted\" }));\n\n            if (pollingRef.current) {\n              clearInterval(pollingRef.current);\n              pollingRef.current = null;\n            }\n\n            await completeOnboarding();\n          }\n\n          errorCountRef.current = 0;\n          return;\n        }\n\n        const [accessibilityGranted, microphoneGranted] = await Promise.all([\n          checkAccessibilityPermission(),\n          checkMicrophonePermission(),\n        ]);\n\n        setPermissions((prev) => {\n          const newState = { ...prev };\n\n          if (accessibilityGranted && prev.accessibility !== \"granted\") {\n            newState.accessibility = \"granted\";\n            // Initialize Enigo and shortcuts when accessibility is granted\n            Promise.all([\n              commands.initializeEnigo(),\n              commands.initializeShortcuts(),\n            ]).catch((e) => {\n              console.warn(\"Failed to initialize after permission grant:\", e);\n            });\n          }\n\n          if (microphoneGranted && prev.microphone !== \"granted\") {\n            newState.microphone = \"granted\";\n          }\n\n          return newState;\n        });\n\n        // If both granted, stop polling, refresh audio devices, and proceed\n        if (accessibilityGranted && microphoneGranted) {\n          if (pollingRef.current) {\n            clearInterval(pollingRef.current);\n            pollingRef.current = null;\n          }\n          await completeOnboarding();\n        }\n\n        // Reset error count on success\n        errorCountRef.current = 0;\n      } catch (error) {\n        console.error(\"Error checking permissions:\", error);\n        errorCountRef.current += 1;\n\n        if (errorCountRef.current >= MAX_POLLING_ERRORS) {\n          // Stop polling after too many consecutive errors\n          if (pollingRef.current) {\n            clearInterval(pollingRef.current);\n            pollingRef.current = null;\n          }\n          toast.error(t(\"onboarding.permissions.errors.checkFailed\"));\n        }\n      }\n    }, 1000);\n  }, [completeOnboarding, hasWindowsMicrophoneAccess, permissionPlatform, t]);\n\n  // Cleanup polling and timeouts on unmount\n  useEffect(() => {\n    return () => {\n      if (pollingRef.current) {\n        clearInterval(pollingRef.current);\n      }\n      if (timeoutRef.current) {\n        clearTimeout(timeoutRef.current);\n      }\n    };\n  }, []);\n\n  const handleGrantAccessibility = async () => {\n    try {\n      await requestAccessibilityPermission();\n      setPermissions((prev) => ({ ...prev, accessibility: \"waiting\" }));\n      startPolling();\n    } catch (error) {\n      console.error(\"Failed to request accessibility permission:\", error);\n      toast.error(t(\"onboarding.permissions.errors.requestFailed\"));\n    }\n  };\n\n  const handleGrantMicrophone = async () => {\n    try {\n      if (isWindows) {\n        await commands.openMicrophonePrivacySettings();\n      } else {\n        await requestMicrophonePermission();\n      }\n\n      setPermissions((prev) => ({ ...prev, microphone: \"waiting\" }));\n      startPolling();\n    } catch (error) {\n      console.error(\"Failed to request microphone permission:\", error);\n      toast.error(t(\"onboarding.permissions.errors.requestFailed\"));\n    }\n  };\n\n  const isChecking =\n    permissionPlatform === null ||\n    (isMacOS &&\n      permissions.accessibility === \"checking\" &&\n      permissions.microphone === \"checking\") ||\n    (isWindows && permissions.microphone === \"checking\");\n\n  // Still checking platform/initial permissions\n  if (isChecking) {\n    return (\n      <div className=\"h-screen w-screen flex items-center justify-center\">\n        <Loader2 className=\"w-8 h-8 animate-spin text-text/50\" />\n      </div>\n    );\n  }\n\n  // All permissions granted - show success briefly\n  if (allGranted) {\n    return (\n      <div className=\"h-screen w-screen flex flex-col items-center justify-center gap-4\">\n        <div className=\"p-4 rounded-full bg-emerald-500/20\">\n          <Check className=\"w-12 h-12 text-emerald-400\" />\n        </div>\n        <p className=\"text-lg font-medium text-text\">\n          {t(\"onboarding.permissions.allGranted\")}\n        </p>\n      </div>\n    );\n  }\n\n  // Show permissions request screen\n  return (\n    <div className=\"h-screen w-screen flex flex-col p-6 gap-6 items-center justify-center\">\n      <div className=\"flex flex-col items-center gap-2\">\n        <HandyTextLogo width={200} />\n      </div>\n\n      <div className=\"max-w-md w-full flex flex-col items-center gap-4\">\n        <div className=\"text-center mb-2\">\n          <h2 className=\"text-xl font-semibold text-text mb-2\">\n            {t(\"onboarding.permissions.title\")}\n          </h2>\n          <p className=\"text-text/70\">\n            {t(\"onboarding.permissions.description\")}\n          </p>\n        </div>\n\n        {/* Microphone Permission Card */}\n        {showMicrophonePermission && (\n          <div className=\"w-full p-4 rounded-lg bg-white/5 border border-mid-gray/20\">\n            <div className=\"flex items-center gap-4\">\n              <div className=\"p-3 rounded-full bg-logo-primary/20 shrink-0\">\n                <Mic className=\"w-6 h-6 text-logo-primary\" />\n              </div>\n              <div className=\"flex-1 min-w-0\">\n                <h3 className=\"font-medium text-text\">\n                  {t(\"onboarding.permissions.microphone.title\")}\n                </h3>\n                <p className=\"text-sm text-text/60 mb-3\">\n                  {t(\"onboarding.permissions.microphone.description\")}\n                </p>\n                {permissions.microphone === \"granted\" ? (\n                  <div className=\"flex items-center gap-2 text-emerald-400 text-sm\">\n                    <Check className=\"w-4 h-4\" />\n                    {t(\"onboarding.permissions.granted\")}\n                  </div>\n                ) : permissions.microphone === \"waiting\" ? (\n                  <div className=\"flex items-center gap-2 text-text/50 text-sm\">\n                    <Loader2 className=\"w-4 h-4 animate-spin\" />\n                    {t(\"onboarding.permissions.waiting\")}\n                  </div>\n                ) : (\n                  <button\n                    onClick={handleGrantMicrophone}\n                    className=\"px-4 py-2 rounded-lg bg-logo-primary hover:bg-logo-primary/90 text-white text-sm font-medium transition-colors\"\n                  >\n                    {isWindows\n                      ? t(\"accessibility.openSettings\")\n                      : t(\"onboarding.permissions.grant\")}\n                  </button>\n                )}\n              </div>\n            </div>\n          </div>\n        )}\n\n        {/* Accessibility Permission Card */}\n        {showAccessibilityPermission && (\n          <div className=\"w-full p-4 rounded-lg bg-white/5 border border-mid-gray/20\">\n            <div className=\"flex items-center gap-4\">\n              <div className=\"p-3 rounded-full bg-logo-primary/20 shrink-0\">\n                <Keyboard className=\"w-6 h-6 text-logo-primary\" />\n              </div>\n              <div className=\"flex-1 min-w-0\">\n                <h3 className=\"font-medium text-text\">\n                  {t(\"onboarding.permissions.accessibility.title\")}\n                </h3>\n                <p className=\"text-sm text-text/60 mb-3\">\n                  {t(\"onboarding.permissions.accessibility.description\")}\n                </p>\n                {permissions.accessibility === \"granted\" ? (\n                  <div className=\"flex items-center gap-2 text-emerald-400 text-sm\">\n                    <Check className=\"w-4 h-4\" />\n                    {t(\"onboarding.permissions.granted\")}\n                  </div>\n                ) : permissions.accessibility === \"waiting\" ? (\n                  <div className=\"flex items-center gap-2 text-text/50 text-sm\">\n                    <Loader2 className=\"w-4 h-4 animate-spin\" />\n                    {t(\"onboarding.permissions.waiting\")}\n                  </div>\n                ) : (\n                  <button\n                    onClick={handleGrantAccessibility}\n                    className=\"px-4 py-2 rounded-lg bg-logo-primary hover:bg-logo-primary/90 text-white text-sm font-medium transition-colors\"\n                  >\n                    {t(\"onboarding.permissions.grant\")}\n                  </button>\n                )}\n              </div>\n            </div>\n          </div>\n        )}\n      </div>\n    </div>\n  );\n};\n\nexport default AccessibilityOnboarding;\n"
  },
  {
    "path": "src/components/onboarding/ModelCard.tsx",
    "content": "import React from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport {\n  Check,\n  Download,\n  Globe,\n  Languages,\n  Loader2,\n  Trash2,\n} from \"lucide-react\";\nimport type { ModelInfo } from \"@/bindings\";\nimport { formatModelSize } from \"../../lib/utils/format\";\nimport {\n  getTranslatedModelDescription,\n  getTranslatedModelName,\n} from \"../../lib/utils/modelTranslation\";\nimport { LANGUAGES } from \"../../lib/constants/languages\";\nimport Badge from \"../ui/Badge\";\nimport { Button } from \"../ui/Button\";\n\n// Get display text for model's language support\nconst getLanguageDisplayText = (\n  supportedLanguages: string[],\n  t: (key: string, options?: Record<string, unknown>) => string,\n): string => {\n  if (supportedLanguages.length === 1) {\n    const langCode = supportedLanguages[0];\n    const langName =\n      LANGUAGES.find((l) => l.value === langCode)?.label || langCode;\n    return t(\"modelSelector.capabilities.languageOnly\", { language: langName });\n  }\n  return t(\"modelSelector.capabilities.multiLanguage\");\n};\n\nexport type ModelCardStatus =\n  | \"downloadable\"\n  | \"downloading\"\n  | \"extracting\"\n  | \"switching\"\n  | \"active\"\n  | \"available\";\n\ninterface ModelCardProps {\n  model: ModelInfo;\n  variant?: \"default\" | \"featured\";\n  status?: ModelCardStatus;\n  disabled?: boolean;\n  className?: string;\n  onSelect: (modelId: string) => void;\n  onDownload?: (modelId: string) => void;\n  onDelete?: (modelId: string) => void;\n  onCancel?: (modelId: string) => void;\n  downloadProgress?: number;\n  downloadSpeed?: number; // MB/s\n  showRecommended?: boolean;\n}\n\nconst ModelCard: React.FC<ModelCardProps> = ({\n  model,\n  variant = \"default\",\n  status = \"downloadable\",\n  disabled = false,\n  className = \"\",\n  onSelect,\n  onDownload,\n  onDelete,\n  onCancel,\n  downloadProgress,\n  downloadSpeed,\n  showRecommended = true,\n}) => {\n  const { t } = useTranslation();\n  const isFeatured = variant === \"featured\";\n  const isClickable =\n    status === \"available\" || status === \"active\" || status === \"downloadable\";\n\n  // Get translated model name and description\n  const displayName = getTranslatedModelName(model, t);\n  const displayDescription = getTranslatedModelDescription(model, t);\n\n  const baseClasses =\n    \"flex flex-col rounded-xl px-4 py-3 gap-2 text-left transition-all duration-200\";\n\n  const getVariantClasses = () => {\n    if (status === \"active\") {\n      return \"border-2 border-logo-primary/50 bg-logo-primary/10\";\n    }\n    if (isFeatured) {\n      return \"border-2 border-logo-primary/25 bg-logo-primary/5\";\n    }\n    return \"border-2 border-mid-gray/20\";\n  };\n\n  const getInteractiveClasses = () => {\n    if (!isClickable) return \"\";\n    if (disabled) return \"opacity-50 cursor-not-allowed\";\n    return \"cursor-pointer hover:border-logo-primary/50 hover:bg-logo-primary/5 hover:shadow-lg hover:scale-[1.01] active:scale-[0.99] group\";\n  };\n\n  const handleClick = () => {\n    if (!isClickable || disabled) return;\n    if (status === \"downloadable\" && onDownload) {\n      onDownload(model.id);\n    } else {\n      onSelect(model.id);\n    }\n  };\n\n  const handleDelete = (e: React.MouseEvent) => {\n    e.stopPropagation();\n    onDelete?.(model.id);\n  };\n\n  return (\n    <div\n      onClick={handleClick}\n      onKeyDown={(e) => {\n        if (e.key === \"Enter\" && isClickable) handleClick();\n      }}\n      role={isClickable ? \"button\" : undefined}\n      tabIndex={isClickable ? 0 : undefined}\n      className={[\n        baseClasses,\n        getVariantClasses(),\n        getInteractiveClasses(),\n        className,\n      ]\n        .filter(Boolean)\n        .join(\" \")}\n    >\n      {/* Top section: name/description + score bars */}\n      <div className=\"flex justify-between items-center w-full\">\n        <div className=\"flex flex-col items-start flex-1 min-w-0\">\n          <div className=\"flex items-center gap-3 flex-wrap\">\n            <h3\n              className={`text-base font-semibold text-text ${isClickable ? \"group-hover:text-logo-primary\" : \"\"} transition-colors`}\n            >\n              {displayName}\n            </h3>\n            {showRecommended && model.is_recommended && (\n              <Badge variant=\"primary\">{t(\"onboarding.recommended\")}</Badge>\n            )}\n            {status === \"active\" && (\n              <Badge variant=\"primary\">\n                <Check className=\"w-3 h-3 mr-1\" />\n                {t(\"modelSelector.active\")}\n              </Badge>\n            )}\n            {model.is_custom && (\n              <Badge variant=\"secondary\">{t(\"modelSelector.custom\")}</Badge>\n            )}\n            {status === \"switching\" && (\n              <Badge variant=\"secondary\">\n                <Loader2 className=\"w-3 h-3 mr-1 animate-spin\" />\n                {t(\"modelSelector.switching\")}\n              </Badge>\n            )}\n          </div>\n          <p className=\"text-text/60 text-sm leading-relaxed\">\n            {displayDescription}\n          </p>\n        </div>\n        {(model.accuracy_score > 0 || model.speed_score > 0) && (\n          <div className=\"hidden sm:flex items-center ml-4\">\n            <div className=\"space-y-1\">\n              <div className=\"flex items-center gap-2\">\n                <p className=\"text-xs text-text/60 w-14 text-right\">\n                  {t(\"onboarding.modelCard.accuracy\")}\n                </p>\n                <div className=\"w-16 h-1.5 bg-mid-gray/20 rounded-full overflow-hidden\">\n                  <div\n                    className=\"h-full bg-logo-primary rounded-full\"\n                    style={{ width: `${model.accuracy_score * 100}%` }}\n                  />\n                </div>\n              </div>\n              <div className=\"flex items-center gap-2\">\n                <p className=\"text-xs text-text/60 w-14 text-right\">\n                  {t(\"onboarding.modelCard.speed\")}\n                </p>\n                <div className=\"w-16 h-1.5 bg-mid-gray/20 rounded-full overflow-hidden\">\n                  <div\n                    className=\"h-full bg-logo-primary rounded-full\"\n                    style={{ width: `${model.speed_score * 100}%` }}\n                  />\n                </div>\n              </div>\n            </div>\n          </div>\n        )}\n      </div>\n\n      <hr className=\"w-full border-mid-gray/20\" />\n\n      {/* Bottom row: tags + action buttons (full width) */}\n      <div className=\"flex items-center gap-3 w-full -mb-0.5 mt-0.5 h-5\">\n        {model.supported_languages.length > 0 && (\n          <div\n            className=\"flex items-center gap-1 text-xs text-text/50\"\n            title={\n              model.supported_languages.length === 1\n                ? t(\"modelSelector.capabilities.singleLanguage\")\n                : t(\"modelSelector.capabilities.languageSelection\")\n            }\n          >\n            <Globe className=\"w-3.5 h-3.5\" />\n            <span>{getLanguageDisplayText(model.supported_languages, t)}</span>\n          </div>\n        )}\n        {model.supports_translation && (\n          <div\n            className=\"flex items-center gap-1 text-xs text-text/50\"\n            title={t(\"modelSelector.capabilities.translation\")}\n          >\n            <Languages className=\"w-3.5 h-3.5\" />\n            <span>{t(\"modelSelector.capabilities.translate\")}</span>\n          </div>\n        )}\n        {status === \"downloadable\" && (\n          <span className=\"flex items-center gap-1.5 ml-auto text-xs text-text/50\">\n            <Download className=\"w-3.5 h-3.5\" />\n            <span>{formatModelSize(Number(model.size_mb))}</span>\n          </span>\n        )}\n        {onDelete && (status === \"available\" || status === \"active\") && (\n          <Button\n            variant=\"ghost\"\n            size=\"sm\"\n            onClick={handleDelete}\n            title={t(\"modelSelector.deleteModel\", { modelName: displayName })}\n            className=\"flex items-center gap-1.5 ml-auto text-logo-primary/85 hover:text-logo-primary hover:bg-logo-primary/10\"\n          >\n            <Trash2 className=\"w-3.5 h-3.5\" />\n            <span>{t(\"common.delete\")}</span>\n          </Button>\n        )}\n      </div>\n\n      {/* Download/extract progress */}\n      {status === \"downloading\" && downloadProgress !== undefined && (\n        <div className=\"w-full mt-3\">\n          <div className=\"w-full h-1.5 bg-mid-gray/20 rounded-full overflow-hidden\">\n            <div\n              className=\"h-full bg-logo-primary rounded-full transition-all duration-300\"\n              style={{ width: `${downloadProgress}%` }}\n            />\n          </div>\n          <div className=\"flex items-center justify-between text-xs mt-1\">\n            <span className=\"text-text/50\">\n              {t(\"modelSelector.downloading\", {\n                percentage: Math.round(downloadProgress),\n              })}\n            </span>\n            <div className=\"flex items-center gap-2\">\n              {downloadSpeed !== undefined && downloadSpeed > 0 && (\n                <span className=\"tabular-nums text-text/50\">\n                  {t(\"modelSelector.downloadSpeed\", {\n                    speed: downloadSpeed.toFixed(1),\n                  })}\n                </span>\n              )}\n              {onCancel && (\n                <Button\n                  variant=\"danger-ghost\"\n                  size=\"sm\"\n                  onClick={(e) => {\n                    e.preventDefault();\n                    e.stopPropagation();\n                    onCancel(model.id);\n                  }}\n                  aria-label={t(\"modelSelector.cancelDownload\")}\n                >\n                  {t(\"modelSelector.cancel\")}\n                </Button>\n              )}\n            </div>\n          </div>\n        </div>\n      )}\n      {status === \"extracting\" && (\n        <div className=\"w-full mt-3\">\n          <div className=\"w-full h-1.5 bg-mid-gray/20 rounded-full overflow-hidden\">\n            <div className=\"h-full bg-logo-primary rounded-full animate-pulse w-full\" />\n          </div>\n          <p className=\"text-xs text-text/50 mt-1\">\n            {t(\"modelSelector.extractingGeneric\")}\n          </p>\n        </div>\n      )}\n    </div>\n  );\n};\n\nexport default ModelCard;\n"
  },
  {
    "path": "src/components/onboarding/Onboarding.tsx",
    "content": "import React, { useEffect, useState } from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { toast } from \"sonner\";\nimport type { ModelInfo } from \"@/bindings\";\nimport type { ModelCardStatus } from \"./ModelCard\";\nimport ModelCard from \"./ModelCard\";\nimport HandyTextLogo from \"../icons/HandyTextLogo\";\nimport { useModelStore } from \"../../stores/modelStore\";\n\ninterface OnboardingProps {\n  onModelSelected: () => void;\n}\n\nconst Onboarding: React.FC<OnboardingProps> = ({ onModelSelected }) => {\n  const { t } = useTranslation();\n  const {\n    models,\n    downloadModel,\n    selectModel,\n    downloadingModels,\n    extractingModels,\n    downloadProgress,\n    downloadStats,\n  } = useModelStore();\n  const [selectedModelId, setSelectedModelId] = useState<string | null>(null);\n\n  const isDownloading = selectedModelId !== null;\n\n  // Watch for the selected model to finish downloading + extracting\n  useEffect(() => {\n    if (!selectedModelId) return;\n\n    const model = models.find((m) => m.id === selectedModelId);\n    const stillDownloading = selectedModelId in downloadingModels;\n    const stillExtracting = selectedModelId in extractingModels;\n\n    if (model?.is_downloaded && !stillDownloading && !stillExtracting) {\n      // Model is ready — select it and transition\n      selectModel(selectedModelId).then((success) => {\n        if (success) {\n          onModelSelected();\n        } else {\n          toast.error(t(\"onboarding.errors.selectModel\"));\n          setSelectedModelId(null);\n        }\n      });\n    }\n  }, [\n    selectedModelId,\n    models,\n    downloadingModels,\n    extractingModels,\n    selectModel,\n    onModelSelected,\n  ]);\n\n  const handleDownloadModel = async (modelId: string) => {\n    setSelectedModelId(modelId);\n\n    const success = await downloadModel(modelId);\n    if (!success) {\n      toast.error(t(\"onboarding.downloadFailed\"));\n      setSelectedModelId(null);\n    }\n  };\n\n  const getModelStatus = (modelId: string): ModelCardStatus => {\n    if (modelId in extractingModels) return \"extracting\";\n    if (modelId in downloadingModels) return \"downloading\";\n    return \"downloadable\";\n  };\n\n  const getModelDownloadProgress = (modelId: string): number | undefined => {\n    return downloadProgress[modelId]?.percentage;\n  };\n\n  const getModelDownloadSpeed = (modelId: string): number | undefined => {\n    return downloadStats[modelId]?.speed;\n  };\n\n  return (\n    <div className=\"h-screen w-screen flex flex-col p-6 gap-4 inset-0\">\n      <div className=\"flex flex-col items-center gap-2 shrink-0\">\n        <HandyTextLogo width={200} />\n        <p className=\"text-text/70 max-w-md font-medium mx-auto\">\n          {t(\"onboarding.subtitle\")}\n        </p>\n      </div>\n\n      <div className=\"max-w-[600px] w-full mx-auto text-center flex-1 flex flex-col min-h-0\">\n        <div className=\"flex flex-col gap-4 pb-6\">\n          {models\n            .filter((m: ModelInfo) => !m.is_downloaded)\n            .filter((model: ModelInfo) => model.is_recommended)\n            .map((model: ModelInfo) => (\n              <ModelCard\n                key={model.id}\n                model={model}\n                variant=\"featured\"\n                status={getModelStatus(model.id)}\n                disabled={isDownloading}\n                onSelect={handleDownloadModel}\n                onDownload={handleDownloadModel}\n                downloadProgress={getModelDownloadProgress(model.id)}\n                downloadSpeed={getModelDownloadSpeed(model.id)}\n              />\n            ))}\n\n          {models\n            .filter((m: ModelInfo) => !m.is_downloaded)\n            .filter((model: ModelInfo) => !model.is_recommended)\n            .sort(\n              (a: ModelInfo, b: ModelInfo) =>\n                Number(a.size_mb) - Number(b.size_mb),\n            )\n            .map((model: ModelInfo) => (\n              <ModelCard\n                key={model.id}\n                model={model}\n                status={getModelStatus(model.id)}\n                disabled={isDownloading}\n                onSelect={handleDownloadModel}\n                onDownload={handleDownloadModel}\n                downloadProgress={getModelDownloadProgress(model.id)}\n                downloadSpeed={getModelDownloadSpeed(model.id)}\n              />\n            ))}\n        </div>\n      </div>\n    </div>\n  );\n};\n\nexport default Onboarding;\n"
  },
  {
    "path": "src/components/onboarding/index.ts",
    "content": "export { default } from \"./Onboarding\";\nexport { default as AccessibilityOnboarding } from \"./AccessibilityOnboarding\";\nexport { default as ModelCard } from \"./ModelCard\";\nexport type { ModelCardStatus } from \"./ModelCard\";\n"
  },
  {
    "path": "src/components/settings/AccelerationSelector.tsx",
    "content": "import { type FC, useEffect, useState } from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { SettingContainer } from \"../ui/SettingContainer\";\nimport { Dropdown, type DropdownOption } from \"../ui/Dropdown\";\nimport { useSettings } from \"../../hooks/useSettings\";\nimport { commands } from \"@/bindings\";\nimport type {\n  WhisperAcceleratorSetting,\n  OrtAcceleratorSetting,\n} from \"@/bindings\";\n\nconst WHISPER_LABELS: Record<WhisperAcceleratorSetting, string> = {\n  auto: \"Auto\",\n  cpu: \"CPU\",\n  gpu: \"GPU\",\n};\n\nconst ORT_LABELS: Record<OrtAcceleratorSetting, string> = {\n  auto: \"Auto\",\n  cpu: \"CPU\",\n  cuda: \"CUDA\",\n  directml: \"DirectML\",\n  rocm: \"ROCm\",\n};\n\ninterface AccelerationSelectorProps {\n  descriptionMode?: \"tooltip\" | \"inline\";\n  grouped?: boolean;\n}\n\nexport const AccelerationSelector: FC<AccelerationSelectorProps> = ({\n  descriptionMode = \"tooltip\",\n  grouped = false,\n}) => {\n  const { t } = useTranslation();\n  const { getSetting, updateSetting, isUpdating } = useSettings();\n\n  const [whisperOptions, setWhisperOptions] = useState<DropdownOption[]>([]);\n  const [ortOptions, setOrtOptions] = useState<DropdownOption[]>([]);\n\n  useEffect(() => {\n    commands.getAvailableAccelerators().then((available) => {\n      setWhisperOptions(\n        available.whisper.map((v) => ({\n          value: v,\n          label: WHISPER_LABELS[v as WhisperAcceleratorSetting] ?? v,\n        })),\n      );\n      // Always include \"auto\" for ORT even though available() only returns compiled-in backends\n      const ortVals = available.ort.includes(\"auto\")\n        ? available.ort\n        : [\"auto\", ...available.ort];\n      setOrtOptions(\n        ortVals.map((v) => ({\n          value: v,\n          label: ORT_LABELS[v as OrtAcceleratorSetting] ?? v,\n        })),\n      );\n    });\n  }, []);\n\n  const currentWhisper = getSetting(\"whisper_accelerator\") ?? \"auto\";\n  const currentOrt = getSetting(\"ort_accelerator\") ?? \"auto\";\n\n  return (\n    <>\n      <SettingContainer\n        title={t(\"settings.advanced.acceleration.whisper.title\")}\n        description={t(\"settings.advanced.acceleration.whisper.description\")}\n        descriptionMode={descriptionMode}\n        grouped={grouped}\n        layout=\"horizontal\"\n      >\n        <Dropdown\n          options={whisperOptions}\n          selectedValue={currentWhisper}\n          onSelect={(value) =>\n            updateSetting(\n              \"whisper_accelerator\",\n              value as WhisperAcceleratorSetting,\n            )\n          }\n          disabled={isUpdating(\"whisper_accelerator\")}\n        />\n      </SettingContainer>\n      {ortOptions.length > 2 && (\n        <SettingContainer\n          title={t(\"settings.advanced.acceleration.ort.title\")}\n          description={t(\"settings.advanced.acceleration.ort.description\")}\n          descriptionMode={descriptionMode}\n          grouped={grouped}\n          layout=\"horizontal\"\n        >\n          <Dropdown\n            options={ortOptions}\n            selectedValue={currentOrt}\n            onSelect={(value) =>\n              updateSetting(\"ort_accelerator\", value as OrtAcceleratorSetting)\n            }\n            disabled={isUpdating(\"ort_accelerator\")}\n          />\n        </SettingContainer>\n      )}\n    </>\n  );\n};\n"
  },
  {
    "path": "src/components/settings/AlwaysOnMicrophone.tsx",
    "content": "import React from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { ToggleSwitch } from \"../ui/ToggleSwitch\";\nimport { useSettings } from \"../../hooks/useSettings\";\n\ninterface AlwaysOnMicrophoneProps {\n  descriptionMode?: \"inline\" | \"tooltip\";\n  grouped?: boolean;\n}\n\nexport const AlwaysOnMicrophone: React.FC<AlwaysOnMicrophoneProps> = React.memo(\n  ({ descriptionMode = \"tooltip\", grouped = false }) => {\n    const { t } = useTranslation();\n    const { getSetting, updateSetting, isUpdating } = useSettings();\n\n    const alwaysOnMode = getSetting(\"always_on_microphone\") || false;\n\n    return (\n      <ToggleSwitch\n        checked={alwaysOnMode}\n        onChange={(enabled) => updateSetting(\"always_on_microphone\", enabled)}\n        isUpdating={isUpdating(\"always_on_microphone\")}\n        label={t(\"settings.debug.alwaysOnMicrophone.label\")}\n        description={t(\"settings.debug.alwaysOnMicrophone.description\")}\n        descriptionMode={descriptionMode}\n        grouped={grouped}\n      />\n    );\n  },\n);\n"
  },
  {
    "path": "src/components/settings/AppDataDirectory.tsx",
    "content": "import React, { useState, useEffect } from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { commands } from \"@/bindings\";\nimport { SettingContainer } from \"../ui/SettingContainer\";\nimport { PathDisplay } from \"../ui/PathDisplay\";\n\ninterface AppDataDirectoryProps {\n  descriptionMode?: \"tooltip\" | \"inline\";\n  grouped?: boolean;\n}\n\nexport const AppDataDirectory: React.FC<AppDataDirectoryProps> = ({\n  descriptionMode = \"inline\",\n  grouped = false,\n}) => {\n  const { t } = useTranslation();\n  const [appDirPath, setAppDirPath] = useState<string>(\"\");\n  const [loading, setLoading] = useState(true);\n  const [error, setError] = useState<string | null>(null);\n\n  useEffect(() => {\n    const loadAppDirectory = async () => {\n      try {\n        const result = await commands.getAppDirPath();\n        if (result.status === \"ok\") {\n          setAppDirPath(result.data);\n        } else {\n          setError(result.error);\n        }\n      } catch (err) {\n        setError(\n          err instanceof Error ? err.message : \"Failed to load app directory\",\n        );\n      } finally {\n        setLoading(false);\n      }\n    };\n\n    loadAppDirectory();\n  }, []);\n\n  const handleOpen = async () => {\n    if (!appDirPath) return;\n    try {\n      await commands.openAppDataDir();\n    } catch (openError) {\n      console.error(\"Failed to open app data directory:\", openError);\n    }\n  };\n\n  if (loading) {\n    return (\n      <div className=\"animate-pulse\">\n        <div className=\"h-4 bg-gray-200 rounded w-1/3 mb-2\"></div>\n        <div className=\"h-8 bg-gray-100 rounded\"></div>\n      </div>\n    );\n  }\n\n  if (error) {\n    return (\n      <div className=\"p-4 bg-red-50 border border-red-200 rounded-lg\">\n        <p className=\"text-red-600 text-sm\">\n          {t(\"errors.loadDirectory\", { error })}\n        </p>\n      </div>\n    );\n  }\n\n  return (\n    <SettingContainer\n      title={t(\"settings.about.appDataDirectory.title\")}\n      description={t(\"settings.about.appDataDirectory.description\")}\n      descriptionMode={descriptionMode}\n      grouped={grouped}\n      layout=\"stacked\"\n    >\n      <PathDisplay\n        path={appDirPath}\n        onOpen={handleOpen}\n        disabled={!appDirPath}\n      />\n    </SettingContainer>\n  );\n};\n"
  },
  {
    "path": "src/components/settings/AppLanguageSelector.tsx",
    "content": "import React from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { Dropdown } from \"../ui/Dropdown\";\nimport { SettingContainer } from \"../ui/SettingContainer\";\nimport { SUPPORTED_LANGUAGES, type SupportedLanguageCode } from \"../../i18n\";\nimport { useSettings } from \"@/hooks/useSettings\";\n\ninterface AppLanguageSelectorProps {\n  descriptionMode?: \"inline\" | \"tooltip\";\n  grouped?: boolean;\n}\n\nexport const AppLanguageSelector: React.FC<AppLanguageSelectorProps> =\n  React.memo(({ descriptionMode = \"tooltip\", grouped = false }) => {\n    const { t, i18n } = useTranslation();\n    const { settings, updateSetting } = useSettings();\n\n    const currentLanguage = (settings?.app_language ||\n      i18n.language) as SupportedLanguageCode;\n\n    const languageOptions = SUPPORTED_LANGUAGES.map((lang) => ({\n      value: lang.code,\n      label: `${lang.nativeName} (${lang.name})`,\n    }));\n\n    const handleLanguageChange = (langCode: string) => {\n      i18n.changeLanguage(langCode);\n      updateSetting(\"app_language\", langCode);\n    };\n\n    return (\n      <SettingContainer\n        title={t(\"appLanguage.title\")}\n        description={t(\"appLanguage.description\")}\n        descriptionMode={descriptionMode}\n        grouped={grouped}\n      >\n        <Dropdown\n          options={languageOptions}\n          selectedValue={currentLanguage}\n          onSelect={handleLanguageChange}\n        />\n      </SettingContainer>\n    );\n  });\n\nAppLanguageSelector.displayName = \"AppLanguageSelector\";\n"
  },
  {
    "path": "src/components/settings/AppendTrailingSpace.tsx",
    "content": "import React from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { ToggleSwitch } from \"../ui/ToggleSwitch\";\nimport { useSettings } from \"../../hooks/useSettings\";\n\ninterface AppendTrailingSpaceProps {\n  descriptionMode?: \"inline\" | \"tooltip\";\n  grouped?: boolean;\n}\n\nexport const AppendTrailingSpace: React.FC<AppendTrailingSpaceProps> =\n  React.memo(({ descriptionMode = \"tooltip\", grouped = false }) => {\n    const { t } = useTranslation();\n    const { getSetting, updateSetting, isUpdating } = useSettings();\n\n    const enabled = getSetting(\"append_trailing_space\") ?? false;\n\n    return (\n      <ToggleSwitch\n        checked={enabled}\n        onChange={(enabled) => updateSetting(\"append_trailing_space\", enabled)}\n        isUpdating={isUpdating(\"append_trailing_space\")}\n        label={t(\"settings.debug.appendTrailingSpace.label\")}\n        description={t(\"settings.debug.appendTrailingSpace.description\")}\n        descriptionMode={descriptionMode}\n        grouped={grouped}\n      />\n    );\n  });\n"
  },
  {
    "path": "src/components/settings/AudioFeedback.tsx",
    "content": "import React from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { ToggleSwitch } from \"../ui/ToggleSwitch\";\nimport { useSettings } from \"../../hooks/useSettings\";\nimport { VolumeSlider } from \"./VolumeSlider\";\nimport { SoundPicker } from \"./SoundPicker\";\n\ninterface AudioFeedbackProps {\n  descriptionMode?: \"inline\" | \"tooltip\";\n  grouped?: boolean;\n}\n\nexport const AudioFeedback: React.FC<AudioFeedbackProps> = React.memo(\n  ({ descriptionMode = \"tooltip\", grouped = false }) => {\n    const { t } = useTranslation();\n    const { getSetting, updateSetting, isUpdating } = useSettings();\n    const audioFeedbackEnabled = getSetting(\"audio_feedback\") || false;\n\n    return (\n      <div className=\"flex flex-col\">\n        <ToggleSwitch\n          checked={audioFeedbackEnabled}\n          onChange={(enabled) => updateSetting(\"audio_feedback\", enabled)}\n          isUpdating={isUpdating(\"audio_feedback\")}\n          label={t(\"settings.sound.audioFeedback.label\")}\n          description={t(\"settings.sound.audioFeedback.description\")}\n          descriptionMode={descriptionMode}\n          grouped={grouped}\n        />\n      </div>\n    );\n  },\n);\n"
  },
  {
    "path": "src/components/settings/AutoSubmit.tsx",
    "content": "import React from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { Dropdown } from \"../ui/Dropdown\";\nimport { SettingContainer } from \"../ui/SettingContainer\";\nimport { useSettings } from \"../../hooks/useSettings\";\nimport { useOsType } from \"../../hooks/useOsType\";\nimport type { AutoSubmitKey } from \"@/bindings\";\n\ninterface AutoSubmitProps {\n  descriptionMode?: \"inline\" | \"tooltip\";\n  grouped?: boolean;\n}\n\ntype AutoSubmitOptionValue = AutoSubmitKey | \"off\";\n\nexport const AutoSubmit: React.FC<AutoSubmitProps> = React.memo(\n  ({ descriptionMode = \"tooltip\", grouped = false }) => {\n    const { t } = useTranslation();\n    const osType = useOsType();\n    const { getSetting, updateSetting, isUpdating } = useSettings();\n\n    const enabled = getSetting(\"auto_submit\") ?? false;\n    const selectedKey = (getSetting(\"auto_submit_key\") ||\n      \"enter\") as AutoSubmitKey;\n    const selectedValue: AutoSubmitOptionValue = enabled ? selectedKey : \"off\";\n    const submitWithMetaLabel =\n      osType === \"macos\"\n        ? t(\"settings.advanced.autoSubmit.options.cmdEnter\")\n        : t(\"settings.advanced.autoSubmit.options.superEnter\");\n\n    const autoSubmitOptions = [\n      {\n        value: \"off\",\n        label: t(\"settings.advanced.autoSubmit.options.off\"),\n      },\n      {\n        value: \"enter\",\n        label: t(\"settings.advanced.autoSubmit.options.enter\"),\n      },\n      {\n        value: \"ctrl_enter\",\n        label: t(\"settings.advanced.autoSubmit.options.ctrlEnter\"),\n      },\n      {\n        value: \"cmd_enter\",\n        label: submitWithMetaLabel,\n      },\n    ];\n\n    const handleAutoSubmitSelect = async (value: string) => {\n      const selected = value as AutoSubmitOptionValue;\n\n      if (selected === \"off\") {\n        await updateSetting(\"auto_submit\", false);\n        return;\n      }\n\n      await updateSetting(\"auto_submit_key\", selected as AutoSubmitKey);\n      if (!enabled) {\n        await updateSetting(\"auto_submit\", true);\n      }\n    };\n\n    return (\n      <SettingContainer\n        title={t(\"settings.advanced.autoSubmit.title\")}\n        description={t(\"settings.advanced.autoSubmit.description\")}\n        descriptionMode={descriptionMode}\n        grouped={grouped}\n      >\n        <Dropdown\n          options={autoSubmitOptions}\n          selectedValue={selectedValue}\n          onSelect={handleAutoSubmitSelect}\n          disabled={isUpdating(\"auto_submit\") || isUpdating(\"auto_submit_key\")}\n        />\n      </SettingContainer>\n    );\n  },\n);\n"
  },
  {
    "path": "src/components/settings/AutostartToggle.tsx",
    "content": "import React from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { ToggleSwitch } from \"../ui/ToggleSwitch\";\nimport { useSettings } from \"../../hooks/useSettings\";\n\ninterface AutostartToggleProps {\n  descriptionMode?: \"inline\" | \"tooltip\";\n  grouped?: boolean;\n}\n\nexport const AutostartToggle: React.FC<AutostartToggleProps> = React.memo(\n  ({ descriptionMode = \"tooltip\", grouped = false }) => {\n    const { t } = useTranslation();\n    const { getSetting, updateSetting, isUpdating } = useSettings();\n\n    const autostartEnabled = getSetting(\"autostart_enabled\") ?? false;\n\n    return (\n      <ToggleSwitch\n        checked={autostartEnabled}\n        onChange={(enabled) => updateSetting(\"autostart_enabled\", enabled)}\n        isUpdating={isUpdating(\"autostart_enabled\")}\n        label={t(\"settings.advanced.autostart.label\")}\n        description={t(\"settings.advanced.autostart.description\")}\n        descriptionMode={descriptionMode}\n        grouped={grouped}\n      />\n    );\n  },\n);\n"
  },
  {
    "path": "src/components/settings/ClamshellMicrophoneSelector.tsx",
    "content": "import React, { useState, useEffect } from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { commands } from \"@/bindings\";\nimport { Dropdown } from \"../ui/Dropdown\";\nimport { SettingContainer } from \"../ui/SettingContainer\";\nimport { ResetButton } from \"../ui/ResetButton\";\nimport { useSettings } from \"../../hooks/useSettings\";\n\ninterface ClamshellMicrophoneSelectorProps {\n  descriptionMode?: \"inline\" | \"tooltip\";\n  grouped?: boolean;\n}\n\nexport const ClamshellMicrophoneSelector: React.FC<ClamshellMicrophoneSelectorProps> =\n  React.memo(({ descriptionMode = \"tooltip\", grouped = false }) => {\n    const { t } = useTranslation();\n    const {\n      getSetting,\n      updateSetting,\n      resetSetting,\n      isUpdating,\n      isLoading,\n      audioDevices,\n      refreshAudioDevices,\n    } = useSettings();\n\n    const [isLaptop, setIsLaptop] = useState<boolean>(false);\n\n    useEffect(() => {\n      const checkIsLaptop = async () => {\n        try {\n          const result = await commands.isLaptop();\n          if (result.status === \"ok\") {\n            setIsLaptop(result.data);\n          } else {\n            setIsLaptop(false);\n          }\n        } catch (error) {\n          console.error(\"Failed to check if device is laptop:\", error);\n          setIsLaptop(false);\n        }\n      };\n\n      checkIsLaptop();\n    }, []);\n\n    // Only render on laptops\n    if (!isLaptop) {\n      return null;\n    }\n\n    const selectedClamshellMicrophone =\n      getSetting(\"clamshell_microphone\") === \"default\"\n        ? \"Default\"\n        : getSetting(\"clamshell_microphone\") || \"Default\";\n\n    const handleClamshellMicrophoneSelect = async (deviceName: string) => {\n      await updateSetting(\"clamshell_microphone\", deviceName);\n    };\n\n    const handleReset = async () => {\n      await resetSetting(\"clamshell_microphone\");\n    };\n\n    const microphoneOptions = audioDevices.map((device) => ({\n      value: device.name,\n      label: device.name,\n    }));\n\n    return (\n      <SettingContainer\n        title={t(\"settings.debug.clamshellMicrophone.title\")}\n        description={t(\"settings.debug.clamshellMicrophone.description\")}\n        descriptionMode={descriptionMode}\n        grouped={grouped}\n      >\n        <div className=\"flex items-center space-x-1\">\n          <Dropdown\n            options={microphoneOptions}\n            selectedValue={selectedClamshellMicrophone}\n            onSelect={handleClamshellMicrophoneSelect}\n            placeholder={\n              isLoading || audioDevices.length === 0\n                ? t(\"common.loading\")\n                : t(\"settings.sound.microphone.placeholder\")\n            }\n            disabled={\n              isUpdating(\"clamshell_microphone\") ||\n              isLoading ||\n              audioDevices.length === 0\n            }\n            onRefresh={refreshAudioDevices}\n          />\n          <ResetButton\n            onClick={handleReset}\n            disabled={isUpdating(\"clamshell_microphone\") || isLoading}\n          />\n        </div>\n      </SettingContainer>\n    );\n  });\n\nClamshellMicrophoneSelector.displayName = \"ClamshellMicrophoneSelector\";\n"
  },
  {
    "path": "src/components/settings/ClipboardHandling.tsx",
    "content": "import React from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { Dropdown } from \"../ui/Dropdown\";\nimport { SettingContainer } from \"../ui/SettingContainer\";\nimport { useSettings } from \"../../hooks/useSettings\";\nimport type { ClipboardHandling } from \"@/bindings\";\n\ninterface ClipboardHandlingProps {\n  descriptionMode?: \"inline\" | \"tooltip\";\n  grouped?: boolean;\n}\n\nexport const ClipboardHandlingSetting: React.FC<ClipboardHandlingProps> =\n  React.memo(({ descriptionMode = \"tooltip\", grouped = false }) => {\n    const { t } = useTranslation();\n    const { getSetting, updateSetting, isUpdating } = useSettings();\n\n    const clipboardHandlingOptions = [\n      {\n        value: \"dont_modify\",\n        label: t(\"settings.advanced.clipboardHandling.options.dontModify\"),\n      },\n      {\n        value: \"copy_to_clipboard\",\n        label: t(\"settings.advanced.clipboardHandling.options.copyToClipboard\"),\n      },\n    ];\n\n    const selectedHandling = (getSetting(\"clipboard_handling\") ||\n      \"dont_modify\") as ClipboardHandling;\n\n    return (\n      <SettingContainer\n        title={t(\"settings.advanced.clipboardHandling.title\")}\n        description={t(\"settings.advanced.clipboardHandling.description\")}\n        descriptionMode={descriptionMode}\n        grouped={grouped}\n      >\n        <Dropdown\n          options={clipboardHandlingOptions}\n          selectedValue={selectedHandling}\n          onSelect={(value) =>\n            updateSetting(\"clipboard_handling\", value as ClipboardHandling)\n          }\n          disabled={isUpdating(\"clipboard_handling\")}\n        />\n      </SettingContainer>\n    );\n  });\n"
  },
  {
    "path": "src/components/settings/CustomWords.tsx",
    "content": "import React, { useState } from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { toast } from \"sonner\";\nimport { useSettings } from \"../../hooks/useSettings\";\nimport { Input } from \"../ui/Input\";\nimport { Button } from \"../ui/Button\";\nimport { SettingContainer } from \"../ui/SettingContainer\";\n\ninterface CustomWordsProps {\n  descriptionMode?: \"inline\" | \"tooltip\";\n  grouped?: boolean;\n}\n\nexport const CustomWords: React.FC<CustomWordsProps> = React.memo(\n  ({ descriptionMode = \"tooltip\", grouped = false }) => {\n    const { t } = useTranslation();\n    const { getSetting, updateSetting, isUpdating } = useSettings();\n    const [newWord, setNewWord] = useState(\"\");\n    const customWords = getSetting(\"custom_words\") || [];\n\n    const handleAddWord = () => {\n      const trimmedWord = newWord.trim();\n      const sanitizedWord = trimmedWord.replace(/[<>\"'&]/g, \"\");\n      if (\n        sanitizedWord &&\n        !sanitizedWord.includes(\" \") &&\n        sanitizedWord.length <= 50\n      ) {\n        if (customWords.includes(sanitizedWord)) {\n          toast.error(\n            t(\"settings.advanced.customWords.duplicate\", {\n              word: sanitizedWord,\n            }),\n          );\n          return;\n        }\n        updateSetting(\"custom_words\", [...customWords, sanitizedWord]);\n        setNewWord(\"\");\n      }\n    };\n\n    const handleRemoveWord = (wordToRemove: string) => {\n      updateSetting(\n        \"custom_words\",\n        customWords.filter((word) => word !== wordToRemove),\n      );\n    };\n\n    const handleKeyPress = (e: React.KeyboardEvent) => {\n      if (e.key === \"Enter\") {\n        e.preventDefault();\n        handleAddWord();\n      }\n    };\n\n    return (\n      <>\n        <SettingContainer\n          title={t(\"settings.advanced.customWords.title\")}\n          description={t(\"settings.advanced.customWords.description\")}\n          descriptionMode={descriptionMode}\n          grouped={grouped}\n        >\n          <div className=\"flex items-center gap-2\">\n            <Input\n              type=\"text\"\n              className=\"max-w-40\"\n              value={newWord}\n              onChange={(e) => setNewWord(e.target.value)}\n              onKeyDown={handleKeyPress}\n              placeholder={t(\"settings.advanced.customWords.placeholder\")}\n              variant=\"compact\"\n              disabled={isUpdating(\"custom_words\")}\n            />\n            <Button\n              onClick={handleAddWord}\n              disabled={\n                !newWord.trim() ||\n                newWord.includes(\" \") ||\n                newWord.trim().length > 50 ||\n                isUpdating(\"custom_words\")\n              }\n              variant=\"primary\"\n              size=\"md\"\n            >\n              {t(\"settings.advanced.customWords.add\")}\n            </Button>\n          </div>\n        </SettingContainer>\n        {customWords.length > 0 && (\n          <div\n            className={`px-4 p-2 ${grouped ? \"\" : \"rounded-lg border border-mid-gray/20\"} flex flex-wrap gap-1`}\n          >\n            {customWords.map((word) => (\n              <Button\n                key={word}\n                onClick={() => handleRemoveWord(word)}\n                disabled={isUpdating(\"custom_words\")}\n                variant=\"secondary\"\n                size=\"sm\"\n                className=\"inline-flex items-center gap-1 cursor-pointer\"\n                aria-label={t(\"settings.advanced.customWords.remove\", { word })}\n              >\n                <span>{word}</span>\n                <svg\n                  className=\"w-3 h-3\"\n                  fill=\"none\"\n                  stroke=\"currentColor\"\n                  viewBox=\"0 0 24 24\"\n                >\n                  <path\n                    strokeLinecap=\"round\"\n                    strokeLinejoin=\"round\"\n                    strokeWidth={2}\n                    d=\"M6 18L18 6M6 6l12 12\"\n                  />\n                </svg>\n              </Button>\n            ))}\n          </div>\n        )}\n      </>\n    );\n  },\n);\n"
  },
  {
    "path": "src/components/settings/ExperimentalToggle.tsx",
    "content": "import React from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { ToggleSwitch } from \"../ui/ToggleSwitch\";\nimport { useSettings } from \"../../hooks/useSettings\";\n\ninterface ExperimentalToggleProps {\n  descriptionMode?: \"inline\" | \"tooltip\";\n  grouped?: boolean;\n}\n\nexport const ExperimentalToggle: React.FC<ExperimentalToggleProps> = React.memo(\n  ({ descriptionMode = \"tooltip\", grouped = false }) => {\n    const { t } = useTranslation();\n    const { getSetting, updateSetting, isUpdating } = useSettings();\n\n    const enabled = getSetting(\"experimental_enabled\") || false;\n\n    return (\n      <ToggleSwitch\n        checked={enabled}\n        onChange={(enabled) => updateSetting(\"experimental_enabled\", enabled)}\n        isUpdating={isUpdating(\"experimental_enabled\")}\n        label={t(\"settings.advanced.experimentalToggle.label\")}\n        description={t(\"settings.advanced.experimentalToggle.description\")}\n        descriptionMode={descriptionMode}\n        grouped={grouped}\n      />\n    );\n  },\n);\n"
  },
  {
    "path": "src/components/settings/GlobalShortcutInput.tsx",
    "content": "import React, { useEffect, useState, useRef } from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport {\n  getKeyName,\n  formatKeyCombination,\n  normalizeKey,\n} from \"../../lib/utils/keyboard\";\nimport { ResetButton } from \"../ui/ResetButton\";\nimport { SettingContainer } from \"../ui/SettingContainer\";\nimport { useSettings } from \"../../hooks/useSettings\";\nimport { useOsType } from \"../../hooks/useOsType\";\nimport { commands } from \"@/bindings\";\nimport { toast } from \"sonner\";\n\ninterface GlobalShortcutInputProps {\n  descriptionMode?: \"inline\" | \"tooltip\";\n  grouped?: boolean;\n  shortcutId: string;\n  disabled?: boolean;\n}\n\nexport const GlobalShortcutInput: React.FC<GlobalShortcutInputProps> = ({\n  descriptionMode = \"tooltip\",\n  grouped = false,\n  shortcutId,\n  disabled = false,\n}) => {\n  const { t } = useTranslation();\n  const { getSetting, updateBinding, resetBinding, isUpdating, isLoading } =\n    useSettings();\n  const [keyPressed, setKeyPressed] = useState<string[]>([]);\n  const [recordedKeys, setRecordedKeys] = useState<string[]>([]);\n  const [editingShortcutId, setEditingShortcutId] = useState<string | null>(\n    null,\n  );\n  const [originalBinding, setOriginalBinding] = useState<string>(\"\");\n  const shortcutRefs = useRef<Map<string, HTMLDivElement | null>>(new Map());\n  const osType = useOsType();\n\n  const bindings = getSetting(\"bindings\") || {};\n\n  useEffect(() => {\n    // Only add event listeners when we're in editing mode\n    if (editingShortcutId === null) return;\n\n    let cleanup = false;\n\n    // Keyboard event listeners\n    const handleKeyDown = async (e: KeyboardEvent) => {\n      if (cleanup) return;\n      if (e.repeat) return; // ignore auto-repeat\n      if (e.key === \"Escape\") {\n        // Cancel recording and restore original binding\n        if (editingShortcutId && originalBinding) {\n          try {\n            await updateBinding(editingShortcutId, originalBinding);\n          } catch (error) {\n            console.error(\"Failed to restore original binding:\", error);\n            toast.error(t(\"settings.general.shortcut.errors.restore\"));\n          }\n        } else if (editingShortcutId) {\n          await commands.resumeBinding(editingShortcutId).catch(console.error);\n        }\n        setEditingShortcutId(null);\n        setKeyPressed([]);\n        setRecordedKeys([]);\n        setOriginalBinding(\"\");\n        return;\n      }\n      e.preventDefault();\n\n      // Get the key with OS-specific naming and normalize it\n      const rawKey = getKeyName(e, osType);\n      const key = normalizeKey(rawKey);\n\n      if (!keyPressed.includes(key)) {\n        setKeyPressed((prev) => [...prev, key]);\n        // Also add to recorded keys if not already there\n        if (!recordedKeys.includes(key)) {\n          setRecordedKeys((prev) => [...prev, key]);\n        }\n      }\n    };\n\n    const handleKeyUp = async (e: KeyboardEvent) => {\n      if (cleanup) return;\n      e.preventDefault();\n\n      // Get the key with OS-specific naming and normalize it\n      const rawKey = getKeyName(e, osType);\n      const key = normalizeKey(rawKey);\n\n      // Remove from currently pressed keys\n      setKeyPressed((prev) => prev.filter((k) => k !== key));\n\n      // If no keys are pressed anymore, commit the shortcut\n      const updatedKeyPressed = keyPressed.filter((k) => k !== key);\n      if (updatedKeyPressed.length === 0 && recordedKeys.length > 0) {\n        // Create the shortcut string from all recorded keys\n        // Sort keys so modifiers come first, then the main key\n        const modifiers = [\n          \"ctrl\",\n          \"control\",\n          \"shift\",\n          \"alt\",\n          \"option\",\n          \"meta\",\n          \"command\",\n          \"cmd\",\n          \"super\",\n          \"win\",\n          \"windows\",\n        ];\n        const sortedKeys = recordedKeys.sort((a, b) => {\n          const aIsModifier = modifiers.includes(a.toLowerCase());\n          const bIsModifier = modifiers.includes(b.toLowerCase());\n          if (aIsModifier && !bIsModifier) return -1;\n          if (!aIsModifier && bIsModifier) return 1;\n          return 0;\n        });\n        const newShortcut = sortedKeys.join(\"+\");\n\n        if (editingShortcutId && bindings[editingShortcutId]) {\n          try {\n            await updateBinding(editingShortcutId, newShortcut);\n          } catch (error) {\n            console.error(\"Failed to change binding:\", error);\n            toast.error(\n              t(\"settings.general.shortcut.errors.set\", {\n                error: String(error),\n              }),\n            );\n\n            // Reset to original binding on error\n            if (originalBinding) {\n              try {\n                await updateBinding(editingShortcutId, originalBinding);\n              } catch (resetError) {\n                console.error(\"Failed to reset binding:\", resetError);\n                toast.error(t(\"settings.general.shortcut.errors.reset\"));\n              }\n            }\n          }\n\n          // Exit editing mode and reset states\n          setEditingShortcutId(null);\n          setKeyPressed([]);\n          setRecordedKeys([]);\n          setOriginalBinding(\"\");\n        }\n      }\n    };\n\n    // Add click outside handler\n    const handleClickOutside = async (e: MouseEvent) => {\n      if (cleanup) return;\n      const activeElement = shortcutRefs.current.get(editingShortcutId);\n      if (activeElement && !activeElement.contains(e.target as Node)) {\n        // Cancel shortcut recording and restore original binding\n        if (editingShortcutId && originalBinding) {\n          try {\n            await updateBinding(editingShortcutId, originalBinding);\n          } catch (error) {\n            console.error(\"Failed to restore original binding:\", error);\n            toast.error(t(\"settings.general.shortcut.errors.restore\"));\n          }\n        } else if (editingShortcutId) {\n          commands.resumeBinding(editingShortcutId).catch(console.error);\n        }\n        setEditingShortcutId(null);\n        setKeyPressed([]);\n        setRecordedKeys([]);\n        setOriginalBinding(\"\");\n      }\n    };\n\n    window.addEventListener(\"keydown\", handleKeyDown);\n    window.addEventListener(\"keyup\", handleKeyUp);\n    window.addEventListener(\"click\", handleClickOutside);\n\n    return () => {\n      cleanup = true;\n      window.removeEventListener(\"keydown\", handleKeyDown);\n      window.removeEventListener(\"keyup\", handleKeyUp);\n      window.removeEventListener(\"click\", handleClickOutside);\n    };\n  }, [\n    keyPressed,\n    recordedKeys,\n    editingShortcutId,\n    bindings,\n    originalBinding,\n    updateBinding,\n    osType,\n  ]);\n\n  // Start recording a new shortcut\n  const startRecording = async (id: string) => {\n    if (editingShortcutId === id) return; // Already editing this shortcut\n\n    // Suspend current binding to avoid firing while recording\n    await commands.suspendBinding(id).catch(console.error);\n\n    // Store the original binding to restore if canceled\n    setOriginalBinding(bindings[id]?.current_binding || \"\");\n    setEditingShortcutId(id);\n    setKeyPressed([]);\n    setRecordedKeys([]);\n  };\n\n  // Format the current shortcut keys being recorded\n  const formatCurrentKeys = (): string => {\n    if (recordedKeys.length === 0)\n      return t(\"settings.general.shortcut.pressKeys\");\n\n    // Use the same formatting as the display to ensure consistency\n    return formatKeyCombination(recordedKeys.join(\"+\"), osType);\n  };\n\n  // Store references to shortcut elements\n  const setShortcutRef = (id: string, ref: HTMLDivElement | null) => {\n    shortcutRefs.current.set(id, ref);\n  };\n\n  // If still loading, show loading state\n  if (isLoading) {\n    return (\n      <SettingContainer\n        title={t(\"settings.general.shortcut.title\")}\n        description={t(\"settings.general.shortcut.description\")}\n        descriptionMode={descriptionMode}\n        grouped={grouped}\n      >\n        <div className=\"text-sm text-mid-gray\">\n          {t(\"settings.general.shortcut.loading\")}\n        </div>\n      </SettingContainer>\n    );\n  }\n\n  // If no bindings are loaded, show empty state\n  if (Object.keys(bindings).length === 0) {\n    return (\n      <SettingContainer\n        title={t(\"settings.general.shortcut.title\")}\n        description={t(\"settings.general.shortcut.description\")}\n        descriptionMode={descriptionMode}\n        grouped={grouped}\n      >\n        <div className=\"text-sm text-mid-gray\">\n          {t(\"settings.general.shortcut.none\")}\n        </div>\n      </SettingContainer>\n    );\n  }\n\n  const binding = bindings[shortcutId];\n  if (!binding) {\n    return (\n      <SettingContainer\n        title={t(\"settings.general.shortcut.title\")}\n        description={t(\"settings.general.shortcut.notFound\")}\n        descriptionMode={descriptionMode}\n        grouped={grouped}\n      >\n        <div className=\"text-sm text-mid-gray\">\n          {t(\"settings.general.shortcut.none\")}\n        </div>\n      </SettingContainer>\n    );\n  }\n\n  // Get translated name and description for the binding\n  const translatedName = t(\n    `settings.general.shortcut.bindings.${shortcutId}.name`,\n    binding.name,\n  );\n  const translatedDescription = t(\n    `settings.general.shortcut.bindings.${shortcutId}.description`,\n    binding.description,\n  );\n\n  return (\n    <SettingContainer\n      title={translatedName}\n      description={translatedDescription}\n      descriptionMode={descriptionMode}\n      grouped={grouped}\n      disabled={disabled}\n      layout=\"horizontal\"\n    >\n      <div className=\"flex items-center space-x-1\">\n        {editingShortcutId === shortcutId ? (\n          <div\n            ref={(ref) => setShortcutRef(shortcutId, ref)}\n            className=\"px-2 py-1 text-sm font-semibold border border-logo-primary bg-logo-primary/30 rounded-md\"\n          >\n            {formatCurrentKeys()}\n          </div>\n        ) : (\n          <div\n            className=\"px-2 py-1 text-sm font-semibold bg-mid-gray/10 border border-mid-gray/80 hover:bg-logo-primary/10 rounded-md cursor-pointer hover:border-logo-primary\"\n            onClick={() => startRecording(shortcutId)}\n          >\n            {formatKeyCombination(binding.current_binding, osType)}\n          </div>\n        )}\n        <ResetButton\n          onClick={() => resetBinding(shortcutId)}\n          disabled={isUpdating(`binding_${shortcutId}`)}\n        />\n      </div>\n    </SettingContainer>\n  );\n};\n"
  },
  {
    "path": "src/components/settings/HandyKeysShortcutInput.tsx",
    "content": "import React, { useEffect, useState, useRef, useCallback } from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { listen } from \"@tauri-apps/api/event\";\nimport { formatKeyCombination } from \"../../lib/utils/keyboard\";\nimport { ResetButton } from \"../ui/ResetButton\";\nimport { SettingContainer } from \"../ui/SettingContainer\";\nimport { useSettings } from \"../../hooks/useSettings\";\nimport { useOsType } from \"../../hooks/useOsType\";\nimport { commands } from \"@/bindings\";\nimport { toast } from \"sonner\";\n\ninterface HandyKeysShortcutInputProps {\n  descriptionMode?: \"inline\" | \"tooltip\";\n  grouped?: boolean;\n  shortcutId: string;\n  disabled?: boolean;\n}\n\ninterface HandyKeysEvent {\n  modifiers: string[];\n  key: string | null;\n  is_key_down: boolean;\n  hotkey_string: string;\n}\n\nexport const HandyKeysShortcutInput: React.FC<HandyKeysShortcutInputProps> = ({\n  descriptionMode = \"tooltip\",\n  grouped = false,\n  shortcutId,\n  disabled = false,\n}) => {\n  const { t } = useTranslation();\n  const { getSetting, updateBinding, resetBinding, isUpdating, isLoading } =\n    useSettings();\n  const [isRecording, setIsRecording] = useState(false);\n  const [currentKeys, setCurrentKeys] = useState<string>(\"\");\n  const [originalBinding, setOriginalBinding] = useState<string>(\"\");\n  const shortcutRef = useRef<HTMLDivElement | null>(null);\n  const unlistenRef = useRef<(() => void) | null>(null);\n  // Use a ref to track currentKeys for the event handler (avoids stale closure)\n  const currentKeysRef = useRef<string>(\"\");\n  const osType = useOsType();\n\n  const bindings = getSetting(\"bindings\") || {};\n\n  // Handle cancellation\n  const cancelRecording = useCallback(async () => {\n    if (!isRecording) return;\n\n    // Stop listening for backend events\n    if (unlistenRef.current) {\n      unlistenRef.current();\n      unlistenRef.current = null;\n    }\n\n    // Stop backend recording\n    await commands.stopHandyKeysRecording().catch(console.error);\n\n    // Restore original binding\n    if (originalBinding) {\n      try {\n        await updateBinding(shortcutId, originalBinding);\n      } catch (error) {\n        console.error(\"Failed to restore original binding:\", error);\n        toast.error(t(\"settings.general.shortcut.errors.restore\"));\n      }\n    }\n\n    setIsRecording(false);\n    setCurrentKeys(\"\");\n    currentKeysRef.current = \"\";\n    setOriginalBinding(\"\");\n  }, [isRecording, originalBinding, shortcutId, updateBinding, t]);\n\n  // Set up event listener for handy-keys events\n  useEffect(() => {\n    if (!isRecording) return;\n\n    let cleanup = false;\n\n    const setupListener = async () => {\n      // Listen for key events from backend\n      const unlisten = await listen<HandyKeysEvent>(\n        \"handy-keys-event\",\n        async (event) => {\n          if (cleanup) return;\n\n          const { hotkey_string, is_key_down } = event.payload;\n\n          if (is_key_down && hotkey_string) {\n            // Update both state (for display) and ref (for release handler)\n            currentKeysRef.current = hotkey_string;\n            setCurrentKeys(hotkey_string);\n          } else if (!is_key_down && currentKeysRef.current) {\n            // Key released - commit the shortcut using the ref value\n            const keysToCommit = currentKeysRef.current;\n            try {\n              await updateBinding(shortcutId, keysToCommit);\n            } catch (error) {\n              console.error(\"Failed to change binding:\", error);\n              toast.error(\n                t(\"settings.general.shortcut.errors.set\", {\n                  error: String(error),\n                }),\n              );\n\n              // Reset to original binding on error\n              if (originalBinding) {\n                try {\n                  await updateBinding(shortcutId, originalBinding);\n                } catch (resetError) {\n                  console.error(\"Failed to reset binding:\", resetError);\n                  toast.error(t(\"settings.general.shortcut.errors.reset\"));\n                }\n              }\n            }\n\n            // Stop recording\n            if (unlistenRef.current) {\n              unlistenRef.current();\n              unlistenRef.current = null;\n            }\n            await commands.stopHandyKeysRecording().catch(console.error);\n            setIsRecording(false);\n            setCurrentKeys(\"\");\n            currentKeysRef.current = \"\";\n            setOriginalBinding(\"\");\n          }\n        },\n      );\n\n      unlistenRef.current = unlisten;\n    };\n\n    setupListener();\n\n    // Handle escape key to cancel\n    const handleKeyDown = (e: KeyboardEvent) => {\n      if (e.key === \"Escape\") {\n        e.preventDefault();\n        cancelRecording();\n      }\n    };\n\n    window.addEventListener(\"keydown\", handleKeyDown);\n\n    return () => {\n      cleanup = true;\n      window.removeEventListener(\"keydown\", handleKeyDown);\n      if (unlistenRef.current) {\n        unlistenRef.current();\n        unlistenRef.current = null;\n      }\n      // Stop backend recording on unmount to prevent orphaned recording loops\n      commands.stopHandyKeysRecording().catch(console.error);\n    };\n  }, [\n    isRecording,\n    shortcutId,\n    originalBinding,\n    updateBinding,\n    cancelRecording,\n    t,\n  ]);\n\n  // Handle click outside\n  useEffect(() => {\n    if (!isRecording) return;\n\n    const handleClickOutside = (e: MouseEvent) => {\n      if (\n        shortcutRef.current &&\n        !shortcutRef.current.contains(e.target as Node)\n      ) {\n        cancelRecording();\n      }\n    };\n\n    window.addEventListener(\"click\", handleClickOutside);\n    return () => window.removeEventListener(\"click\", handleClickOutside);\n  }, [isRecording, cancelRecording]);\n\n  // Start recording a new shortcut\n  const startRecording = async () => {\n    if (isRecording) return;\n\n    // Store the original binding to restore if canceled\n    setOriginalBinding(bindings[shortcutId]?.current_binding || \"\");\n\n    // Start backend recording\n    try {\n      await commands.startHandyKeysRecording(shortcutId);\n      setIsRecording(true);\n      setCurrentKeys(\"\");\n      currentKeysRef.current = \"\";\n    } catch (error) {\n      console.error(\"Failed to start recording:\", error);\n      toast.error(\n        t(\"settings.general.shortcut.errors.set\", { error: String(error) }),\n      );\n    }\n  };\n\n  // Format the current shortcut keys being recorded\n  const formatCurrentKeys = (): string => {\n    if (!currentKeys) return t(\"settings.general.shortcut.pressKeys\");\n    return formatKeyCombination(currentKeys, osType);\n  };\n\n  // If still loading, show loading state\n  if (isLoading) {\n    return (\n      <SettingContainer\n        title={t(\"settings.general.shortcut.title\")}\n        description={t(\"settings.general.shortcut.description\")}\n        descriptionMode={descriptionMode}\n        grouped={grouped}\n      >\n        <div className=\"text-sm text-mid-gray\">\n          {t(\"settings.general.shortcut.loading\")}\n        </div>\n      </SettingContainer>\n    );\n  }\n\n  // If no bindings are loaded, show empty state\n  if (Object.keys(bindings).length === 0) {\n    return (\n      <SettingContainer\n        title={t(\"settings.general.shortcut.title\")}\n        description={t(\"settings.general.shortcut.description\")}\n        descriptionMode={descriptionMode}\n        grouped={grouped}\n      >\n        <div className=\"text-sm text-mid-gray\">\n          {t(\"settings.general.shortcut.none\")}\n        </div>\n      </SettingContainer>\n    );\n  }\n\n  const binding = bindings[shortcutId];\n  if (!binding) {\n    return (\n      <SettingContainer\n        title={t(\"settings.general.shortcut.title\")}\n        description={t(\"settings.general.shortcut.notFound\")}\n        descriptionMode={descriptionMode}\n        grouped={grouped}\n      >\n        <div className=\"text-sm text-mid-gray\">\n          {t(\"settings.general.shortcut.none\")}\n        </div>\n      </SettingContainer>\n    );\n  }\n\n  // Get translated name and description for the binding\n  const translatedName = t(\n    `settings.general.shortcut.bindings.${shortcutId}.name`,\n    binding.name,\n  );\n  const translatedDescription = t(\n    `settings.general.shortcut.bindings.${shortcutId}.description`,\n    binding.description,\n  );\n\n  return (\n    <SettingContainer\n      title={translatedName}\n      description={translatedDescription}\n      descriptionMode={descriptionMode}\n      grouped={grouped}\n      disabled={disabled}\n      layout=\"horizontal\"\n    >\n      <div className=\"flex items-center space-x-1\">\n        {isRecording ? (\n          <div\n            ref={shortcutRef}\n            className=\"px-2 py-1 text-sm font-semibold border border-logo-primary bg-logo-primary/30 rounded-md\"\n          >\n            {formatCurrentKeys()}\n          </div>\n        ) : (\n          <div\n            className=\"px-2 py-1 text-sm font-semibold bg-mid-gray/10 border border-mid-gray/80 hover:bg-logo-primary/10 rounded-md cursor-pointer hover:border-logo-primary\"\n            onClick={startRecording}\n          >\n            {formatKeyCombination(binding.current_binding, osType)}\n          </div>\n        )}\n        <ResetButton\n          onClick={() => resetBinding(shortcutId)}\n          disabled={isUpdating(`binding_${shortcutId}`)}\n        />\n      </div>\n    </SettingContainer>\n  );\n};\n"
  },
  {
    "path": "src/components/settings/HistoryLimit.tsx",
    "content": "import React from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { useSettings } from \"../../hooks/useSettings\";\nimport { Input } from \"../ui/Input\";\nimport { SettingContainer } from \"../ui/SettingContainer\";\n\ninterface HistoryLimitProps {\n  descriptionMode?: \"tooltip\" | \"inline\";\n  grouped?: boolean;\n}\n\nexport const HistoryLimit: React.FC<HistoryLimitProps> = ({\n  descriptionMode = \"inline\",\n  grouped = false,\n}) => {\n  const { t } = useTranslation();\n  const { getSetting, updateSetting, isUpdating } = useSettings();\n\n  const historyLimit = getSetting(\"history_limit\") ?? 5;\n\n  const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {\n    const value = parseInt(event.target.value, 10);\n    if (!isNaN(value) && value >= 0) {\n      updateSetting(\"history_limit\", value);\n    }\n  };\n\n  return (\n    <SettingContainer\n      title={t(\"settings.debug.historyLimit.title\")}\n      description={t(\"settings.debug.historyLimit.description\")}\n      descriptionMode={descriptionMode}\n      grouped={grouped}\n      layout=\"horizontal\"\n    >\n      <div className=\"flex items-center space-x-2\">\n        <Input\n          type=\"number\"\n          min=\"0\"\n          max=\"1000\"\n          value={historyLimit}\n          onChange={handleChange}\n          disabled={isUpdating(\"history_limit\")}\n          className=\"w-20\"\n        />\n        <span className=\"text-sm text-text\">\n          {t(\"settings.debug.historyLimit.entries\")}\n        </span>\n      </div>\n    </SettingContainer>\n  );\n};\n"
  },
  {
    "path": "src/components/settings/LanguageSelector.tsx",
    "content": "import React, { useState, useRef, useEffect, useMemo } from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { SettingContainer } from \"../ui/SettingContainer\";\nimport { ResetButton } from \"../ui/ResetButton\";\nimport { useSettings } from \"../../hooks/useSettings\";\nimport { LANGUAGES } from \"../../lib/constants/languages\";\n\ninterface LanguageSelectorProps {\n  descriptionMode?: \"inline\" | \"tooltip\";\n  grouped?: boolean;\n  supportedLanguages?: string[];\n}\n\nexport const LanguageSelector: React.FC<LanguageSelectorProps> = ({\n  descriptionMode = \"tooltip\",\n  grouped = false,\n  supportedLanguages,\n}) => {\n  const { t } = useTranslation();\n  const { getSetting, updateSetting, resetSetting, isUpdating } = useSettings();\n  const [isOpen, setIsOpen] = useState(false);\n  const [searchQuery, setSearchQuery] = useState(\"\");\n  const dropdownRef = useRef<HTMLDivElement>(null);\n  const searchInputRef = useRef<HTMLInputElement>(null);\n\n  const selectedLanguage = getSetting(\"selected_language\") || \"auto\";\n\n  useEffect(() => {\n    const handleClickOutside = (event: MouseEvent) => {\n      if (\n        dropdownRef.current &&\n        !dropdownRef.current.contains(event.target as Node)\n      ) {\n        setIsOpen(false);\n        setSearchQuery(\"\");\n      }\n    };\n\n    document.addEventListener(\"mousedown\", handleClickOutside);\n    return () => {\n      document.removeEventListener(\"mousedown\", handleClickOutside);\n    };\n  }, []);\n\n  useEffect(() => {\n    if (isOpen && searchInputRef.current) {\n      searchInputRef.current.focus();\n    }\n  }, [isOpen]);\n\n  const availableLanguages = useMemo(() => {\n    if (!supportedLanguages || supportedLanguages.length === 0)\n      return LANGUAGES;\n    return LANGUAGES.filter(\n      (lang) =>\n        lang.value === \"auto\" || supportedLanguages.includes(lang.value),\n    );\n  }, [supportedLanguages]);\n\n  const filteredLanguages = useMemo(\n    () =>\n      availableLanguages.filter((language) =>\n        language.label.toLowerCase().includes(searchQuery.toLowerCase()),\n      ),\n    [searchQuery, availableLanguages],\n  );\n\n  const selectedLanguageName =\n    LANGUAGES.find((lang) => lang.value === selectedLanguage)?.label ||\n    t(\"settings.general.language.auto\");\n\n  const handleLanguageSelect = async (languageCode: string) => {\n    await updateSetting(\"selected_language\", languageCode);\n    setIsOpen(false);\n    setSearchQuery(\"\");\n  };\n\n  const handleReset = async () => {\n    await resetSetting(\"selected_language\");\n  };\n\n  const handleToggle = () => {\n    if (isUpdating(\"selected_language\")) return;\n    setIsOpen(!isOpen);\n  };\n\n  const handleSearchChange = (event: React.ChangeEvent<HTMLInputElement>) => {\n    setSearchQuery(event.target.value);\n  };\n\n  const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {\n    if (event.key === \"Enter\" && filteredLanguages.length > 0) {\n      // Select first filtered language on Enter\n      handleLanguageSelect(filteredLanguages[0].value);\n    } else if (event.key === \"Escape\") {\n      setIsOpen(false);\n      setSearchQuery(\"\");\n    }\n  };\n\n  return (\n    <SettingContainer\n      title={t(\"settings.general.language.title\")}\n      description={t(\"settings.general.language.description\")}\n      descriptionMode={descriptionMode}\n      grouped={grouped}\n    >\n      <div className=\"flex items-center space-x-1\">\n        <div className=\"relative\" ref={dropdownRef}>\n          <button\n            type=\"button\"\n            className={`px-2 py-1 text-sm font-semibold bg-mid-gray/10 border border-mid-gray/80 rounded min-w-[200px] text-start flex items-center justify-between transition-all duration-150 ${\n              isUpdating(\"selected_language\")\n                ? \"opacity-50 cursor-not-allowed\"\n                : \"hover:bg-logo-primary/10 cursor-pointer hover:border-logo-primary\"\n            }`}\n            onClick={handleToggle}\n            disabled={isUpdating(\"selected_language\")}\n          >\n            <span className=\"truncate\">{selectedLanguageName}</span>\n            <svg\n              className={`w-4 h-4 ms-2 transition-transform duration-200 ${\n                isOpen ? \"transform rotate-180\" : \"\"\n              }`}\n              fill=\"none\"\n              stroke=\"currentColor\"\n              viewBox=\"0 0 24 24\"\n            >\n              <path\n                strokeLinecap=\"round\"\n                strokeLinejoin=\"round\"\n                strokeWidth={2}\n                d=\"M19 9l-7 7-7-7\"\n              />\n            </svg>\n          </button>\n\n          {isOpen && !isUpdating(\"selected_language\") && (\n            <div className=\"absolute top-full left-0 right-0 mt-1 bg-background border border-mid-gray/80 rounded shadow-lg z-50 max-h-60 overflow-hidden\">\n              {/* Search input */}\n              <div className=\"p-2 border-b border-mid-gray/80\">\n                <input\n                  ref={searchInputRef}\n                  type=\"text\"\n                  value={searchQuery}\n                  onChange={handleSearchChange}\n                  onKeyDown={handleKeyDown}\n                  placeholder={t(\"settings.general.language.searchPlaceholder\")}\n                  className=\"w-full px-2 py-1 text-sm bg-mid-gray/10 border border-mid-gray/40 rounded focus:outline-none focus:ring-1 focus:ring-logo-primary focus:border-logo-primary\"\n                />\n              </div>\n\n              <div className=\"max-h-48 overflow-y-auto\">\n                {filteredLanguages.length === 0 ? (\n                  <div className=\"px-2 py-2 text-sm text-mid-gray text-center\">\n                    {t(\"settings.general.language.noResults\")}\n                  </div>\n                ) : (\n                  filteredLanguages.map((language) => (\n                    <button\n                      key={language.value}\n                      type=\"button\"\n                      className={`w-full px-2 py-1 text-sm text-start hover:bg-logo-primary/10 transition-colors duration-150 ${\n                        selectedLanguage === language.value\n                          ? \"bg-logo-primary/20 text-logo-primary font-semibold\"\n                          : \"\"\n                      }`}\n                      onClick={() => handleLanguageSelect(language.value)}\n                    >\n                      <div className=\"flex items-center justify-between\">\n                        <span className=\"truncate\">{language.label}</span>\n                      </div>\n                    </button>\n                  ))\n                )}\n              </div>\n            </div>\n          )}\n        </div>\n        <ResetButton\n          onClick={handleReset}\n          disabled={isUpdating(\"selected_language\")}\n        />\n      </div>\n      {isUpdating(\"selected_language\") && (\n        <div className=\"absolute inset-0 bg-mid-gray/10 rounded flex items-center justify-center\">\n          <div className=\"w-4 h-4 border-2 border-logo-primary border-t-transparent rounded-full animate-spin\"></div>\n        </div>\n      )}\n    </SettingContainer>\n  );\n};\n"
  },
  {
    "path": "src/components/settings/LazyStreamClose.tsx",
    "content": "import React from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { ToggleSwitch } from \"../ui/ToggleSwitch\";\nimport { useSettings } from \"../../hooks/useSettings\";\n\ninterface LazyStreamCloseProps {\n  descriptionMode?: \"inline\" | \"tooltip\";\n  grouped?: boolean;\n}\n\nexport const LazyStreamClose: React.FC<LazyStreamCloseProps> = React.memo(\n  ({ descriptionMode = \"tooltip\", grouped = false }) => {\n    const { t } = useTranslation();\n    const { getSetting, updateSetting, isUpdating } = useSettings();\n\n    const enabled = getSetting(\"lazy_stream_close\") ?? false;\n\n    return (\n      <ToggleSwitch\n        checked={enabled}\n        onChange={(enabled) => updateSetting(\"lazy_stream_close\", enabled)}\n        isUpdating={isUpdating(\"lazy_stream_close\")}\n        label={t(\"settings.advanced.lazyStreamClose.label\")}\n        description={t(\"settings.advanced.lazyStreamClose.description\")}\n        descriptionMode={descriptionMode}\n        grouped={grouped}\n      />\n    );\n  },\n);\n"
  },
  {
    "path": "src/components/settings/MicrophoneSelector.tsx",
    "content": "import React from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { Dropdown } from \"../ui/Dropdown\";\nimport { SettingContainer } from \"../ui/SettingContainer\";\nimport { ResetButton } from \"../ui/ResetButton\";\nimport { useSettings } from \"../../hooks/useSettings\";\n\ninterface MicrophoneSelectorProps {\n  descriptionMode?: \"inline\" | \"tooltip\";\n  grouped?: boolean;\n}\n\nexport const MicrophoneSelector: React.FC<MicrophoneSelectorProps> = React.memo(\n  ({ descriptionMode = \"tooltip\", grouped = false }) => {\n    const { t } = useTranslation();\n    const {\n      getSetting,\n      updateSetting,\n      resetSetting,\n      isUpdating,\n      isLoading,\n      audioDevices,\n      refreshAudioDevices,\n    } = useSettings();\n\n    const selectedMicrophone =\n      getSetting(\"selected_microphone\") === \"default\"\n        ? \"Default\"\n        : getSetting(\"selected_microphone\") || \"Default\";\n\n    const handleMicrophoneSelect = async (deviceName: string) => {\n      await updateSetting(\"selected_microphone\", deviceName);\n    };\n\n    const handleReset = async () => {\n      await resetSetting(\"selected_microphone\");\n    };\n\n    const microphoneOptions = audioDevices.map((device) => ({\n      value: device.name,\n      label: device.name,\n    }));\n\n    return (\n      <SettingContainer\n        title={t(\"settings.sound.microphone.title\")}\n        description={t(\"settings.sound.microphone.description\")}\n        descriptionMode={descriptionMode}\n        grouped={grouped}\n      >\n        <div className=\"flex items-center space-x-1\">\n          <Dropdown\n            options={microphoneOptions}\n            selectedValue={selectedMicrophone}\n            onSelect={handleMicrophoneSelect}\n            placeholder={\n              isLoading || audioDevices.length === 0\n                ? t(\"settings.sound.microphone.loading\")\n                : t(\"settings.sound.microphone.placeholder\")\n            }\n            disabled={\n              isUpdating(\"selected_microphone\") ||\n              isLoading ||\n              audioDevices.length === 0\n            }\n            onRefresh={refreshAudioDevices}\n          />\n          <ResetButton\n            onClick={handleReset}\n            disabled={isUpdating(\"selected_microphone\") || isLoading}\n          />\n        </div>\n      </SettingContainer>\n    );\n  },\n);\n"
  },
  {
    "path": "src/components/settings/ModelUnloadTimeout.tsx",
    "content": "import React, { useMemo } from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { useSettings } from \"../../hooks/useSettings\";\nimport { commands, type ModelUnloadTimeout } from \"@/bindings\";\nimport { Dropdown } from \"../ui/Dropdown\";\nimport { SettingContainer } from \"../ui/SettingContainer\";\n\ninterface ModelUnloadTimeoutProps {\n  descriptionMode?: \"tooltip\" | \"inline\";\n  grouped?: boolean;\n}\n\nexport const ModelUnloadTimeoutSetting: React.FC<ModelUnloadTimeoutProps> = ({\n  descriptionMode = \"inline\",\n  grouped = false,\n}) => {\n  const { t } = useTranslation();\n  const { settings, getSetting, updateSetting } = useSettings();\n\n  const timeoutOptions = [\n    {\n      value: \"never\" as ModelUnloadTimeout,\n      label: t(\"settings.advanced.modelUnload.options.never\"),\n    },\n    {\n      value: \"immediately\" as ModelUnloadTimeout,\n      label: t(\"settings.advanced.modelUnload.options.immediately\"),\n    },\n    {\n      value: \"min2\" as ModelUnloadTimeout,\n      label: t(\"settings.advanced.modelUnload.options.min2\"),\n    },\n    {\n      value: \"min5\" as ModelUnloadTimeout,\n      label: t(\"settings.advanced.modelUnload.options.min5\"),\n    },\n    {\n      value: \"min10\" as ModelUnloadTimeout,\n      label: t(\"settings.advanced.modelUnload.options.min10\"),\n    },\n    {\n      value: \"min15\" as ModelUnloadTimeout,\n      label: t(\"settings.advanced.modelUnload.options.min15\"),\n    },\n    {\n      value: \"hour1\" as ModelUnloadTimeout,\n      label: t(\"settings.advanced.modelUnload.options.hour1\"),\n    },\n  ];\n\n  const debugTimeoutOptions = [\n    ...timeoutOptions,\n    {\n      value: \"sec15\" as ModelUnloadTimeout,\n      label: t(\"settings.advanced.modelUnload.options.sec15\"),\n    },\n  ];\n\n  const handleChange = async (event: React.ChangeEvent<HTMLSelectElement>) => {\n    const newTimeout = event.target.value as ModelUnloadTimeout;\n\n    try {\n      await commands.setModelUnloadTimeout(newTimeout);\n      updateSetting(\"model_unload_timeout\", newTimeout);\n    } catch (error) {\n      console.error(\"Failed to update model unload timeout:\", error);\n    }\n  };\n\n  const currentValue = getSetting(\"model_unload_timeout\") ?? \"never\";\n\n  const options = useMemo(() => {\n    return settings?.debug_mode === true ? debugTimeoutOptions : timeoutOptions;\n  }, [settings]);\n\n  return (\n    <SettingContainer\n      title={t(\"settings.advanced.modelUnload.title\")}\n      description={t(\"settings.advanced.modelUnload.description\")}\n      descriptionMode={descriptionMode}\n      grouped={grouped}\n    >\n      <Dropdown\n        options={options}\n        selectedValue={currentValue}\n        onSelect={(value) =>\n          handleChange({\n            target: { value },\n          } as React.ChangeEvent<HTMLSelectElement>)\n        }\n        disabled={false}\n      />\n    </SettingContainer>\n  );\n};\n"
  },
  {
    "path": "src/components/settings/MuteWhileRecording.tsx",
    "content": "import React from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { ToggleSwitch } from \"../ui/ToggleSwitch\";\nimport { useSettings } from \"../../hooks/useSettings\";\n\ninterface MuteWhileRecordingToggleProps {\n  descriptionMode?: \"inline\" | \"tooltip\";\n  grouped?: boolean;\n}\n\nexport const MuteWhileRecording: React.FC<MuteWhileRecordingToggleProps> =\n  React.memo(({ descriptionMode = \"tooltip\", grouped = false }) => {\n    const { t } = useTranslation();\n    const { getSetting, updateSetting, isUpdating } = useSettings();\n\n    const muteEnabled = getSetting(\"mute_while_recording\") ?? false;\n\n    return (\n      <ToggleSwitch\n        checked={muteEnabled}\n        onChange={(enabled) => updateSetting(\"mute_while_recording\", enabled)}\n        isUpdating={isUpdating(\"mute_while_recording\")}\n        label={t(\"settings.debug.muteWhileRecording.label\")}\n        description={t(\"settings.debug.muteWhileRecording.description\")}\n        descriptionMode={descriptionMode}\n        grouped={grouped}\n      />\n    );\n  });\n"
  },
  {
    "path": "src/components/settings/OutputDeviceSelector.tsx",
    "content": "import React from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { Dropdown } from \"../ui/Dropdown\";\nimport { SettingContainer } from \"../ui/SettingContainer\";\nimport { ResetButton } from \"../ui/ResetButton\";\nimport { useSettings } from \"../../hooks/useSettings\";\nimport type { AudioDevice } from \"@/bindings\";\n\ninterface OutputDeviceSelectorProps {\n  descriptionMode?: \"inline\" | \"tooltip\";\n  grouped?: boolean;\n  disabled?: boolean;\n}\n\nexport const OutputDeviceSelector: React.FC<OutputDeviceSelectorProps> =\n  React.memo(\n    ({ descriptionMode = \"tooltip\", grouped = false, disabled = false }) => {\n      const { t } = useTranslation();\n      const {\n        getSetting,\n        updateSetting,\n        resetSetting,\n        isUpdating,\n        isLoading,\n        outputDevices,\n        refreshOutputDevices,\n      } = useSettings();\n\n      const selectedOutputDevice =\n        getSetting(\"selected_output_device\") === \"default\"\n          ? \"Default\"\n          : getSetting(\"selected_output_device\") || \"Default\";\n\n      const handleOutputDeviceSelect = async (deviceName: string) => {\n        await updateSetting(\"selected_output_device\", deviceName);\n      };\n\n      const handleReset = async () => {\n        await resetSetting(\"selected_output_device\");\n      };\n\n      const outputDeviceOptions = outputDevices.map((device: AudioDevice) => ({\n        value: device.name,\n        label: device.name,\n      }));\n\n      return (\n        <SettingContainer\n          title={t(\"settings.sound.outputDevice.title\")}\n          description={t(\"settings.sound.outputDevice.description\")}\n          descriptionMode={descriptionMode}\n          grouped={grouped}\n          disabled={disabled}\n        >\n          <div className=\"flex items-center space-x-1\">\n            <Dropdown\n              options={outputDeviceOptions}\n              selectedValue={selectedOutputDevice}\n              onSelect={handleOutputDeviceSelect}\n              placeholder={\n                isLoading || outputDevices.length === 0\n                  ? t(\"settings.sound.outputDevice.loading\")\n                  : t(\"settings.sound.outputDevice.placeholder\")\n              }\n              disabled={\n                disabled ||\n                isUpdating(\"selected_output_device\") ||\n                isLoading ||\n                outputDevices.length === 0\n              }\n              onRefresh={refreshOutputDevices}\n            />\n            <ResetButton\n              onClick={handleReset}\n              disabled={\n                disabled || isUpdating(\"selected_output_device\") || isLoading\n              }\n            />\n          </div>\n        </SettingContainer>\n      );\n    },\n  );\n"
  },
  {
    "path": "src/components/settings/PasteMethod.tsx",
    "content": "import React from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { Dropdown } from \"../ui/Dropdown\";\nimport { SettingContainer } from \"../ui/SettingContainer\";\nimport { Input } from \"../ui/Input\";\nimport { useSettings } from \"../../hooks/useSettings\";\nimport { useOsType } from \"../../hooks/useOsType\";\nimport type { PasteMethod } from \"@/bindings\";\n\ninterface PasteMethodProps {\n  descriptionMode?: \"inline\" | \"tooltip\";\n  grouped?: boolean;\n}\n\nexport const PasteMethodSetting: React.FC<PasteMethodProps> = React.memo(\n  ({ descriptionMode = \"tooltip\", grouped = false }) => {\n    const { t } = useTranslation();\n    const { getSetting, updateSetting, isUpdating } = useSettings();\n    const osType = useOsType();\n\n    const getPasteMethodOptions = (osType: string) => {\n      const mod = osType === \"macos\" ? \"Cmd\" : \"Ctrl\";\n\n      const options = [\n        {\n          value: \"ctrl_v\",\n          label: t(\"settings.advanced.pasteMethod.options.clipboard\", {\n            modifier: mod,\n          }),\n        },\n        {\n          value: \"direct\",\n          label: t(\"settings.advanced.pasteMethod.options.direct\"),\n        },\n        {\n          value: \"none\",\n          label: t(\"settings.advanced.pasteMethod.options.none\"),\n        },\n      ];\n\n      // Add Shift+Insert and Ctrl+Shift+V options for Windows and Linux only\n      if (osType === \"windows\" || osType === \"linux\") {\n        options.push(\n          {\n            value: \"ctrl_shift_v\",\n            label: t(\n              \"settings.advanced.pasteMethod.options.clipboardCtrlShiftV\",\n            ),\n          },\n          {\n            value: \"shift_insert\",\n            label: t(\n              \"settings.advanced.pasteMethod.options.clipboardShiftInsert\",\n            ),\n          },\n        );\n      }\n\n      // External script is only available on Linux\n      if (osType === \"linux\") {\n        options.push({\n          value: \"external_script\",\n          label: t(\"settings.advanced.pasteMethod.options.externalScript\"),\n        });\n      }\n\n      return options;\n    };\n\n    const selectedMethod = (getSetting(\"paste_method\") ||\n      \"ctrl_v\") as PasteMethod;\n    const externalScriptPath = getSetting(\"external_script_path\") || \"\";\n\n    const pasteMethodOptions = getPasteMethodOptions(osType);\n\n    return (\n      <SettingContainer\n        title={t(\"settings.advanced.pasteMethod.title\")}\n        description={t(\"settings.advanced.pasteMethod.description\")}\n        descriptionMode={descriptionMode}\n        grouped={grouped}\n        tooltipPosition=\"bottom\"\n      >\n        <div className=\"flex flex-col gap-2\">\n          <Dropdown\n            options={pasteMethodOptions}\n            selectedValue={selectedMethod}\n            onSelect={(value) =>\n              updateSetting(\"paste_method\", value as PasteMethod)\n            }\n            disabled={isUpdating(\"paste_method\")}\n          />\n          {selectedMethod === \"external_script\" && (\n            <Input\n              type=\"text\"\n              value={externalScriptPath}\n              onChange={(e) =>\n                updateSetting(\"external_script_path\", e.target.value)\n              }\n              placeholder={t(\n                \"settings.advanced.pasteMethod.externalScriptPlaceholder\",\n              )}\n              disabled={isUpdating(\"external_script_path\")}\n            />\n          )}\n        </div>\n      </SettingContainer>\n    );\n  },\n);\n"
  },
  {
    "path": "src/components/settings/PostProcessingSettingsApi/ApiKeyField.tsx",
    "content": "import React, { useState } from \"react\";\nimport { Input } from \"../../ui/Input\";\n\ninterface ApiKeyFieldProps {\n  value: string;\n  onBlur: (value: string) => void;\n  disabled: boolean;\n  placeholder?: string;\n  className?: string;\n}\n\nexport const ApiKeyField: React.FC<ApiKeyFieldProps> = React.memo(\n  ({ value, onBlur, disabled, placeholder, className = \"\" }) => {\n    const [localValue, setLocalValue] = useState(value);\n\n    // Sync with prop changes\n    React.useEffect(() => {\n      setLocalValue(value);\n    }, [value]);\n\n    return (\n      <Input\n        type=\"password\"\n        value={localValue}\n        onChange={(event) => setLocalValue(event.target.value)}\n        onBlur={() => onBlur(localValue)}\n        placeholder={placeholder}\n        variant=\"compact\"\n        disabled={disabled}\n        className={`flex-1 min-w-[320px] ${className}`}\n      />\n    );\n  },\n);\n\nApiKeyField.displayName = \"ApiKeyField\";\n"
  },
  {
    "path": "src/components/settings/PostProcessingSettingsApi/BaseUrlField.tsx",
    "content": "import React, { useState } from \"react\";\nimport { Input } from \"../../ui/Input\";\n\ninterface BaseUrlFieldProps {\n  value: string;\n  onBlur: (value: string) => void;\n  disabled: boolean;\n  placeholder?: string;\n  className?: string;\n}\n\nexport const BaseUrlField: React.FC<BaseUrlFieldProps> = React.memo(\n  ({ value, onBlur, disabled, placeholder, className = \"\" }) => {\n    const [localValue, setLocalValue] = useState(value);\n\n    // Sync with prop changes\n    React.useEffect(() => {\n      setLocalValue(value);\n    }, [value]);\n\n    const disabledMessage = disabled\n      ? \"Base URL is managed by the selected provider.\"\n      : undefined;\n\n    return (\n      <Input\n        type=\"text\"\n        value={localValue}\n        onChange={(event) => setLocalValue(event.target.value)}\n        onBlur={() => onBlur(localValue)}\n        placeholder={placeholder}\n        variant=\"compact\"\n        disabled={disabled}\n        className={`flex-1 min-w-[360px] ${className}`}\n        title={disabledMessage}\n      />\n    );\n  },\n);\n\nBaseUrlField.displayName = \"BaseUrlField\";\n"
  },
  {
    "path": "src/components/settings/PostProcessingSettingsApi/ModelSelect.tsx",
    "content": "import React from \"react\";\nimport type { ModelOption } from \"./types\";\nimport { Select } from \"../../ui/Select\";\n\ntype ModelSelectProps = {\n  value: string;\n  options: ModelOption[];\n  disabled?: boolean;\n  placeholder?: string;\n  isLoading?: boolean;\n  onSelect: (value: string) => void;\n  onCreate: (value: string) => void;\n  onBlur: () => void;\n  className?: string;\n};\n\nexport const ModelSelect: React.FC<ModelSelectProps> = React.memo(\n  ({\n    value,\n    options,\n    disabled,\n    placeholder,\n    isLoading,\n    onSelect,\n    onCreate,\n    onBlur,\n    className = \"flex-1 min-w-[360px]\",\n  }) => {\n    const handleCreate = (inputValue: string) => {\n      const trimmed = inputValue.trim();\n      if (!trimmed) return;\n      onCreate(trimmed);\n    };\n\n    const computedClassName = `text-sm ${className}`;\n\n    return (\n      <Select\n        className={computedClassName}\n        value={value || null}\n        options={options}\n        onChange={(selected) => onSelect(selected ?? \"\")}\n        onCreateOption={handleCreate}\n        onBlur={onBlur}\n        placeholder={placeholder}\n        disabled={disabled}\n        isLoading={isLoading}\n        isCreatable\n        formatCreateLabel={(input) => `Use \"${input}\"`}\n      />\n    );\n  },\n);\n\nModelSelect.displayName = \"ModelSelect\";\n"
  },
  {
    "path": "src/components/settings/PostProcessingSettingsApi/ProviderSelect.tsx",
    "content": "import React from \"react\";\nimport { Dropdown, type DropdownOption } from \"../../ui/Dropdown\";\n\ninterface ProviderSelectProps {\n  options: DropdownOption[];\n  value: string;\n  onChange: (value: string) => void;\n  disabled?: boolean;\n}\n\nexport const ProviderSelect: React.FC<ProviderSelectProps> = React.memo(\n  ({ options, value, onChange, disabled }) => {\n    return (\n      <Dropdown\n        options={options}\n        selectedValue={value}\n        onSelect={onChange}\n        disabled={disabled}\n        className=\"flex-1\"\n      />\n    );\n  },\n);\n\nProviderSelect.displayName = \"ProviderSelect\";\n"
  },
  {
    "path": "src/components/settings/PostProcessingSettingsApi/index.tsx",
    "content": "export { PostProcessingSettingsApi } from \"../post-processing/PostProcessingSettings\";\n"
  },
  {
    "path": "src/components/settings/PostProcessingSettingsApi/types.ts",
    "content": "export type ModelOption = {\n  value: string;\n  label: string;\n};\n"
  },
  {
    "path": "src/components/settings/PostProcessingSettingsApi/usePostProcessProviderState.ts",
    "content": "import { useCallback, useMemo, useState } from \"react\";\nimport { useSettings } from \"../../../hooks/useSettings\";\nimport { commands, type PostProcessProvider } from \"@/bindings\";\nimport type { ModelOption } from \"./types\";\nimport type { DropdownOption } from \"../../ui/Dropdown\";\n\ntype PostProcessProviderState = {\n  providerOptions: DropdownOption[];\n  selectedProviderId: string;\n  selectedProvider: PostProcessProvider | undefined;\n  isCustomProvider: boolean;\n  isAppleProvider: boolean;\n  appleIntelligenceUnavailable: boolean;\n  baseUrl: string;\n  handleBaseUrlChange: (value: string) => void;\n  isBaseUrlUpdating: boolean;\n  apiKey: string;\n  handleApiKeyChange: (value: string) => void;\n  isApiKeyUpdating: boolean;\n  model: string;\n  handleModelChange: (value: string) => void;\n  modelOptions: ModelOption[];\n  isModelUpdating: boolean;\n  isFetchingModels: boolean;\n  handleProviderSelect: (providerId: string) => void;\n  handleModelSelect: (value: string) => void;\n  handleModelCreate: (value: string) => void;\n  handleRefreshModels: () => void;\n};\n\nconst APPLE_PROVIDER_ID = \"apple_intelligence\";\n\nexport const usePostProcessProviderState = (): PostProcessProviderState => {\n  const {\n    settings,\n    isUpdating,\n    setPostProcessProvider,\n    updatePostProcessBaseUrl,\n    updatePostProcessApiKey,\n    updatePostProcessModel,\n    fetchPostProcessModels,\n    postProcessModelOptions,\n  } = useSettings();\n\n  // Settings are guaranteed to have providers after migration\n  const providers = settings?.post_process_providers || [];\n\n  const selectedProviderId = useMemo(() => {\n    return settings?.post_process_provider_id || providers[0]?.id || \"openai\";\n  }, [providers, settings?.post_process_provider_id]);\n\n  const selectedProvider = useMemo(() => {\n    return (\n      providers.find((provider) => provider.id === selectedProviderId) ||\n      providers[0]\n    );\n  }, [providers, selectedProviderId]);\n\n  const isAppleProvider = selectedProvider?.id === APPLE_PROVIDER_ID;\n  const [appleIntelligenceUnavailable, setAppleIntelligenceUnavailable] =\n    useState(false);\n\n  // Use settings directly as single source of truth\n  const baseUrl = selectedProvider?.base_url ?? \"\";\n  const apiKey = settings?.post_process_api_keys?.[selectedProviderId] ?? \"\";\n  const model = settings?.post_process_models?.[selectedProviderId] ?? \"\";\n\n  const providerOptions = useMemo<DropdownOption[]>(() => {\n    return providers.map((provider) => ({\n      value: provider.id,\n      label: provider.label,\n    }));\n  }, [providers]);\n\n  const handleProviderSelect = useCallback(\n    async (providerId: string) => {\n      // Clear error state on any selection attempt (allows dismissing the error)\n      setAppleIntelligenceUnavailable(false);\n\n      if (providerId === selectedProviderId) return;\n\n      // Check Apple Intelligence availability before selecting\n      if (providerId === APPLE_PROVIDER_ID) {\n        const available = await commands.checkAppleIntelligenceAvailable();\n        if (!available) {\n          setAppleIntelligenceUnavailable(true);\n          // Don't return - still set the provider so dropdown shows the selection\n          // The backend gracefully handles unavailable Apple Intelligence\n        }\n      }\n\n      await setPostProcessProvider(providerId);\n\n      // Auto-fetch available models for the new provider so the model dropdown\n      // reflects what's actually valid. Without this, a stale model value from\n      // a previous provider/base_url can persist and silently 404 at runtime.\n      // Skip when the provider isn't configured yet (no API key / empty base URL)\n      // to avoid unnecessary backend errors.\n      if (providerId !== APPLE_PROVIDER_ID) {\n        const provider = providers.find((p) => p.id === providerId);\n        const apiKey = settings?.post_process_api_keys?.[providerId] ?? \"\";\n        const hasBaseUrl = (provider?.base_url ?? \"\").trim() !== \"\";\n        const hasApiKey = apiKey.trim() !== \"\";\n\n        if (provider?.id === \"custom\" ? hasBaseUrl : hasApiKey) {\n          void fetchPostProcessModels(providerId);\n        }\n      }\n    },\n    [\n      selectedProviderId,\n      setPostProcessProvider,\n      fetchPostProcessModels,\n      providers,\n      settings,\n    ],\n  );\n\n  const handleBaseUrlChange = useCallback(\n    (value: string) => {\n      if (!selectedProvider || selectedProvider.id !== \"custom\") {\n        return;\n      }\n      const trimmed = value.trim();\n      if (trimmed && trimmed !== baseUrl) {\n        void updatePostProcessBaseUrl(selectedProvider.id, trimmed);\n      }\n    },\n    [selectedProvider, baseUrl, updatePostProcessBaseUrl],\n  );\n\n  const handleApiKeyChange = useCallback(\n    (value: string) => {\n      const trimmed = value.trim();\n      if (trimmed !== apiKey) {\n        void updatePostProcessApiKey(selectedProviderId, trimmed);\n      }\n    },\n    [apiKey, selectedProviderId, updatePostProcessApiKey],\n  );\n\n  const handleModelChange = useCallback(\n    (value: string) => {\n      const trimmed = value.trim();\n      if (trimmed !== model) {\n        void updatePostProcessModel(selectedProviderId, trimmed);\n      }\n    },\n    [model, selectedProviderId, updatePostProcessModel],\n  );\n\n  const handleModelSelect = useCallback(\n    (value: string) => {\n      void updatePostProcessModel(selectedProviderId, value.trim());\n    },\n    [selectedProviderId, updatePostProcessModel],\n  );\n\n  const handleModelCreate = useCallback(\n    (value: string) => {\n      void updatePostProcessModel(selectedProviderId, value);\n    },\n    [selectedProviderId, updatePostProcessModel],\n  );\n\n  const handleRefreshModels = useCallback(() => {\n    if (isAppleProvider) return;\n    void fetchPostProcessModels(selectedProviderId);\n  }, [fetchPostProcessModels, isAppleProvider, selectedProviderId]);\n\n  const availableModelsRaw = postProcessModelOptions[selectedProviderId] || [];\n\n  const modelOptions = useMemo<ModelOption[]>(() => {\n    const seen = new Set<string>();\n    const options: ModelOption[] = [];\n\n    const upsert = (value: string | null | undefined) => {\n      const trimmed = value?.trim();\n      if (!trimmed || seen.has(trimmed)) return;\n      seen.add(trimmed);\n      options.push({ value: trimmed, label: trimmed });\n    };\n\n    // Add available models from API\n    for (const candidate of availableModelsRaw) {\n      upsert(candidate);\n    }\n\n    // Ensure current model is in the list\n    upsert(model);\n\n    return options;\n  }, [availableModelsRaw, model]);\n\n  const isBaseUrlUpdating = isUpdating(\n    `post_process_base_url:${selectedProviderId}`,\n  );\n  const isApiKeyUpdating = isUpdating(\n    `post_process_api_key:${selectedProviderId}`,\n  );\n  const isModelUpdating = isUpdating(\n    `post_process_model:${selectedProviderId}`,\n  );\n  const isFetchingModels = isUpdating(\n    `post_process_models_fetch:${selectedProviderId}`,\n  );\n\n  const isCustomProvider = selectedProvider?.id === \"custom\";\n\n  // No automatic fetching - user must click refresh button\n\n  return {\n    providerOptions,\n    selectedProviderId,\n    selectedProvider,\n    isCustomProvider,\n    isAppleProvider,\n    appleIntelligenceUnavailable,\n    baseUrl,\n    handleBaseUrlChange,\n    isBaseUrlUpdating,\n    apiKey,\n    handleApiKeyChange,\n    isApiKeyUpdating,\n    model,\n    handleModelChange,\n    modelOptions,\n    isModelUpdating,\n    isFetchingModels,\n    handleProviderSelect,\n    handleModelSelect,\n    handleModelCreate,\n    handleRefreshModels,\n  };\n};\n"
  },
  {
    "path": "src/components/settings/PostProcessingSettingsPrompts.tsx",
    "content": "export { PostProcessingSettingsPrompts } from \"./post-processing/PostProcessingSettings\";\n"
  },
  {
    "path": "src/components/settings/PostProcessingToggle.tsx",
    "content": "import React from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { ToggleSwitch } from \"../ui/ToggleSwitch\";\nimport { useSettings } from \"../../hooks/useSettings\";\n\ninterface PostProcessingToggleProps {\n  descriptionMode?: \"inline\" | \"tooltip\";\n  grouped?: boolean;\n}\n\nexport const PostProcessingToggle: React.FC<PostProcessingToggleProps> =\n  React.memo(({ descriptionMode = \"tooltip\", grouped = false }) => {\n    const { t } = useTranslation();\n    const { getSetting, updateSetting, isUpdating } = useSettings();\n\n    const enabled = getSetting(\"post_process_enabled\") || false;\n\n    return (\n      <ToggleSwitch\n        checked={enabled}\n        onChange={(enabled) => updateSetting(\"post_process_enabled\", enabled)}\n        isUpdating={isUpdating(\"post_process_enabled\")}\n        label={t(\"settings.debug.postProcessingToggle.label\")}\n        description={t(\"settings.debug.postProcessingToggle.description\")}\n        descriptionMode={descriptionMode}\n        grouped={grouped}\n      />\n    );\n  });\n"
  },
  {
    "path": "src/components/settings/PushToTalk.tsx",
    "content": "import React from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { ToggleSwitch } from \"../ui/ToggleSwitch\";\nimport { useSettings } from \"../../hooks/useSettings\";\n\ninterface PushToTalkProps {\n  descriptionMode?: \"inline\" | \"tooltip\";\n  grouped?: boolean;\n}\n\nexport const PushToTalk: React.FC<PushToTalkProps> = React.memo(\n  ({ descriptionMode = \"tooltip\", grouped = false }) => {\n    const { t } = useTranslation();\n    const { getSetting, updateSetting, isUpdating } = useSettings();\n\n    const pttEnabled = getSetting(\"push_to_talk\") || false;\n\n    return (\n      <ToggleSwitch\n        checked={pttEnabled}\n        onChange={(enabled) => updateSetting(\"push_to_talk\", enabled)}\n        isUpdating={isUpdating(\"push_to_talk\")}\n        label={t(\"settings.general.pushToTalk.label\")}\n        description={t(\"settings.general.pushToTalk.description\")}\n        descriptionMode={descriptionMode}\n        grouped={grouped}\n      />\n    );\n  },\n);\n"
  },
  {
    "path": "src/components/settings/RecordingRetentionPeriod.tsx",
    "content": "import React from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { Dropdown } from \"../ui/Dropdown\";\nimport { SettingContainer } from \"../ui/SettingContainer\";\nimport { useSettings } from \"../../hooks/useSettings\";\nimport { RecordingRetentionPeriod } from \"@/bindings\";\n\ninterface RecordingRetentionPeriodProps {\n  descriptionMode?: \"inline\" | \"tooltip\";\n  grouped?: boolean;\n}\n\nexport const RecordingRetentionPeriodSelector: React.FC<RecordingRetentionPeriodProps> =\n  React.memo(({ descriptionMode = \"tooltip\", grouped = false }) => {\n    const { t } = useTranslation();\n    const { getSetting, updateSetting, isUpdating } = useSettings();\n\n    const selectedRetentionPeriod =\n      getSetting(\"recording_retention_period\") || \"never\";\n    const historyLimit = getSetting(\"history_limit\") || 5;\n\n    const handleRetentionPeriodSelect = async (period: string) => {\n      await updateSetting(\n        \"recording_retention_period\",\n        period as RecordingRetentionPeriod,\n      );\n    };\n\n    const retentionOptions = [\n      { value: \"never\", label: t(\"settings.debug.recordingRetention.never\") },\n      {\n        value: \"preserve_limit\",\n        label: t(\"settings.debug.recordingRetention.preserveLimit\", {\n          count: Number(historyLimit),\n        }),\n      },\n      { value: \"days3\", label: t(\"settings.debug.recordingRetention.days3\") },\n      { value: \"weeks2\", label: t(\"settings.debug.recordingRetention.weeks2\") },\n      {\n        value: \"months3\",\n        label: t(\"settings.debug.recordingRetention.months3\"),\n      },\n    ];\n\n    return (\n      <SettingContainer\n        title={t(\"settings.debug.recordingRetention.title\")}\n        description={t(\"settings.debug.recordingRetention.description\")}\n        descriptionMode={descriptionMode}\n        grouped={grouped}\n      >\n        <Dropdown\n          options={retentionOptions}\n          selectedValue={selectedRetentionPeriod}\n          onSelect={handleRetentionPeriodSelect}\n          placeholder={t(\"settings.debug.recordingRetention.placeholder\")}\n          disabled={isUpdating(\"recording_retention_period\")}\n        />\n      </SettingContainer>\n    );\n  });\n\nRecordingRetentionPeriodSelector.displayName =\n  \"RecordingRetentionPeriodSelector\";\n"
  },
  {
    "path": "src/components/settings/ShortcutInput.tsx",
    "content": "import React from \"react\";\nimport { useSettings } from \"../../hooks/useSettings\";\nimport { GlobalShortcutInput } from \"./GlobalShortcutInput\";\nimport { HandyKeysShortcutInput } from \"./HandyKeysShortcutInput\";\n\ninterface ShortcutInputProps {\n  descriptionMode?: \"inline\" | \"tooltip\";\n  grouped?: boolean;\n  shortcutId: string;\n  disabled?: boolean;\n}\n\n/**\n * Wrapper component that selects the appropriate shortcut input implementation\n * based on the keyboard_implementation setting.\n *\n * - \"tauri\" (default): Uses GlobalShortcutInput with JS keyboard events\n * - \"handy_keys\": Uses HandyKeysShortcutInput with backend key events\n */\nexport const ShortcutInput: React.FC<ShortcutInputProps> = (props) => {\n  const { getSetting } = useSettings();\n  const keyboardImplementation = getSetting(\"keyboard_implementation\");\n\n  // Default to Tauri implementation if not set\n  if (keyboardImplementation === \"handy_keys\") {\n    return <HandyKeysShortcutInput {...props} />;\n  }\n\n  return <GlobalShortcutInput {...props} />;\n};\n"
  },
  {
    "path": "src/components/settings/ShowOverlay.tsx",
    "content": "import React from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { Dropdown } from \"../ui/Dropdown\";\nimport { SettingContainer } from \"../ui/SettingContainer\";\nimport { useSettings } from \"../../hooks/useSettings\";\nimport type { OverlayPosition } from \"@/bindings\";\n\ninterface ShowOverlayProps {\n  descriptionMode?: \"inline\" | \"tooltip\";\n  grouped?: boolean;\n}\n\nexport const ShowOverlay: React.FC<ShowOverlayProps> = React.memo(\n  ({ descriptionMode = \"tooltip\", grouped = false }) => {\n    const { t } = useTranslation();\n    const { getSetting, updateSetting, isUpdating } = useSettings();\n\n    const overlayOptions = [\n      { value: \"none\", label: t(\"settings.advanced.overlay.options.none\") },\n      { value: \"bottom\", label: t(\"settings.advanced.overlay.options.bottom\") },\n      { value: \"top\", label: t(\"settings.advanced.overlay.options.top\") },\n    ];\n\n    const selectedPosition = (getSetting(\"overlay_position\") ||\n      \"bottom\") as OverlayPosition;\n\n    return (\n      <SettingContainer\n        title={t(\"settings.advanced.overlay.title\")}\n        description={t(\"settings.advanced.overlay.description\")}\n        descriptionMode={descriptionMode}\n        grouped={grouped}\n      >\n        <Dropdown\n          options={overlayOptions}\n          selectedValue={selectedPosition}\n          onSelect={(value) =>\n            updateSetting(\"overlay_position\", value as OverlayPosition)\n          }\n          disabled={isUpdating(\"overlay_position\")}\n        />\n      </SettingContainer>\n    );\n  },\n);\n"
  },
  {
    "path": "src/components/settings/ShowTrayIcon.tsx",
    "content": "import React from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { ToggleSwitch } from \"../ui/ToggleSwitch\";\nimport { useSettings } from \"../../hooks/useSettings\";\n\ninterface ShowTrayIconProps {\n  descriptionMode?: \"inline\" | \"tooltip\";\n  grouped?: boolean;\n}\n\nexport const ShowTrayIcon: React.FC<ShowTrayIconProps> = React.memo(\n  ({ descriptionMode = \"tooltip\", grouped = false }) => {\n    const { t } = useTranslation();\n    const { getSetting, updateSetting, isUpdating } = useSettings();\n\n    const showTrayIcon = getSetting(\"show_tray_icon\") ?? true;\n\n    return (\n      <ToggleSwitch\n        checked={showTrayIcon}\n        onChange={(enabled) => updateSetting(\"show_tray_icon\", enabled)}\n        isUpdating={isUpdating(\"show_tray_icon\")}\n        label={t(\"settings.advanced.showTrayIcon.label\")}\n        description={t(\"settings.advanced.showTrayIcon.description\")}\n        descriptionMode={descriptionMode}\n        grouped={grouped}\n        tooltipPosition=\"bottom\"\n      />\n    );\n  },\n);\n"
  },
  {
    "path": "src/components/settings/SoundPicker.tsx",
    "content": "import React from \"react\";\nimport { Button } from \"../ui/Button\";\nimport { Dropdown, DropdownOption } from \"../ui/Dropdown\";\nimport { PlayIcon } from \"lucide-react\";\nimport { SettingContainer } from \"../ui/SettingContainer\";\nimport { useSettingsStore } from \"../../stores/settingsStore\";\nimport { useSettings } from \"../../hooks/useSettings\";\n\ninterface SoundPickerProps {\n  label: string;\n  description: string;\n}\n\nexport const SoundPicker: React.FC<SoundPickerProps> = ({\n  label,\n  description,\n}) => {\n  const { getSetting, updateSetting } = useSettings();\n  const playTestSound = useSettingsStore((state) => state.playTestSound);\n  const customSounds = useSettingsStore((state) => state.customSounds);\n\n  const selectedTheme = getSetting(\"sound_theme\") ?? \"marimba\";\n\n  const options: DropdownOption[] = [\n    { value: \"marimba\", label: \"Marimba\" },\n    { value: \"pop\", label: \"Pop\" },\n  ];\n\n  // Only add Custom option if both custom sound files exist\n  if (customSounds.start && customSounds.stop) {\n    options.push({ value: \"custom\", label: \"Custom\" });\n  }\n\n  const handlePlayBothSounds = async () => {\n    await playTestSound(\"start\");\n    await playTestSound(\"stop\");\n  };\n\n  return (\n    <SettingContainer\n      title={label}\n      description={description}\n      grouped\n      layout=\"horizontal\"\n    >\n      <div className=\"flex items-center gap-2\">\n        <Dropdown\n          selectedValue={selectedTheme}\n          onSelect={(value) =>\n            updateSetting(\"sound_theme\", value as \"marimba\" | \"pop\" | \"custom\")\n          }\n          options={options}\n        />\n        <Button\n          variant=\"ghost\"\n          size=\"sm\"\n          onClick={handlePlayBothSounds}\n          title=\"Preview sound theme (plays start then stop)\"\n        >\n          <PlayIcon className=\"h-4 w-4\" />\n        </Button>\n      </div>\n    </SettingContainer>\n  );\n};\n"
  },
  {
    "path": "src/components/settings/StartHidden.tsx",
    "content": "import React from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { ToggleSwitch } from \"../ui/ToggleSwitch\";\nimport { useSettings } from \"../../hooks/useSettings\";\n\ninterface StartHiddenProps {\n  descriptionMode?: \"inline\" | \"tooltip\";\n  grouped?: boolean;\n}\n\nexport const StartHidden: React.FC<StartHiddenProps> = React.memo(\n  ({ descriptionMode = \"tooltip\", grouped = false }) => {\n    const { t } = useTranslation();\n    const { getSetting, updateSetting, isUpdating } = useSettings();\n\n    const startHidden = getSetting(\"start_hidden\") ?? false;\n\n    return (\n      <ToggleSwitch\n        checked={startHidden}\n        onChange={(enabled) => updateSetting(\"start_hidden\", enabled)}\n        isUpdating={isUpdating(\"start_hidden\")}\n        label={t(\"settings.advanced.startHidden.label\")}\n        description={t(\"settings.advanced.startHidden.description\")}\n        descriptionMode={descriptionMode}\n        grouped={grouped}\n        tooltipPosition=\"bottom\"\n      />\n    );\n  },\n);\n"
  },
  {
    "path": "src/components/settings/TranslateToEnglish.tsx",
    "content": "import React from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { ToggleSwitch } from \"../ui/ToggleSwitch\";\nimport { useSettings } from \"../../hooks/useSettings\";\n\ninterface TranslateToEnglishProps {\n  descriptionMode?: \"inline\" | \"tooltip\";\n  grouped?: boolean;\n}\n\nexport const TranslateToEnglish: React.FC<TranslateToEnglishProps> = React.memo(\n  ({ descriptionMode = \"tooltip\", grouped = false }) => {\n    const { t } = useTranslation();\n    const { getSetting, updateSetting, isUpdating } = useSettings();\n\n    const translateToEnglish = getSetting(\"translate_to_english\") || false;\n\n    return (\n      <ToggleSwitch\n        checked={translateToEnglish}\n        onChange={(enabled) => updateSetting(\"translate_to_english\", enabled)}\n        isUpdating={isUpdating(\"translate_to_english\")}\n        label={t(\"settings.advanced.translateToEnglish.label\")}\n        description={t(\"settings.advanced.translateToEnglish.description\")}\n        descriptionMode={descriptionMode}\n        grouped={grouped}\n      />\n    );\n  },\n);\n"
  },
  {
    "path": "src/components/settings/TypingTool.tsx",
    "content": "import React, { useEffect, useState } from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { Dropdown } from \"../ui/Dropdown\";\nimport { SettingContainer } from \"../ui/SettingContainer\";\nimport { useSettings } from \"../../hooks/useSettings\";\nimport { useOsType } from \"../../hooks/useOsType\";\nimport { commands } from \"@/bindings\";\nimport type { TypingTool } from \"@/bindings\";\n\ninterface TypingToolProps {\n  descriptionMode?: \"inline\" | \"tooltip\";\n  grouped?: boolean;\n}\n\nconst allToolLabels: Record<string, string> = {\n  wtype: \"wtype\",\n  kwtype: \"kwtype\",\n  dotool: \"dotool\",\n  ydotool: \"ydotool\",\n  xdotool: \"xdotool\",\n};\n\nexport const TypingToolSetting: React.FC<TypingToolProps> = React.memo(\n  ({ descriptionMode = \"tooltip\", grouped = false }) => {\n    const { t } = useTranslation();\n    const { getSetting, updateSetting, isUpdating } = useSettings();\n    const osType = useOsType();\n    const [availableTools, setAvailableTools] = useState<string[] | null>(null);\n\n    useEffect(() => {\n      if (osType !== \"linux\") return;\n      commands\n        .getAvailableTypingTools()\n        .then(setAvailableTools)\n        .catch(() => {\n          setAvailableTools([\"auto\"]);\n        });\n    }, [osType]);\n\n    // Only show this setting on Linux\n    if (osType !== \"linux\") {\n      return null;\n    }\n\n    // Only show if paste method is \"direct\"\n    const pasteMethod = getSetting(\"paste_method\");\n    if (pasteMethod !== \"direct\") {\n      return null;\n    }\n\n    const tools = availableTools ?? [\"auto\"];\n    const typingToolOptions = tools.map((tool) =>\n      tool === \"auto\"\n        ? {\n            value: \"auto\",\n            label: t(\"settings.advanced.typingTool.options.auto\"),\n          }\n        : { value: tool, label: allToolLabels[tool] ?? tool },\n    );\n\n    const selectedTool = (getSetting(\"typing_tool\") || \"auto\") as TypingTool;\n\n    return (\n      <SettingContainer\n        title={t(\"settings.advanced.typingTool.title\")}\n        description={t(\"settings.advanced.typingTool.description\")}\n        descriptionMode={descriptionMode}\n        grouped={grouped}\n        tooltipPosition=\"bottom\"\n      >\n        <Dropdown\n          options={typingToolOptions}\n          selectedValue={selectedTool}\n          onSelect={(value) =>\n            updateSetting(\"typing_tool\", value as TypingTool)\n          }\n          disabled={isUpdating(\"typing_tool\")}\n        />\n      </SettingContainer>\n    );\n  },\n);\n"
  },
  {
    "path": "src/components/settings/UpdateChecksToggle.tsx",
    "content": "import React from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { ToggleSwitch } from \"../ui/ToggleSwitch\";\nimport { useSettings } from \"../../hooks/useSettings\";\n\ninterface UpdateChecksToggleProps {\n  descriptionMode?: \"inline\" | \"tooltip\";\n  grouped?: boolean;\n}\n\nexport const UpdateChecksToggle: React.FC<UpdateChecksToggleProps> = ({\n  descriptionMode = \"tooltip\",\n  grouped = false,\n}) => {\n  const { t } = useTranslation();\n  const { getSetting, updateSetting, isUpdating } = useSettings();\n  const updateChecksEnabled = getSetting(\"update_checks_enabled\") ?? true;\n\n  return (\n    <ToggleSwitch\n      checked={updateChecksEnabled}\n      onChange={(enabled) => updateSetting(\"update_checks_enabled\", enabled)}\n      isUpdating={isUpdating(\"update_checks_enabled\")}\n      label={t(\"settings.debug.updateChecks.label\")}\n      description={t(\"settings.debug.updateChecks.description\")}\n      descriptionMode={descriptionMode}\n      grouped={grouped}\n    />\n  );\n};\n"
  },
  {
    "path": "src/components/settings/VolumeSlider.tsx",
    "content": "import React from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { Slider } from \"../ui/Slider\";\nimport { useSettings } from \"../../hooks/useSettings\";\n\nexport const VolumeSlider: React.FC<{ disabled?: boolean }> = ({\n  disabled = false,\n}) => {\n  const { t } = useTranslation();\n  const { getSetting, updateSetting } = useSettings();\n  const audioFeedbackVolume = getSetting(\"audio_feedback_volume\") ?? 0.5;\n\n  return (\n    <Slider\n      value={audioFeedbackVolume}\n      onChange={(value: number) =>\n        updateSetting(\"audio_feedback_volume\", value)\n      }\n      min={0}\n      max={1}\n      label={t(\"settings.sound.volume.title\")}\n      description={t(\"settings.sound.volume.description\")}\n      descriptionMode=\"tooltip\"\n      grouped\n      formatValue={(value) => `${Math.round(value * 100)}%`}\n      disabled={disabled}\n    />\n  );\n};\n"
  },
  {
    "path": "src/components/settings/about/AboutSettings.tsx",
    "content": "import React, { useState, useEffect } from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { getVersion } from \"@tauri-apps/api/app\";\nimport { openUrl } from \"@tauri-apps/plugin-opener\";\nimport { SettingsGroup } from \"../../ui/SettingsGroup\";\nimport { SettingContainer } from \"../../ui/SettingContainer\";\nimport { Button } from \"../../ui/Button\";\nimport { AppDataDirectory } from \"../AppDataDirectory\";\nimport { AppLanguageSelector } from \"../AppLanguageSelector\";\nimport { LogDirectory } from \"../debug\";\n\nexport const AboutSettings: React.FC = () => {\n  const { t } = useTranslation();\n  const [version, setVersion] = useState(\"\");\n\n  useEffect(() => {\n    const fetchVersion = async () => {\n      try {\n        const appVersion = await getVersion();\n        setVersion(appVersion);\n      } catch (error) {\n        console.error(\"Failed to get app version:\", error);\n        setVersion(\"0.1.2\");\n      }\n    };\n\n    fetchVersion();\n  }, []);\n\n  const handleDonateClick = async () => {\n    try {\n      await openUrl(\"https://handy.computer/donate\");\n    } catch (error) {\n      console.error(\"Failed to open donate link:\", error);\n    }\n  };\n\n  return (\n    <div className=\"max-w-3xl w-full mx-auto space-y-6\">\n      <SettingsGroup title={t(\"settings.about.title\")}>\n        <AppLanguageSelector descriptionMode=\"tooltip\" grouped={true} />\n        <SettingContainer\n          title={t(\"settings.about.version.title\")}\n          description={t(\"settings.about.version.description\")}\n          grouped={true}\n        >\n          {/* eslint-disable-next-line i18next/no-literal-string */}\n          <span className=\"text-sm font-mono\">v{version}</span>\n        </SettingContainer>\n\n        <SettingContainer\n          title={t(\"settings.about.supportDevelopment.title\")}\n          description={t(\"settings.about.supportDevelopment.description\")}\n          grouped={true}\n        >\n          <Button variant=\"primary\" size=\"md\" onClick={handleDonateClick}>\n            {t(\"settings.about.supportDevelopment.button\")}\n          </Button>\n        </SettingContainer>\n\n        <SettingContainer\n          title={t(\"settings.about.sourceCode.title\")}\n          description={t(\"settings.about.sourceCode.description\")}\n          grouped={true}\n        >\n          <Button\n            variant=\"secondary\"\n            size=\"md\"\n            onClick={() => openUrl(\"https://github.com/cjpais/Handy\")}\n          >\n            {t(\"settings.about.sourceCode.button\")}\n          </Button>\n        </SettingContainer>\n\n        <AppDataDirectory descriptionMode=\"tooltip\" grouped={true} />\n        <LogDirectory grouped={true} />\n      </SettingsGroup>\n\n      <SettingsGroup title={t(\"settings.about.acknowledgments.title\")}>\n        <SettingContainer\n          title={t(\"settings.about.acknowledgments.whisper.title\")}\n          description={t(\"settings.about.acknowledgments.whisper.description\")}\n          grouped={true}\n          layout=\"stacked\"\n        >\n          <div className=\"text-sm text-mid-gray\">\n            {t(\"settings.about.acknowledgments.whisper.details\")}\n          </div>\n        </SettingContainer>\n      </SettingsGroup>\n    </div>\n  );\n};\n"
  },
  {
    "path": "src/components/settings/advanced/AdvancedSettings.tsx",
    "content": "import React from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { ShowOverlay } from \"../ShowOverlay\";\nimport { ModelUnloadTimeoutSetting } from \"../ModelUnloadTimeout\";\nimport { CustomWords } from \"../CustomWords\";\nimport { SettingsGroup } from \"../../ui/SettingsGroup\";\nimport { StartHidden } from \"../StartHidden\";\nimport { AutostartToggle } from \"../AutostartToggle\";\nimport { ShowTrayIcon } from \"../ShowTrayIcon\";\nimport { PasteMethodSetting } from \"../PasteMethod\";\nimport { TypingToolSetting } from \"../TypingTool\";\nimport { ClipboardHandlingSetting } from \"../ClipboardHandling\";\nimport { AutoSubmit } from \"../AutoSubmit\";\nimport { PostProcessingToggle } from \"../PostProcessingToggle\";\nimport { AppendTrailingSpace } from \"../AppendTrailingSpace\";\nimport { HistoryLimit } from \"../HistoryLimit\";\nimport { RecordingRetentionPeriodSelector } from \"../RecordingRetentionPeriod\";\nimport { ExperimentalToggle } from \"../ExperimentalToggle\";\nimport { useSettings } from \"../../../hooks/useSettings\";\nimport { KeyboardImplementationSelector } from \"../debug/KeyboardImplementationSelector\";\nimport { AccelerationSelector } from \"../AccelerationSelector\";\nimport { LazyStreamClose } from \"../LazyStreamClose\";\n\nexport const AdvancedSettings: React.FC = () => {\n  const { t } = useTranslation();\n  const { getSetting } = useSettings();\n  const experimentalEnabled = getSetting(\"experimental_enabled\") || false;\n\n  return (\n    <div className=\"max-w-3xl w-full mx-auto space-y-6\">\n      <SettingsGroup title={t(\"settings.advanced.groups.app\")}>\n        <StartHidden descriptionMode=\"tooltip\" grouped={true} />\n        <AutostartToggle descriptionMode=\"tooltip\" grouped={true} />\n        <ShowTrayIcon descriptionMode=\"tooltip\" grouped={true} />\n        <ShowOverlay descriptionMode=\"tooltip\" grouped={true} />\n        <ModelUnloadTimeoutSetting descriptionMode=\"tooltip\" grouped={true} />\n        <ExperimentalToggle descriptionMode=\"tooltip\" grouped={true} />\n      </SettingsGroup>\n\n      <SettingsGroup title={t(\"settings.advanced.groups.output\")}>\n        <PasteMethodSetting descriptionMode=\"tooltip\" grouped={true} />\n        <TypingToolSetting descriptionMode=\"tooltip\" grouped={true} />\n        <ClipboardHandlingSetting descriptionMode=\"tooltip\" grouped={true} />\n        <AutoSubmit descriptionMode=\"tooltip\" grouped={true} />\n      </SettingsGroup>\n\n      <SettingsGroup title={t(\"settings.advanced.groups.transcription\")}>\n        <CustomWords descriptionMode=\"tooltip\" grouped />\n        <AppendTrailingSpace descriptionMode=\"tooltip\" grouped={true} />\n      </SettingsGroup>\n\n      <SettingsGroup title={t(\"settings.advanced.groups.history\")}>\n        <HistoryLimit descriptionMode=\"tooltip\" grouped={true} />\n        <RecordingRetentionPeriodSelector\n          descriptionMode=\"tooltip\"\n          grouped={true}\n        />\n      </SettingsGroup>\n\n      {experimentalEnabled && (\n        <SettingsGroup title={t(\"settings.advanced.groups.experimental\")}>\n          <PostProcessingToggle descriptionMode=\"tooltip\" grouped={true} />\n          <KeyboardImplementationSelector\n            descriptionMode=\"tooltip\"\n            grouped={true}\n          />\n          <AccelerationSelector descriptionMode=\"tooltip\" grouped={true} />\n          <LazyStreamClose descriptionMode=\"tooltip\" grouped={true} />\n        </SettingsGroup>\n      )}\n    </div>\n  );\n};\n"
  },
  {
    "path": "src/components/settings/debug/DebugPaths.tsx",
    "content": "import React from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { SettingContainer } from \"../../ui/SettingContainer\";\n\ninterface DebugPathsProps {\n  descriptionMode?: \"tooltip\" | \"inline\";\n  grouped?: boolean;\n}\n\nexport const DebugPaths: React.FC<DebugPathsProps> = ({\n  descriptionMode = \"inline\",\n  grouped = false,\n}) => {\n  const { t } = useTranslation();\n\n  return (\n    <SettingContainer\n      title=\"Debug Paths\"\n      description=\"Display internal file paths and directories for debugging purposes\"\n      descriptionMode={descriptionMode}\n      grouped={grouped}\n    >\n      <div className=\"text-sm text-gray-600 space-y-2\">\n        <div>\n          <span className=\"font-medium\">\n            {t(\"settings.debug.paths.appData\")}\n          </span>{\" \"}\n          {/* eslint-disable-next-line i18next/no-literal-string */}\n          <span className=\"font-mono text-xs select-text\">%APPDATA%/handy</span>\n        </div>\n        <div>\n          <span className=\"font-medium\">\n            {t(\"settings.debug.paths.models\")}\n          </span>{\" \"}\n          {/* eslint-disable-next-line i18next/no-literal-string */}\n          <span className=\"font-mono text-xs select-text\">\n            %APPDATA%/handy/models\n          </span>\n        </div>\n        <div>\n          <span className=\"font-medium\">\n            {t(\"settings.debug.paths.settings\")}\n          </span>{\" \"}\n          {/* eslint-disable-next-line i18next/no-literal-string */}\n          <span className=\"font-mono text-xs select-text\">\n            %APPDATA%/handy/settings_store.json\n          </span>\n        </div>\n      </div>\n    </SettingContainer>\n  );\n};\n"
  },
  {
    "path": "src/components/settings/debug/DebugSettings.tsx",
    "content": "import React from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { type } from \"@tauri-apps/plugin-os\";\nimport { WordCorrectionThreshold } from \"./WordCorrectionThreshold\";\nimport { LogLevelSelector } from \"./LogLevelSelector\";\nimport { PasteDelay } from \"./PasteDelay\";\nimport { RecordingBuffer } from \"./RecordingBuffer\";\nimport { SettingsGroup } from \"../../ui/SettingsGroup\";\nimport { AlwaysOnMicrophone } from \"../AlwaysOnMicrophone\";\nimport { SoundPicker } from \"../SoundPicker\";\nimport { ClamshellMicrophoneSelector } from \"../ClamshellMicrophoneSelector\";\nimport { ShortcutInput } from \"../ShortcutInput\";\nimport { UpdateChecksToggle } from \"../UpdateChecksToggle\";\nimport { useSettings } from \"../../../hooks/useSettings\";\n\nexport const DebugSettings: React.FC = () => {\n  const { t } = useTranslation();\n  const { getSetting } = useSettings();\n  const pushToTalk = getSetting(\"push_to_talk\");\n  const isLinux = type() === \"linux\";\n\n  return (\n    <div className=\"max-w-3xl w-full mx-auto space-y-6\">\n      <SettingsGroup title={t(\"settings.debug.title\")}>\n        <LogLevelSelector grouped={true} />\n        <UpdateChecksToggle descriptionMode=\"tooltip\" grouped={true} />\n        <SoundPicker\n          label={t(\"settings.debug.soundTheme.label\")}\n          description={t(\"settings.debug.soundTheme.description\")}\n        />\n        <WordCorrectionThreshold descriptionMode=\"tooltip\" grouped={true} />\n        <PasteDelay descriptionMode=\"tooltip\" grouped={true} />\n        <RecordingBuffer descriptionMode=\"tooltip\" grouped={true} />\n        <AlwaysOnMicrophone descriptionMode=\"tooltip\" grouped={true} />\n        <ClamshellMicrophoneSelector descriptionMode=\"tooltip\" grouped={true} />\n        {/* Cancel shortcut is disabled on Linux due to instability with dynamic shortcut registration */}\n        {!isLinux && (\n          <ShortcutInput\n            shortcutId=\"cancel\"\n            grouped={true}\n            disabled={pushToTalk}\n          />\n        )}\n      </SettingsGroup>\n    </div>\n  );\n};\n"
  },
  {
    "path": "src/components/settings/debug/KeyboardImplementationSelector.tsx",
    "content": "import React from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { SettingContainer } from \"../../ui/SettingContainer\";\nimport { Dropdown, type DropdownOption } from \"../../ui/Dropdown\";\nimport { useSettings } from \"../../../hooks/useSettings\";\nimport { commands } from \"@/bindings\";\nimport { toast } from \"sonner\";\n\nconst KEYBOARD_IMPLEMENTATION_OPTIONS: DropdownOption[] = [\n  { value: \"tauri\", label: \"Tauri Global Shortcut\" },\n  { value: \"handy_keys\", label: \"Handy Keys\" },\n];\n\ninterface KeyboardImplementationSelectorProps {\n  descriptionMode?: \"tooltip\" | \"inline\";\n  grouped?: boolean;\n}\n\nexport const KeyboardImplementationSelector: React.FC<\n  KeyboardImplementationSelectorProps\n> = ({ descriptionMode = \"tooltip\", grouped = false }) => {\n  const { t } = useTranslation();\n  const { getSetting, isUpdating, refreshSettings } = useSettings();\n  const currentImplementation =\n    getSetting(\"keyboard_implementation\") ?? \"tauri\";\n\n  const handleSelect = async (value: string) => {\n    if (value === currentImplementation) return;\n\n    try {\n      const result = await commands.changeKeyboardImplementationSetting(value);\n\n      if (result.status === \"error\") {\n        console.error(\n          \"Failed to update keyboard implementation:\",\n          result.error,\n        );\n        toast.error(String(result.error));\n        return;\n      }\n\n      // If any bindings were reset due to incompatibility, notify the user\n      if (result.data.reset_bindings.length > 0) {\n        toast.warning(t(\"settings.debug.keyboardImplementation.bindingsReset\"));\n      }\n\n      await refreshSettings();\n    } catch (error) {\n      console.error(\"Failed to update keyboard implementation:\", error);\n      toast.error(String(error));\n    }\n  };\n\n  return (\n    <SettingContainer\n      title={t(\"settings.debug.keyboardImplementation.title\")}\n      description={t(\"settings.debug.keyboardImplementation.description\")}\n      descriptionMode={descriptionMode}\n      grouped={grouped}\n      layout=\"horizontal\"\n    >\n      <Dropdown\n        options={KEYBOARD_IMPLEMENTATION_OPTIONS}\n        selectedValue={currentImplementation}\n        onSelect={handleSelect}\n        disabled={isUpdating(\"keyboard_implementation\")}\n      />\n    </SettingContainer>\n  );\n};\n"
  },
  {
    "path": "src/components/settings/debug/LogDirectory.tsx",
    "content": "import React, { useEffect, useState } from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { commands } from \"@/bindings\";\nimport { SettingContainer } from \"../../ui/SettingContainer\";\nimport { PathDisplay } from \"../../ui/PathDisplay\";\n\ninterface LogDirectoryProps {\n  descriptionMode?: \"tooltip\" | \"inline\";\n  grouped?: boolean;\n}\n\nexport const LogDirectory: React.FC<LogDirectoryProps> = ({\n  descriptionMode = \"tooltip\",\n  grouped = false,\n}) => {\n  const { t } = useTranslation();\n  const [logDir, setLogDir] = useState<string>(\"\");\n  const [loading, setLoading] = useState(true);\n  const [error, setError] = useState<string | null>(null);\n\n  useEffect(() => {\n    const loadLogDirectory = async () => {\n      try {\n        const result = await commands.getLogDirPath();\n        if (result.status === \"ok\") {\n          setLogDir(result.data);\n        } else {\n          setError(result.error);\n        }\n      } catch (err) {\n        const errorMessage =\n          err && typeof err === \"object\" && \"message\" in err\n            ? String(err.message)\n            : \"Failed to load log directory\";\n        setError(errorMessage);\n      } finally {\n        setLoading(false);\n      }\n    };\n\n    loadLogDirectory();\n  }, []);\n\n  const handleOpen = async () => {\n    if (!logDir) return;\n    try {\n      await commands.openLogDir();\n    } catch (openError) {\n      console.error(\"Failed to open log directory:\", openError);\n    }\n  };\n\n  return (\n    <SettingContainer\n      title={t(\"settings.debug.logDirectory.title\")}\n      description={t(\"settings.debug.logDirectory.description\")}\n      descriptionMode={descriptionMode}\n      grouped={grouped}\n      layout=\"stacked\"\n    >\n      {loading ? (\n        <div className=\"animate-pulse\">\n          <div className=\"h-8 bg-gray-100 rounded\" />\n        </div>\n      ) : error ? (\n        <div className=\"p-3 bg-red-50 border border-red-200 rounded text-xs text-red-600\">\n          {t(\"errors.loadDirectory\", { error })}\n        </div>\n      ) : (\n        <PathDisplay path={logDir} onOpen={handleOpen} disabled={!logDir} />\n      )}\n    </SettingContainer>\n  );\n};\n"
  },
  {
    "path": "src/components/settings/debug/LogLevelSelector.tsx",
    "content": "import React from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { SettingContainer } from \"../../ui/SettingContainer\";\nimport { Dropdown, type DropdownOption } from \"../../ui/Dropdown\";\nimport { useSettings } from \"../../../hooks/useSettings\";\nimport type { LogLevel } from \"../../../bindings\";\n\nconst LOG_LEVEL_OPTIONS: DropdownOption[] = [\n  { value: \"error\", label: \"Error\" },\n  { value: \"warn\", label: \"Warn\" },\n  { value: \"info\", label: \"Info\" },\n  { value: \"debug\", label: \"Debug\" },\n  { value: \"trace\", label: \"Trace\" },\n];\n\ninterface LogLevelSelectorProps {\n  descriptionMode?: \"tooltip\" | \"inline\";\n  grouped?: boolean;\n}\n\nexport const LogLevelSelector: React.FC<LogLevelSelectorProps> = ({\n  descriptionMode = \"tooltip\",\n  grouped = false,\n}) => {\n  const { t } = useTranslation();\n  const { settings, updateSetting, isUpdating } = useSettings();\n  const currentLevel = settings?.log_level ?? \"debug\";\n\n  const handleSelect = async (value: string) => {\n    if (value === currentLevel) return;\n\n    try {\n      await updateSetting(\"log_level\", value as LogLevel);\n    } catch (error) {\n      console.error(\"Failed to update log level:\", error);\n    }\n  };\n\n  return (\n    <SettingContainer\n      title={t(\"settings.debug.logLevel.title\")}\n      description={t(\"settings.debug.logLevel.description\")}\n      descriptionMode={descriptionMode}\n      grouped={grouped}\n      layout=\"horizontal\"\n    >\n      <Dropdown\n        options={LOG_LEVEL_OPTIONS}\n        selectedValue={currentLevel}\n        onSelect={handleSelect}\n        disabled={!settings || isUpdating(\"log_level\")}\n      />\n    </SettingContainer>\n  );\n};\n"
  },
  {
    "path": "src/components/settings/debug/PasteDelay.tsx",
    "content": "import React from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { Slider } from \"../../ui/Slider\";\nimport { useSettings } from \"../../../hooks/useSettings\";\n\ninterface PasteDelayProps {\n  descriptionMode?: \"tooltip\" | \"inline\";\n  grouped?: boolean;\n}\n\nexport const PasteDelay: React.FC<PasteDelayProps> = ({\n  descriptionMode = \"tooltip\",\n  grouped = false,\n}) => {\n  const { t } = useTranslation();\n  const { settings, updateSetting } = useSettings();\n\n  const handleDelayChange = (value: number) => {\n    updateSetting(\"paste_delay_ms\", value);\n  };\n\n  return (\n    <Slider\n      value={settings?.paste_delay_ms ?? 60}\n      onChange={handleDelayChange}\n      min={10}\n      max={200}\n      step={10}\n      label={t(\"settings.debug.pasteDelay.title\")}\n      description={t(\"settings.debug.pasteDelay.description\")}\n      descriptionMode={descriptionMode}\n      grouped={grouped}\n      formatValue={(v) => `${v}ms`}\n    />\n  );\n};\n"
  },
  {
    "path": "src/components/settings/debug/RecordingBuffer.tsx",
    "content": "import React from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { Slider } from \"../../ui/Slider\";\nimport { useSettings } from \"../../../hooks/useSettings\";\n\ninterface RecordingBufferProps {\n  descriptionMode?: \"tooltip\" | \"inline\";\n  grouped?: boolean;\n}\n\nexport const RecordingBuffer: React.FC<RecordingBufferProps> = ({\n  descriptionMode = \"tooltip\",\n  grouped = false,\n}) => {\n  const { t } = useTranslation();\n  const { settings, updateSetting } = useSettings();\n\n  const handleBufferChange = (value: number) => {\n    updateSetting(\"extra_recording_buffer_ms\", value);\n  };\n\n  return (\n    <Slider\n      value={settings?.extra_recording_buffer_ms ?? 0}\n      onChange={handleBufferChange}\n      min={0}\n      max={1500}\n      step={50}\n      label={t(\"settings.debug.recordingBuffer.title\")}\n      description={t(\"settings.debug.recordingBuffer.description\")}\n      descriptionMode={descriptionMode}\n      grouped={grouped}\n      formatValue={(v) => `${v}ms`}\n    />\n  );\n};\n"
  },
  {
    "path": "src/components/settings/debug/WordCorrectionThreshold.tsx",
    "content": "import React from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { Slider } from \"../../ui/Slider\";\nimport { useSettings } from \"../../../hooks/useSettings\";\n\ninterface WordCorrectionThresholdProps {\n  descriptionMode?: \"tooltip\" | \"inline\";\n  grouped?: boolean;\n}\n\nexport const WordCorrectionThreshold: React.FC<\n  WordCorrectionThresholdProps\n> = ({ descriptionMode = \"tooltip\", grouped = false }) => {\n  const { t } = useTranslation();\n  const { settings, updateSetting } = useSettings();\n\n  const handleThresholdChange = (value: number) => {\n    updateSetting(\"word_correction_threshold\", value);\n  };\n\n  return (\n    <Slider\n      value={settings?.word_correction_threshold ?? 0.18}\n      onChange={handleThresholdChange}\n      min={0.0}\n      max={1.0}\n      label={t(\"settings.debug.wordCorrectionThreshold.title\")}\n      description={t(\"settings.debug.wordCorrectionThreshold.description\")}\n      descriptionMode={descriptionMode}\n      grouped={grouped}\n    />\n  );\n};\n"
  },
  {
    "path": "src/components/settings/debug/index.ts",
    "content": "export { WordCorrectionThreshold } from \"./WordCorrectionThreshold\";\nexport { LogDirectory } from \"./LogDirectory\";\nexport { LogLevelSelector } from \"./LogLevelSelector\";\n"
  },
  {
    "path": "src/components/settings/general/GeneralSettings.tsx",
    "content": "import React from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { MicrophoneSelector } from \"../MicrophoneSelector\";\nimport { ShortcutInput } from \"../ShortcutInput\";\nimport { SettingsGroup } from \"../../ui/SettingsGroup\";\nimport { OutputDeviceSelector } from \"../OutputDeviceSelector\";\nimport { PushToTalk } from \"../PushToTalk\";\nimport { AudioFeedback } from \"../AudioFeedback\";\nimport { useSettings } from \"../../../hooks/useSettings\";\nimport { VolumeSlider } from \"../VolumeSlider\";\nimport { MuteWhileRecording } from \"../MuteWhileRecording\";\nimport { ModelSettingsCard } from \"./ModelSettingsCard\";\n\nexport const GeneralSettings: React.FC = () => {\n  const { t } = useTranslation();\n  const { audioFeedbackEnabled } = useSettings();\n  return (\n    <div className=\"max-w-3xl w-full mx-auto space-y-6\">\n      <SettingsGroup title={t(\"settings.general.title\")}>\n        <ShortcutInput shortcutId=\"transcribe\" grouped={true} />\n        <PushToTalk descriptionMode=\"tooltip\" grouped={true} />\n      </SettingsGroup>\n      <ModelSettingsCard />\n      <SettingsGroup title={t(\"settings.sound.title\")}>\n        <MicrophoneSelector descriptionMode=\"tooltip\" grouped={true} />\n        <MuteWhileRecording descriptionMode=\"tooltip\" grouped={true} />\n        <AudioFeedback descriptionMode=\"tooltip\" grouped={true} />\n        <OutputDeviceSelector\n          descriptionMode=\"tooltip\"\n          grouped={true}\n          disabled={!audioFeedbackEnabled}\n        />\n        <VolumeSlider disabled={!audioFeedbackEnabled} />\n      </SettingsGroup>\n    </div>\n  );\n};\n"
  },
  {
    "path": "src/components/settings/general/ModelSettingsCard.tsx",
    "content": "import React from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { SettingsGroup } from \"../../ui/SettingsGroup\";\nimport { LanguageSelector } from \"../LanguageSelector\";\nimport { TranslateToEnglish } from \"../TranslateToEnglish\";\nimport { useModelStore } from \"../../../stores/modelStore\";\nimport type { ModelInfo } from \"@/bindings\";\n\nexport const ModelSettingsCard: React.FC = () => {\n  const { t } = useTranslation();\n  const { currentModel, models } = useModelStore();\n\n  const currentModelInfo = models.find((m: ModelInfo) => m.id === currentModel);\n\n  const supportsLanguageSelection =\n    currentModelInfo?.supports_language_selection ?? false;\n  const supportsTranslation = currentModelInfo?.supports_translation ?? false;\n  const hasAnySettings = supportsLanguageSelection || supportsTranslation;\n\n  // Don't render anything if no model is selected or no settings available\n  if (!currentModel || !currentModelInfo || !hasAnySettings) {\n    return null;\n  }\n\n  return (\n    <SettingsGroup\n      title={t(\"settings.modelSettings.title\", {\n        model: currentModelInfo.name,\n      })}\n    >\n      {supportsLanguageSelection && (\n        <LanguageSelector\n          descriptionMode=\"tooltip\"\n          grouped={true}\n          supportedLanguages={currentModelInfo.supported_languages}\n        />\n      )}\n      {supportsTranslation && (\n        <TranslateToEnglish descriptionMode=\"tooltip\" grouped={true} />\n      )}\n    </SettingsGroup>\n  );\n};\n"
  },
  {
    "path": "src/components/settings/history/HistorySettings.tsx",
    "content": "import React, { useState, useEffect, useCallback } from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { AudioPlayer } from \"../../ui/AudioPlayer\";\nimport { Button } from \"../../ui/Button\";\nimport { Copy, Star, Check, Trash2, FolderOpen } from \"lucide-react\";\nimport { convertFileSrc } from \"@tauri-apps/api/core\";\nimport { listen } from \"@tauri-apps/api/event\";\nimport { readFile } from \"@tauri-apps/plugin-fs\";\nimport { commands, type HistoryEntry } from \"@/bindings\";\nimport { formatDateTime } from \"@/utils/dateFormat\";\nimport { useOsType } from \"@/hooks/useOsType\";\n\ninterface OpenRecordingsButtonProps {\n  onClick: () => void;\n  label: string;\n}\n\nconst OpenRecordingsButton: React.FC<OpenRecordingsButtonProps> = ({\n  onClick,\n  label,\n}) => (\n  <Button\n    onClick={onClick}\n    variant=\"secondary\"\n    size=\"sm\"\n    className=\"flex items-center gap-2\"\n    title={label}\n  >\n    <FolderOpen className=\"w-4 h-4\" />\n    <span>{label}</span>\n  </Button>\n);\n\nexport const HistorySettings: React.FC = () => {\n  const { t } = useTranslation();\n  const osType = useOsType();\n  const [historyEntries, setHistoryEntries] = useState<HistoryEntry[]>([]);\n  const [loading, setLoading] = useState(true);\n\n  const loadHistoryEntries = useCallback(async () => {\n    try {\n      const result = await commands.getHistoryEntries();\n      if (result.status === \"ok\") {\n        setHistoryEntries(result.data);\n      }\n    } catch (error) {\n      console.error(\"Failed to load history entries:\", error);\n    } finally {\n      setLoading(false);\n    }\n  }, []);\n\n  useEffect(() => {\n    loadHistoryEntries();\n\n    // Listen for history update events\n    const setupListener = async () => {\n      const unlisten = await listen(\"history-updated\", () => {\n        console.log(\"History updated, reloading entries...\");\n        loadHistoryEntries();\n      });\n\n      // Return cleanup function\n      return unlisten;\n    };\n\n    let unlistenPromise = setupListener();\n\n    return () => {\n      unlistenPromise.then((unlisten) => {\n        if (unlisten) {\n          unlisten();\n        }\n      });\n    };\n  }, [loadHistoryEntries]);\n\n  const toggleSaved = async (id: number) => {\n    try {\n      await commands.toggleHistoryEntrySaved(id);\n      // No need to reload here - the event listener will handle it\n    } catch (error) {\n      console.error(\"Failed to toggle saved status:\", error);\n    }\n  };\n\n  const copyToClipboard = async (text: string) => {\n    try {\n      await navigator.clipboard.writeText(text);\n    } catch (error) {\n      console.error(\"Failed to copy to clipboard:\", error);\n    }\n  };\n\n  const getAudioUrl = useCallback(\n    async (fileName: string) => {\n      try {\n        const result = await commands.getAudioFilePath(fileName);\n        if (result.status === \"ok\") {\n          if (osType === \"linux\") {\n            const fileData = await readFile(result.data);\n            const blob = new Blob([fileData], { type: \"audio/wav\" });\n\n            return URL.createObjectURL(blob);\n          }\n\n          return convertFileSrc(result.data, \"asset\");\n        }\n        return null;\n      } catch (error) {\n        console.error(\"Failed to get audio file path:\", error);\n        return null;\n      }\n    },\n    [osType],\n  );\n\n  const deleteAudioEntry = async (id: number) => {\n    try {\n      await commands.deleteHistoryEntry(id);\n    } catch (error) {\n      console.error(\"Failed to delete audio entry:\", error);\n      throw error;\n    }\n  };\n\n  const openRecordingsFolder = async () => {\n    try {\n      await commands.openRecordingsFolder();\n    } catch (error) {\n      console.error(\"Failed to open recordings folder:\", error);\n    }\n  };\n\n  if (loading) {\n    return (\n      <div className=\"max-w-3xl w-full mx-auto space-y-6\">\n        <div className=\"space-y-2\">\n          <div className=\"px-4 flex items-center justify-between\">\n            <div>\n              <h2 className=\"text-xs font-medium text-mid-gray uppercase tracking-wide\">\n                {t(\"settings.history.title\")}\n              </h2>\n            </div>\n            <OpenRecordingsButton\n              onClick={openRecordingsFolder}\n              label={t(\"settings.history.openFolder\")}\n            />\n          </div>\n          <div className=\"bg-background border border-mid-gray/20 rounded-lg overflow-visible\">\n            <div className=\"px-4 py-3 text-center text-text/60\">\n              {t(\"settings.history.loading\")}\n            </div>\n          </div>\n        </div>\n      </div>\n    );\n  }\n\n  if (historyEntries.length === 0) {\n    return (\n      <div className=\"max-w-3xl w-full mx-auto space-y-6\">\n        <div className=\"space-y-2\">\n          <div className=\"px-4 flex items-center justify-between\">\n            <div>\n              <h2 className=\"text-xs font-medium text-mid-gray uppercase tracking-wide\">\n                {t(\"settings.history.title\")}\n              </h2>\n            </div>\n            <OpenRecordingsButton\n              onClick={openRecordingsFolder}\n              label={t(\"settings.history.openFolder\")}\n            />\n          </div>\n          <div className=\"bg-background border border-mid-gray/20 rounded-lg overflow-visible\">\n            <div className=\"px-4 py-3 text-center text-text/60\">\n              {t(\"settings.history.empty\")}\n            </div>\n          </div>\n        </div>\n      </div>\n    );\n  }\n\n  return (\n    <div className=\"max-w-3xl w-full mx-auto space-y-6\">\n      <div className=\"space-y-2\">\n        <div className=\"px-4 flex items-center justify-between\">\n          <div>\n            <h2 className=\"text-xs font-medium text-mid-gray uppercase tracking-wide\">\n              {t(\"settings.history.title\")}\n            </h2>\n          </div>\n          <OpenRecordingsButton\n            onClick={openRecordingsFolder}\n            label={t(\"settings.history.openFolder\")}\n          />\n        </div>\n        <div className=\"bg-background border border-mid-gray/20 rounded-lg overflow-visible\">\n          <div className=\"divide-y divide-mid-gray/20\">\n            {historyEntries.map((entry) => (\n              <HistoryEntryComponent\n                key={entry.id}\n                entry={entry}\n                onToggleSaved={() => toggleSaved(entry.id)}\n                onCopyText={() => copyToClipboard(entry.transcription_text)}\n                getAudioUrl={getAudioUrl}\n                deleteAudio={deleteAudioEntry}\n              />\n            ))}\n          </div>\n        </div>\n      </div>\n    </div>\n  );\n};\n\ninterface HistoryEntryProps {\n  entry: HistoryEntry;\n  onToggleSaved: () => void;\n  onCopyText: () => void;\n  getAudioUrl: (fileName: string) => Promise<string | null>;\n  deleteAudio: (id: number) => Promise<void>;\n}\n\nconst HistoryEntryComponent: React.FC<HistoryEntryProps> = ({\n  entry,\n  onToggleSaved,\n  onCopyText,\n  getAudioUrl,\n  deleteAudio,\n}) => {\n  const { t, i18n } = useTranslation();\n  const [showCopied, setShowCopied] = useState(false);\n\n  const handleLoadAudio = useCallback(\n    () => getAudioUrl(entry.file_name),\n    [getAudioUrl, entry.file_name],\n  );\n\n  const handleCopyText = () => {\n    onCopyText();\n    setShowCopied(true);\n    setTimeout(() => setShowCopied(false), 2000);\n  };\n\n  const handleDeleteEntry = async () => {\n    try {\n      await deleteAudio(entry.id);\n    } catch (error) {\n      console.error(\"Failed to delete entry:\", error);\n      alert(\"Failed to delete entry. Please try again.\");\n    }\n  };\n\n  const formattedDate = formatDateTime(String(entry.timestamp), i18n.language);\n\n  return (\n    <div className=\"px-4 py-2 pb-5 flex flex-col gap-3\">\n      <div className=\"flex justify-between items-center\">\n        <p className=\"text-sm font-medium\">{formattedDate}</p>\n        <div className=\"flex items-center gap-1\">\n          <button\n            onClick={handleCopyText}\n            className=\"text-text/50 hover:text-logo-primary  hover:border-logo-primary transition-colors cursor-pointer\"\n            title={t(\"settings.history.copyToClipboard\")}\n          >\n            {showCopied ? (\n              <Check width={16} height={16} />\n            ) : (\n              <Copy width={16} height={16} />\n            )}\n          </button>\n          <button\n            onClick={onToggleSaved}\n            className={`p-2 rounded-md transition-colors cursor-pointer ${\n              entry.saved\n                ? \"text-logo-primary hover:text-logo-primary/80\"\n                : \"text-text/50 hover:text-logo-primary\"\n            }`}\n            title={\n              entry.saved\n                ? t(\"settings.history.unsave\")\n                : t(\"settings.history.save\")\n            }\n          >\n            <Star\n              width={16}\n              height={16}\n              fill={entry.saved ? \"currentColor\" : \"none\"}\n            />\n          </button>\n          <button\n            onClick={handleDeleteEntry}\n            className=\"text-text/50 hover:text-logo-primary transition-colors cursor-pointer\"\n            title={t(\"settings.history.delete\")}\n          >\n            <Trash2 width={16} height={16} />\n          </button>\n        </div>\n      </div>\n      <p className=\"italic text-text/90 text-sm pb-2 select-text cursor-text\">\n        {entry.transcription_text}\n      </p>\n      <AudioPlayer onLoadRequest={handleLoadAudio} className=\"w-full\" />\n    </div>\n  );\n};\n"
  },
  {
    "path": "src/components/settings/index.ts",
    "content": "// Settings section components\nexport { GeneralSettings } from \"./general/GeneralSettings\";\nexport { AdvancedSettings } from \"./advanced/AdvancedSettings\";\nexport { DebugSettings } from \"./debug/DebugSettings\";\nexport { HistorySettings } from \"./history/HistorySettings\";\nexport { AboutSettings } from \"./about/AboutSettings\";\nexport { PostProcessingSettings } from \"./post-processing/PostProcessingSettings\";\nexport { ModelsSettings } from \"./models/ModelsSettings\";\n\n// Individual setting components\nexport { MicrophoneSelector } from \"./MicrophoneSelector\";\nexport { ClamshellMicrophoneSelector } from \"./ClamshellMicrophoneSelector\";\nexport { OutputDeviceSelector } from \"./OutputDeviceSelector\";\nexport { AlwaysOnMicrophone } from \"./AlwaysOnMicrophone\";\nexport { PushToTalk } from \"./PushToTalk\";\nexport { AudioFeedback } from \"./AudioFeedback\";\nexport { ShowOverlay } from \"./ShowOverlay\";\nexport { GlobalShortcutInput } from \"./GlobalShortcutInput\";\nexport { HandyKeysShortcutInput } from \"./HandyKeysShortcutInput\";\nexport { ShortcutInput } from \"./ShortcutInput\";\nexport { TranslateToEnglish } from \"./TranslateToEnglish\";\nexport { CustomWords } from \"./CustomWords\";\nexport { PostProcessingToggle } from \"./PostProcessingToggle\";\nexport { PostProcessingSettingsApi } from \"./PostProcessingSettingsApi\";\nexport { PostProcessingSettingsPrompts } from \"./PostProcessingSettingsPrompts\";\nexport { AppDataDirectory } from \"./AppDataDirectory\";\nexport { ModelUnloadTimeoutSetting } from \"./ModelUnloadTimeout\";\nexport { StartHidden } from \"./StartHidden\";\nexport { HistoryLimit } from \"./HistoryLimit\";\nexport { RecordingRetentionPeriodSelector } from \"./RecordingRetentionPeriod\";\nexport { AutostartToggle } from \"./AutostartToggle\";\nexport { UpdateChecksToggle } from \"./UpdateChecksToggle\";\n"
  },
  {
    "path": "src/components/settings/models/ModelsSettings.tsx",
    "content": "import React, { useEffect, useMemo, useRef, useState } from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { ask } from \"@tauri-apps/plugin-dialog\";\nimport { ChevronDown, Globe } from \"lucide-react\";\nimport type { ModelCardStatus } from \"@/components/onboarding\";\nimport { ModelCard } from \"@/components/onboarding\";\nimport { useModelStore } from \"@/stores/modelStore\";\nimport { LANGUAGES } from \"@/lib/constants/languages.ts\";\nimport type { ModelInfo } from \"@/bindings\";\n\n// check if model supports a language based on its supported_languages list\nconst modelSupportsLanguage = (model: ModelInfo, langCode: string): boolean => {\n  return model.supported_languages.includes(langCode);\n};\n\nexport const ModelsSettings: React.FC = () => {\n  const { t } = useTranslation();\n  const [switchingModelId, setSwitchingModelId] = useState<string | null>(null);\n  const [languageFilter, setLanguageFilter] = useState(\"all\");\n  const [languageDropdownOpen, setLanguageDropdownOpen] = useState(false);\n  const [languageSearch, setLanguageSearch] = useState(\"\");\n  const languageDropdownRef = useRef<HTMLDivElement>(null);\n  const languageSearchInputRef = useRef<HTMLInputElement>(null);\n  const {\n    models,\n    currentModel,\n    downloadingModels,\n    downloadProgress,\n    downloadStats,\n    extractingModels,\n    loading,\n    downloadModel,\n    cancelDownload,\n    selectModel,\n    deleteModel,\n  } = useModelStore();\n\n  // click outside handler for language dropdown\n  useEffect(() => {\n    const handleClickOutside = (event: MouseEvent) => {\n      if (\n        languageDropdownRef.current &&\n        !languageDropdownRef.current.contains(event.target as Node)\n      ) {\n        setLanguageDropdownOpen(false);\n        setLanguageSearch(\"\");\n      }\n    };\n    document.addEventListener(\"mousedown\", handleClickOutside);\n    return () => document.removeEventListener(\"mousedown\", handleClickOutside);\n  }, []);\n\n  // focus search input when dropdown opens\n  useEffect(() => {\n    if (languageDropdownOpen && languageSearchInputRef.current) {\n      languageSearchInputRef.current.focus();\n    }\n  }, [languageDropdownOpen]);\n\n  // filtered languages for dropdown (exclude \"auto\")\n  const filteredLanguages = useMemo(() => {\n    return LANGUAGES.filter(\n      (lang) =>\n        lang.value !== \"auto\" &&\n        lang.label.toLowerCase().includes(languageSearch.toLowerCase()),\n    );\n  }, [languageSearch]);\n\n  // Get selected language label\n  const selectedLanguageLabel = useMemo(() => {\n    if (languageFilter === \"all\") {\n      return t(\"settings.models.filters.allLanguages\");\n    }\n    return LANGUAGES.find((lang) => lang.value === languageFilter)?.label || \"\";\n  }, [languageFilter, t]);\n\n  const getModelStatus = (modelId: string): ModelCardStatus => {\n    if (modelId in extractingModels) {\n      return \"extracting\";\n    }\n    if (modelId in downloadingModels) {\n      return \"downloading\";\n    }\n    if (switchingModelId === modelId) {\n      return \"switching\";\n    }\n    if (modelId === currentModel) {\n      return \"active\";\n    }\n    const model = models.find((m: ModelInfo) => m.id === modelId);\n    if (model?.is_downloaded) {\n      return \"available\";\n    }\n    return \"downloadable\";\n  };\n\n  const getDownloadProgress = (modelId: string): number | undefined => {\n    const progress = downloadProgress[modelId];\n    return progress?.percentage;\n  };\n\n  const getDownloadSpeed = (modelId: string): number | undefined => {\n    const stats = downloadStats[modelId];\n    return stats?.speed;\n  };\n\n  const handleModelSelect = async (modelId: string) => {\n    setSwitchingModelId(modelId);\n    try {\n      await selectModel(modelId);\n    } finally {\n      setSwitchingModelId(null);\n    }\n  };\n\n  const handleModelDownload = async (modelId: string) => {\n    await downloadModel(modelId);\n  };\n\n  const handleModelDelete = async (modelId: string) => {\n    const model = models.find((m: ModelInfo) => m.id === modelId);\n    const modelName = model?.name || modelId;\n    const isActive = modelId === currentModel;\n\n    const confirmed = await ask(\n      isActive\n        ? t(\"settings.models.deleteActiveConfirm\", { modelName })\n        : t(\"settings.models.deleteConfirm\", { modelName }),\n      {\n        title: t(\"settings.models.deleteTitle\"),\n        kind: \"warning\",\n      },\n    );\n\n    if (confirmed) {\n      try {\n        await deleteModel(modelId);\n      } catch (err) {\n        console.error(`Failed to delete model ${modelId}:`, err);\n      }\n    }\n  };\n\n  const handleModelCancel = async (modelId: string) => {\n    try {\n      await cancelDownload(modelId);\n    } catch (err) {\n      console.error(`Failed to cancel download for ${modelId}:`, err);\n    }\n  };\n\n  // Filter models based on language filter\n  const filteredModels = useMemo(() => {\n    return models.filter((model: ModelInfo) => {\n      if (languageFilter !== \"all\") {\n        if (!modelSupportsLanguage(model, languageFilter)) return false;\n      }\n      return true;\n    });\n  }, [models, languageFilter]);\n\n  // Split filtered models into downloaded (including custom) and available sections\n  const { downloadedModels, availableModels } = useMemo(() => {\n    const downloaded: ModelInfo[] = [];\n    const available: ModelInfo[] = [];\n\n    for (const model of filteredModels) {\n      if (\n        model.is_custom ||\n        model.is_downloaded ||\n        model.id in downloadingModels ||\n        model.id in extractingModels\n      ) {\n        downloaded.push(model);\n      } else {\n        available.push(model);\n      }\n    }\n\n    // Sort: active model first, then non-custom, then custom at the bottom\n    downloaded.sort((a, b) => {\n      if (a.id === currentModel) return -1;\n      if (b.id === currentModel) return 1;\n      if (a.is_custom !== b.is_custom) return a.is_custom ? 1 : -1;\n      return 0;\n    });\n\n    return {\n      downloadedModels: downloaded,\n      availableModels: available,\n    };\n  }, [filteredModels, downloadingModels, extractingModels, currentModel]);\n\n  if (loading) {\n    return (\n      <div className=\"max-w-3xl w-full mx-auto\">\n        <div className=\"flex items-center justify-center py-16\">\n          <div className=\"w-8 h-8 border-2 border-logo-primary border-t-transparent rounded-full animate-spin\" />\n        </div>\n      </div>\n    );\n  }\n\n  return (\n    <div className=\"max-w-3xl w-full mx-auto space-y-4\">\n      <div className=\"mb-4\">\n        <h1 className=\"text-xl font-semibold mb-2\">\n          {t(\"settings.models.title\")}\n        </h1>\n        <p className=\"text-sm text-text/60\">\n          {t(\"settings.models.description\")}\n        </p>\n      </div>\n      {filteredModels.length > 0 ? (\n        <div className=\"space-y-6\">\n          {/* Downloaded Models Section — header always visible so filter stays accessible */}\n          <div className=\"space-y-3\">\n            <div className=\"flex items-center justify-between\">\n              <h2 className=\"text-sm font-medium text-text/60\">\n                {t(\"settings.models.yourModels\")}\n              </h2>\n              {/* Language filter dropdown */}\n              <div className=\"relative\" ref={languageDropdownRef}>\n                <button\n                  type=\"button\"\n                  onClick={() => setLanguageDropdownOpen(!languageDropdownOpen)}\n                  className={`flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium rounded-lg transition-colors ${\n                    languageFilter !== \"all\"\n                      ? \"bg-logo-primary/20 text-logo-primary\"\n                      : \"bg-mid-gray/10 text-text/60 hover:bg-mid-gray/20\"\n                  }`}\n                >\n                  <Globe className=\"w-3.5 h-3.5\" />\n                  <span className=\"max-w-[120px] truncate\">\n                    {selectedLanguageLabel}\n                  </span>\n                  <ChevronDown\n                    className={`w-3.5 h-3.5 transition-transform ${\n                      languageDropdownOpen ? \"rotate-180\" : \"\"\n                    }`}\n                  />\n                </button>\n\n                {languageDropdownOpen && (\n                  <div className=\"absolute top-full right-0 mt-1 w-56 bg-background border border-mid-gray/80 rounded-lg shadow-lg z-50 overflow-hidden\">\n                    <div className=\"p-2 border-b border-mid-gray/40\">\n                      <input\n                        ref={languageSearchInputRef}\n                        type=\"text\"\n                        value={languageSearch}\n                        onChange={(e) => setLanguageSearch(e.target.value)}\n                        onKeyDown={(e) => {\n                          if (\n                            e.key === \"Enter\" &&\n                            filteredLanguages.length > 0\n                          ) {\n                            setLanguageFilter(filteredLanguages[0].value);\n                            setLanguageDropdownOpen(false);\n                            setLanguageSearch(\"\");\n                          } else if (e.key === \"Escape\") {\n                            setLanguageDropdownOpen(false);\n                            setLanguageSearch(\"\");\n                          }\n                        }}\n                        placeholder={t(\n                          \"settings.general.language.searchPlaceholder\",\n                        )}\n                        className=\"w-full px-2 py-1 text-sm bg-mid-gray/10 border border-mid-gray/40 rounded-md focus:outline-none focus:ring-1 focus:ring-logo-primary\"\n                      />\n                    </div>\n                    <div className=\"max-h-48 overflow-y-auto\">\n                      <button\n                        type=\"button\"\n                        onClick={() => {\n                          setLanguageFilter(\"all\");\n                          setLanguageDropdownOpen(false);\n                          setLanguageSearch(\"\");\n                        }}\n                        className={`w-full px-3 py-1.5 text-sm text-left transition-colors ${\n                          languageFilter === \"all\"\n                            ? \"bg-logo-primary/20 text-logo-primary font-semibold\"\n                            : \"hover:bg-mid-gray/10\"\n                        }`}\n                      >\n                        {t(\"settings.models.filters.allLanguages\")}\n                      </button>\n                      {filteredLanguages.map((lang) => (\n                        <button\n                          key={lang.value}\n                          type=\"button\"\n                          onClick={() => {\n                            setLanguageFilter(lang.value);\n                            setLanguageDropdownOpen(false);\n                            setLanguageSearch(\"\");\n                          }}\n                          className={`w-full px-3 py-1.5 text-sm text-left transition-colors ${\n                            languageFilter === lang.value\n                              ? \"bg-logo-primary/20 text-logo-primary font-semibold\"\n                              : \"hover:bg-mid-gray/10\"\n                          }`}\n                        >\n                          {lang.label}\n                        </button>\n                      ))}\n                      {filteredLanguages.length === 0 && (\n                        <div className=\"px-3 py-2 text-sm text-text/50 text-center\">\n                          {t(\"settings.general.language.noResults\")}\n                        </div>\n                      )}\n                    </div>\n                  </div>\n                )}\n              </div>\n            </div>\n            {downloadedModels.map((model: ModelInfo) => (\n              <ModelCard\n                key={model.id}\n                model={model}\n                status={getModelStatus(model.id)}\n                onSelect={handleModelSelect}\n                onDownload={handleModelDownload}\n                onDelete={handleModelDelete}\n                onCancel={handleModelCancel}\n                downloadProgress={getDownloadProgress(model.id)}\n                downloadSpeed={getDownloadSpeed(model.id)}\n                showRecommended={false}\n              />\n            ))}\n          </div>\n\n          {/* Available Models Section */}\n          {availableModels.length > 0 && (\n            <div className=\"space-y-3\">\n              <h2 className=\"text-sm font-medium text-text/60\">\n                {t(\"settings.models.availableModels\")}\n              </h2>\n              {availableModels.map((model: ModelInfo) => (\n                <ModelCard\n                  key={model.id}\n                  model={model}\n                  status={getModelStatus(model.id)}\n                  onSelect={handleModelSelect}\n                  onDownload={handleModelDownload}\n                  onDelete={handleModelDelete}\n                  onCancel={handleModelCancel}\n                  downloadProgress={getDownloadProgress(model.id)}\n                  downloadSpeed={getDownloadSpeed(model.id)}\n                  showRecommended={false}\n                />\n              ))}\n            </div>\n          )}\n        </div>\n      ) : (\n        <div className=\"text-center py-8 text-text/50\">\n          {t(\"settings.models.noModelsMatch\")}\n        </div>\n      )}\n    </div>\n  );\n};\n"
  },
  {
    "path": "src/components/settings/models/index.ts",
    "content": "export { ModelsSettings } from \"./ModelsSettings\";\n"
  },
  {
    "path": "src/components/settings/post-processing/PostProcessingSettings.tsx",
    "content": "import React, { useEffect, useState } from \"react\";\nimport { Trans, useTranslation } from \"react-i18next\";\nimport { RefreshCcw } from \"lucide-react\";\nimport { commands } from \"@/bindings\";\n\nimport { Alert } from \"../../ui/Alert\";\nimport {\n  Dropdown,\n  SettingContainer,\n  SettingsGroup,\n  Textarea,\n} from \"@/components/ui\";\nimport { Button } from \"../../ui/Button\";\nimport { ResetButton } from \"../../ui/ResetButton\";\nimport { Input } from \"../../ui/Input\";\n\nimport { ProviderSelect } from \"../PostProcessingSettingsApi/ProviderSelect\";\nimport { BaseUrlField } from \"../PostProcessingSettingsApi/BaseUrlField\";\nimport { ApiKeyField } from \"../PostProcessingSettingsApi/ApiKeyField\";\nimport { ModelSelect } from \"../PostProcessingSettingsApi/ModelSelect\";\nimport { usePostProcessProviderState } from \"../PostProcessingSettingsApi/usePostProcessProviderState\";\nimport { ShortcutInput } from \"../ShortcutInput\";\nimport { useSettings } from \"../../../hooks/useSettings\";\n\nconst PostProcessingSettingsApiComponent: React.FC = () => {\n  const { t } = useTranslation();\n  const state = usePostProcessProviderState();\n\n  return (\n    <>\n      <SettingContainer\n        title={t(\"settings.postProcessing.api.provider.title\")}\n        description={t(\"settings.postProcessing.api.provider.description\")}\n        descriptionMode=\"tooltip\"\n        layout=\"horizontal\"\n        grouped={true}\n      >\n        <div className=\"flex items-center gap-2\">\n          <ProviderSelect\n            options={state.providerOptions}\n            value={state.selectedProviderId}\n            onChange={state.handleProviderSelect}\n          />\n        </div>\n      </SettingContainer>\n\n      {state.isAppleProvider ? (\n        state.appleIntelligenceUnavailable ? (\n          <Alert variant=\"error\" contained>\n            {t(\"settings.postProcessing.api.appleIntelligence.unavailable\")}\n          </Alert>\n        ) : null\n      ) : (\n        <>\n          {state.selectedProvider?.id === \"custom\" && (\n            <SettingContainer\n              title={t(\"settings.postProcessing.api.baseUrl.title\")}\n              description={t(\"settings.postProcessing.api.baseUrl.description\")}\n              descriptionMode=\"tooltip\"\n              layout=\"horizontal\"\n              grouped={true}\n            >\n              <div className=\"flex items-center gap-2\">\n                <BaseUrlField\n                  value={state.baseUrl}\n                  onBlur={state.handleBaseUrlChange}\n                  placeholder={t(\n                    \"settings.postProcessing.api.baseUrl.placeholder\",\n                  )}\n                  disabled={state.isBaseUrlUpdating}\n                  className=\"min-w-[380px]\"\n                />\n              </div>\n            </SettingContainer>\n          )}\n\n          <SettingContainer\n            title={t(\"settings.postProcessing.api.apiKey.title\")}\n            description={t(\"settings.postProcessing.api.apiKey.description\")}\n            descriptionMode=\"tooltip\"\n            layout=\"horizontal\"\n            grouped={true}\n          >\n            <div className=\"flex items-center gap-2\">\n              <ApiKeyField\n                value={state.apiKey}\n                onBlur={state.handleApiKeyChange}\n                placeholder={t(\n                  \"settings.postProcessing.api.apiKey.placeholder\",\n                )}\n                disabled={state.isApiKeyUpdating}\n                className=\"min-w-[320px]\"\n              />\n            </div>\n          </SettingContainer>\n        </>\n      )}\n\n      {!state.isAppleProvider && (\n        <SettingContainer\n          title={t(\"settings.postProcessing.api.model.title\")}\n          description={\n            state.isCustomProvider\n              ? t(\"settings.postProcessing.api.model.descriptionCustom\")\n              : t(\"settings.postProcessing.api.model.descriptionDefault\")\n          }\n          descriptionMode=\"tooltip\"\n          layout=\"stacked\"\n          grouped={true}\n        >\n          <div className=\"flex items-center gap-2\">\n            <ModelSelect\n              value={state.model}\n              options={state.modelOptions}\n              disabled={state.isModelUpdating}\n              isLoading={state.isFetchingModels}\n              placeholder={\n                state.modelOptions.length > 0\n                  ? t(\n                      \"settings.postProcessing.api.model.placeholderWithOptions\",\n                    )\n                  : t(\"settings.postProcessing.api.model.placeholderNoOptions\")\n              }\n              onSelect={state.handleModelSelect}\n              onCreate={state.handleModelCreate}\n              onBlur={() => {}}\n              className=\"flex-1 min-w-[380px]\"\n            />\n            <ResetButton\n              onClick={state.handleRefreshModels}\n              disabled={state.isFetchingModels}\n              ariaLabel={t(\"settings.postProcessing.api.model.refreshModels\")}\n              className=\"flex h-10 w-10 items-center justify-center\"\n            >\n              <RefreshCcw\n                className={`h-4 w-4 ${state.isFetchingModels ? \"animate-spin\" : \"\"}`}\n              />\n            </ResetButton>\n          </div>\n        </SettingContainer>\n      )}\n    </>\n  );\n};\n\nconst PostProcessingSettingsPromptsComponent: React.FC = () => {\n  const { t } = useTranslation();\n  const { getSetting, updateSetting, isUpdating, refreshSettings } =\n    useSettings();\n  const [isCreating, setIsCreating] = useState(false);\n  const [draftName, setDraftName] = useState(\"\");\n  const [draftText, setDraftText] = useState(\"\");\n\n  const prompts = getSetting(\"post_process_prompts\") || [];\n  const selectedPromptId = getSetting(\"post_process_selected_prompt_id\") || \"\";\n  const selectedPrompt =\n    prompts.find((prompt) => prompt.id === selectedPromptId) || null;\n\n  useEffect(() => {\n    if (isCreating) return;\n\n    if (selectedPrompt) {\n      setDraftName(selectedPrompt.name);\n      setDraftText(selectedPrompt.prompt);\n    } else {\n      setDraftName(\"\");\n      setDraftText(\"\");\n    }\n  }, [\n    isCreating,\n    selectedPromptId,\n    selectedPrompt?.name,\n    selectedPrompt?.prompt,\n  ]);\n\n  const handlePromptSelect = (promptId: string | null) => {\n    if (!promptId) return;\n    updateSetting(\"post_process_selected_prompt_id\", promptId);\n    setIsCreating(false);\n  };\n\n  const handleCreatePrompt = async () => {\n    if (!draftName.trim() || !draftText.trim()) return;\n\n    try {\n      const result = await commands.addPostProcessPrompt(\n        draftName.trim(),\n        draftText.trim(),\n      );\n      if (result.status === \"ok\") {\n        await refreshSettings();\n        updateSetting(\"post_process_selected_prompt_id\", result.data.id);\n        setIsCreating(false);\n      }\n    } catch (error) {\n      console.error(\"Failed to create prompt:\", error);\n    }\n  };\n\n  const handleUpdatePrompt = async () => {\n    if (!selectedPromptId || !draftName.trim() || !draftText.trim()) return;\n\n    try {\n      await commands.updatePostProcessPrompt(\n        selectedPromptId,\n        draftName.trim(),\n        draftText.trim(),\n      );\n      await refreshSettings();\n    } catch (error) {\n      console.error(\"Failed to update prompt:\", error);\n    }\n  };\n\n  const handleDeletePrompt = async (promptId: string) => {\n    if (!promptId) return;\n\n    try {\n      await commands.deletePostProcessPrompt(promptId);\n      await refreshSettings();\n      setIsCreating(false);\n    } catch (error) {\n      console.error(\"Failed to delete prompt:\", error);\n    }\n  };\n\n  const handleCancelCreate = () => {\n    setIsCreating(false);\n    if (selectedPrompt) {\n      setDraftName(selectedPrompt.name);\n      setDraftText(selectedPrompt.prompt);\n    } else {\n      setDraftName(\"\");\n      setDraftText(\"\");\n    }\n  };\n\n  const handleStartCreate = () => {\n    setIsCreating(true);\n    setDraftName(\"\");\n    setDraftText(\"\");\n  };\n\n  const hasPrompts = prompts.length > 0;\n  const isDirty =\n    !!selectedPrompt &&\n    (draftName.trim() !== selectedPrompt.name ||\n      draftText.trim() !== selectedPrompt.prompt.trim());\n\n  return (\n    <SettingContainer\n      title={t(\"settings.postProcessing.prompts.selectedPrompt.title\")}\n      description={t(\n        \"settings.postProcessing.prompts.selectedPrompt.description\",\n      )}\n      descriptionMode=\"tooltip\"\n      layout=\"stacked\"\n      grouped={true}\n    >\n      <div className=\"space-y-3\">\n        <div className=\"flex gap-2\">\n          <Dropdown\n            selectedValue={selectedPromptId || null}\n            options={prompts.map((p) => ({\n              value: p.id,\n              label: p.name,\n            }))}\n            onSelect={(value) => handlePromptSelect(value)}\n            placeholder={\n              prompts.length === 0\n                ? t(\"settings.postProcessing.prompts.noPrompts\")\n                : t(\"settings.postProcessing.prompts.selectPrompt\")\n            }\n            disabled={\n              isUpdating(\"post_process_selected_prompt_id\") || isCreating\n            }\n            className=\"flex-1\"\n          />\n          <Button\n            onClick={handleStartCreate}\n            variant=\"primary\"\n            size=\"md\"\n            disabled={isCreating}\n          >\n            {t(\"settings.postProcessing.prompts.createNew\")}\n          </Button>\n        </div>\n\n        {!isCreating && hasPrompts && selectedPrompt && (\n          <div className=\"space-y-3\">\n            <div className=\"space-y-2 flex flex-col\">\n              <label className=\"text-sm font-semibold\">\n                {t(\"settings.postProcessing.prompts.promptLabel\")}\n              </label>\n              <Input\n                type=\"text\"\n                value={draftName}\n                onChange={(e) => setDraftName(e.target.value)}\n                placeholder={t(\n                  \"settings.postProcessing.prompts.promptLabelPlaceholder\",\n                )}\n                variant=\"compact\"\n              />\n            </div>\n\n            <div className=\"space-y-2 flex flex-col\">\n              <label className=\"text-sm font-semibold\">\n                {t(\"settings.postProcessing.prompts.promptInstructions\")}\n              </label>\n              <Textarea\n                value={draftText}\n                onChange={(e) => setDraftText(e.target.value)}\n                placeholder={t(\n                  \"settings.postProcessing.prompts.promptInstructionsPlaceholder\",\n                )}\n              />\n              <p className=\"text-xs text-mid-gray/70\">\n                <Trans\n                  i18nKey=\"settings.postProcessing.prompts.promptTip\"\n                  components={{ code: <code /> }}\n                />\n              </p>\n            </div>\n\n            <div className=\"flex gap-2 pt-2\">\n              <Button\n                onClick={handleUpdatePrompt}\n                variant=\"primary\"\n                size=\"md\"\n                disabled={!draftName.trim() || !draftText.trim() || !isDirty}\n              >\n                {t(\"settings.postProcessing.prompts.updatePrompt\")}\n              </Button>\n              <Button\n                onClick={() => handleDeletePrompt(selectedPromptId)}\n                variant=\"secondary\"\n                size=\"md\"\n                disabled={!selectedPromptId || prompts.length <= 1}\n              >\n                {t(\"settings.postProcessing.prompts.deletePrompt\")}\n              </Button>\n            </div>\n          </div>\n        )}\n\n        {!isCreating && !selectedPrompt && (\n          <div className=\"p-3 bg-mid-gray/5 rounded-md border border-mid-gray/20\">\n            <p className=\"text-sm text-mid-gray\">\n              {hasPrompts\n                ? t(\"settings.postProcessing.prompts.selectToEdit\")\n                : t(\"settings.postProcessing.prompts.createFirst\")}\n            </p>\n          </div>\n        )}\n\n        {isCreating && (\n          <div className=\"space-y-3\">\n            <div className=\"space-y-2 block flex flex-col\">\n              <label className=\"text-sm font-semibold text-text\">\n                {t(\"settings.postProcessing.prompts.promptLabel\")}\n              </label>\n              <Input\n                type=\"text\"\n                value={draftName}\n                onChange={(e) => setDraftName(e.target.value)}\n                placeholder={t(\n                  \"settings.postProcessing.prompts.promptLabelPlaceholder\",\n                )}\n                variant=\"compact\"\n              />\n            </div>\n\n            <div className=\"space-y-2 flex flex-col\">\n              <label className=\"text-sm font-semibold\">\n                {t(\"settings.postProcessing.prompts.promptInstructions\")}\n              </label>\n              <Textarea\n                value={draftText}\n                onChange={(e) => setDraftText(e.target.value)}\n                placeholder={t(\n                  \"settings.postProcessing.prompts.promptInstructionsPlaceholder\",\n                )}\n              />\n              <p className=\"text-xs text-mid-gray/70\">\n                <Trans\n                  i18nKey=\"settings.postProcessing.prompts.promptTip\"\n                  components={{ code: <code /> }}\n                />\n              </p>\n            </div>\n\n            <div className=\"flex gap-2 pt-2\">\n              <Button\n                onClick={handleCreatePrompt}\n                variant=\"primary\"\n                size=\"md\"\n                disabled={!draftName.trim() || !draftText.trim()}\n              >\n                {t(\"settings.postProcessing.prompts.createPrompt\")}\n              </Button>\n              <Button\n                onClick={handleCancelCreate}\n                variant=\"secondary\"\n                size=\"md\"\n              >\n                {t(\"settings.postProcessing.prompts.cancel\")}\n              </Button>\n            </div>\n          </div>\n        )}\n      </div>\n    </SettingContainer>\n  );\n};\n\nexport const PostProcessingSettingsApi = React.memo(\n  PostProcessingSettingsApiComponent,\n);\nPostProcessingSettingsApi.displayName = \"PostProcessingSettingsApi\";\n\nexport const PostProcessingSettingsPrompts = React.memo(\n  PostProcessingSettingsPromptsComponent,\n);\nPostProcessingSettingsPrompts.displayName = \"PostProcessingSettingsPrompts\";\n\nexport const PostProcessingSettings: React.FC = () => {\n  const { t } = useTranslation();\n\n  return (\n    <div className=\"max-w-3xl w-full mx-auto space-y-6\">\n      <SettingsGroup title={t(\"settings.postProcessing.hotkey.title\")}>\n        <ShortcutInput\n          shortcutId=\"transcribe_with_post_process\"\n          descriptionMode=\"tooltip\"\n          grouped={true}\n        />\n      </SettingsGroup>\n\n      <SettingsGroup title={t(\"settings.postProcessing.api.title\")}>\n        <PostProcessingSettingsApi />\n      </SettingsGroup>\n\n      <SettingsGroup title={t(\"settings.postProcessing.prompts.title\")}>\n        <PostProcessingSettingsPrompts />\n      </SettingsGroup>\n    </div>\n  );\n};\n"
  },
  {
    "path": "src/components/shared/ProgressBar.tsx",
    "content": "import React from \"react\";\n\nexport interface ProgressData {\n  id: string;\n  percentage: number;\n  speed?: number;\n  label?: string;\n}\n\ninterface ProgressBarProps {\n  progress: ProgressData[];\n  className?: string;\n  size?: \"small\" | \"medium\" | \"large\";\n  showSpeed?: boolean;\n  showLabel?: boolean;\n}\n\nconst ProgressBar: React.FC<ProgressBarProps> = ({\n  progress,\n  className = \"\",\n  size = \"medium\",\n  showSpeed = false,\n  showLabel = false,\n}) => {\n  const sizeClasses = {\n    small: \"w-16 h-1\",\n    medium: \"w-20 h-1.5\",\n    large: \"w-24 h-2\",\n  };\n\n  const progressClasses = sizeClasses[size];\n\n  if (progress.length === 0) {\n    return null;\n  }\n\n  if (progress.length === 1) {\n    // Single progress bar\n    const item = progress[0];\n    const percentage = Math.max(0, Math.min(100, item.percentage));\n\n    return (\n      <div className={`flex items-center gap-3 ${className}`}>\n        <progress\n          value={percentage}\n          max={100}\n          className={`${progressClasses} [&::-webkit-progress-bar]:rounded-full [&::-webkit-progress-bar]:bg-mid-gray/20 [&::-webkit-progress-value]:rounded-full [&::-webkit-progress-value]:bg-logo-primary`}\n        />\n        {(showSpeed || showLabel) && (\n          <div className=\"text-xs text-text/60 tabular-nums min-w-fit\">\n            {showLabel && item.label && (\n              <span className=\"me-2\">{item.label}</span>\n            )}\n            {showSpeed && item.speed !== undefined && item.speed > 0 ? (\n              // eslint-disable-next-line i18next/no-literal-string\n              <span>{item.speed.toFixed(1)}MB/s</span>\n            ) : showSpeed ? (\n              <span>Downloading...</span>\n            ) : null}\n          </div>\n        )}\n      </div>\n    );\n  }\n\n  // Multiple progress bars\n  return (\n    <div className={`flex items-center gap-2 ${className}`}>\n      <div className=\"flex gap-1\">\n        {progress.map((item) => {\n          const percentage = Math.max(0, Math.min(100, item.percentage));\n          return (\n            <progress\n              key={item.id}\n              value={percentage}\n              max={100}\n              title={item.label || `${percentage}%`}\n              className=\"w-3 h-1.5 [&::-webkit-progress-bar]:rounded-full [&::-webkit-progress-bar]:bg-mid-gray/20 [&::-webkit-progress-value]:rounded-full [&::-webkit-progress-value]:bg-logo-primary\"\n            />\n          );\n        })}\n      </div>\n      <div className=\"text-xs text-text/60 min-w-fit\">\n        {progress.length} downloading...\n      </div>\n    </div>\n  );\n};\n\nexport default ProgressBar;\n"
  },
  {
    "path": "src/components/shared/index.ts",
    "content": "export { default as ProgressBar } from \"./ProgressBar\";\nexport type { ProgressData } from \"./ProgressBar\";\n"
  },
  {
    "path": "src/components/ui/Alert.tsx",
    "content": "import React from \"react\";\nimport { AlertCircle, AlertTriangle, Info, CheckCircle } from \"lucide-react\";\n\ntype AlertVariant = \"error\" | \"warning\" | \"info\" | \"success\";\n\ninterface AlertProps {\n  variant?: AlertVariant;\n  /** When true, removes rounded corners for use inside containers */\n  contained?: boolean;\n  children: React.ReactNode;\n  className?: string;\n}\n\nconst variantStyles: Record<\n  AlertVariant,\n  { container: string; icon: string; text: string }\n> = {\n  error: {\n    container: \"bg-red-500/10\",\n    icon: \"text-red-500\",\n    text: \"text-red-400\",\n  },\n  warning: {\n    container: \"bg-yellow-500/10\",\n    icon: \"text-yellow-500\",\n    text: \"text-yellow-400\",\n  },\n  info: {\n    container: \"bg-blue-500/10\",\n    icon: \"text-blue-500\",\n    text: \"text-blue-400\",\n  },\n  success: {\n    container: \"bg-green-500/10\",\n    icon: \"text-green-500\",\n    text: \"text-green-400\",\n  },\n};\n\nconst variantIcons: Record<AlertVariant, React.ElementType> = {\n  error: AlertCircle,\n  warning: AlertTriangle,\n  info: Info,\n  success: CheckCircle,\n};\n\nexport const Alert: React.FC<AlertProps> = ({\n  variant = \"error\",\n  contained = false,\n  children,\n  className = \"\",\n}) => {\n  const styles = variantStyles[variant];\n  const Icon = variantIcons[variant];\n\n  return (\n    <div\n      className={`flex items-start gap-3 p-4 ${styles.container} ${contained ? \"\" : \"rounded-lg\"} ${className}`}\n    >\n      <Icon className={`w-5 h-5 shrink-0 mt-0.5 ${styles.icon}`} />\n      <p className={`text-sm ${styles.text}`}>{children}</p>\n    </div>\n  );\n};\n"
  },
  {
    "path": "src/components/ui/AudioPlayer.tsx",
    "content": "import React, { useState, useRef, useEffect, useCallback } from \"react\";\nimport { Play, Pause } from \"lucide-react\";\n\ninterface AudioPlayerProps {\n  /** Audio source URL. If not provided, onLoadRequest must be provided. */\n  src?: string;\n  /** Called when play is clicked and no src is loaded yet. Should return the audio URL. */\n  onLoadRequest?: () => Promise<string | null>;\n  className?: string;\n  autoPlay?: boolean;\n}\n\nexport const AudioPlayer: React.FC<AudioPlayerProps> = ({\n  src: initialSrc,\n  onLoadRequest,\n  className = \"\",\n  autoPlay = false,\n}) => {\n  const [isPlaying, setIsPlaying] = useState(false);\n  const [duration, setDuration] = useState(0);\n  const [currentTime, setCurrentTime] = useState(0);\n  const [isDragging, setIsDragging] = useState(false);\n  const [loadedSrc, setLoadedSrc] = useState<string | null>(initialSrc ?? null);\n  const [isLoading, setIsLoading] = useState(false);\n\n  const audioRef = useRef<HTMLAudioElement>(null);\n  const src = loadedSrc;\n  const animationRef = useRef<number>();\n  const dragTimeRef = useRef<number>(0);\n\n  // Use refs to avoid stale closures in animation loop\n  const isPlayingRef = useRef(false);\n  const isDraggingRef = useRef(false);\n\n  // Keep refs in sync with state\n  useEffect(() => {\n    isPlayingRef.current = isPlaying;\n  }, [isPlaying]);\n\n  useEffect(() => {\n    isDraggingRef.current = isDragging;\n  }, [isDragging]);\n\n  // Stable animation loop with no dependencies\n  const tick = useCallback(() => {\n    if (audioRef.current && !isDraggingRef.current) {\n      const time = audioRef.current.currentTime;\n      setCurrentTime(time);\n    }\n\n    if (isPlayingRef.current) {\n      animationRef.current = requestAnimationFrame(tick);\n    }\n  }, []); // Empty dependency array is key!\n\n  // Manage animation loop lifecycle\n  useEffect(() => {\n    if (isPlaying && !isDragging) {\n      // Only start if not already running\n      if (!animationRef.current) {\n        animationRef.current = requestAnimationFrame(tick);\n      }\n    } else {\n      // Stop animation loop\n      if (animationRef.current) {\n        cancelAnimationFrame(animationRef.current);\n        animationRef.current = undefined;\n      }\n    }\n\n    return () => {\n      if (animationRef.current) {\n        cancelAnimationFrame(animationRef.current);\n        animationRef.current = undefined;\n      }\n    };\n  }, [isPlaying, isDragging, tick]);\n\n  // Audio event handlers\n  useEffect(() => {\n    const audio = audioRef.current;\n    if (!audio) return;\n\n    const handleLoadedMetadata = () => {\n      setDuration(audio.duration || 0);\n      setCurrentTime(0);\n    };\n\n    const handleEnded = () => {\n      setIsPlaying(false);\n      setCurrentTime(audio.duration || 0);\n    };\n\n    const handlePlay = () => setIsPlaying(true);\n    const handlePause = () => setIsPlaying(false);\n\n    audio.addEventListener(\"loadedmetadata\", handleLoadedMetadata);\n    audio.addEventListener(\"ended\", handleEnded);\n    audio.addEventListener(\"play\", handlePlay);\n    audio.addEventListener(\"pause\", handlePause);\n\n    return () => {\n      audio.removeEventListener(\"loadedmetadata\", handleLoadedMetadata);\n      audio.removeEventListener(\"ended\", handleEnded);\n      audio.removeEventListener(\"play\", handlePlay);\n      audio.removeEventListener(\"pause\", handlePause);\n    };\n  }, []);\n\n  // Auto-play when src becomes available (via onLoadRequest or autoPlay prop)\n  const prevLoadedSrc = useRef<string | null>(null);\n  useEffect(() => {\n    const audio = audioRef.current;\n    if (!audio) return;\n\n    // Play when loadedSrc changes from null to a value (lazy load case)\n    if (loadedSrc && !prevLoadedSrc.current && onLoadRequest) {\n      audio.play().catch((error) => {\n        console.error(\"Auto-play failed:\", error);\n      });\n    }\n    // Or when autoPlay is set with initial src\n    else if (autoPlay && initialSrc && !prevLoadedSrc.current) {\n      audio.play().catch((error) => {\n        console.error(\"Auto-play failed:\", error);\n      });\n    }\n\n    prevLoadedSrc.current = loadedSrc;\n  }, [loadedSrc, autoPlay, initialSrc, onLoadRequest]);\n\n  // Global drag handlers\n  const handleMouseUp = useCallback(() => {\n    if (isDragging) {\n      setIsDragging(false);\n      if (audioRef.current) {\n        audioRef.current.currentTime = dragTimeRef.current;\n        setCurrentTime(dragTimeRef.current);\n      }\n    }\n  }, [isDragging]);\n\n  useEffect(() => {\n    if (isDragging) {\n      document.addEventListener(\"mouseup\", handleMouseUp);\n      document.addEventListener(\"touchend\", handleMouseUp);\n\n      return () => {\n        document.removeEventListener(\"mouseup\", handleMouseUp);\n        document.removeEventListener(\"touchend\", handleMouseUp);\n      };\n    }\n  }, [isDragging, handleMouseUp]);\n\n  // Cleanup blob URLs on unmount\n  useEffect(() => {\n    return () => {\n      if (loadedSrc?.startsWith(\"blob:\")) {\n        URL.revokeObjectURL(loadedSrc);\n      }\n    };\n  }, [loadedSrc]);\n\n  const togglePlay = async () => {\n    const audio = audioRef.current;\n    if (!audio) return;\n    if (isLoading) return;\n\n    try {\n      if (isPlaying) {\n        audio.pause();\n      } else {\n        // If no src loaded yet, request it\n        if (!src && onLoadRequest) {\n          setIsLoading(true);\n          const newSrc = await onLoadRequest();\n          setIsLoading(false);\n          if (newSrc) {\n            setLoadedSrc(newSrc);\n            // Playback will be triggered by the useEffect watching loadedSrc\n          }\n        } else if (src) {\n          await audio.play();\n        }\n      }\n    } catch (error) {\n      console.error(\"Playback failed:\", error);\n    }\n  };\n\n  const handleSeek = (e: React.ChangeEvent<HTMLInputElement>) => {\n    const newTime = parseFloat(e.target.value);\n    dragTimeRef.current = newTime;\n    setCurrentTime(newTime);\n\n    if (!isDragging && audioRef.current) {\n      audioRef.current.currentTime = newTime;\n    }\n  };\n\n  const handleSliderMouseDown = () => {\n    setIsDragging(true);\n  };\n\n  const handleSliderTouchStart = () => {\n    setIsDragging(true);\n  };\n\n  const formatTime = (time: number): string => {\n    if (!isFinite(time)) return \"0:00\";\n\n    const minutes = Math.floor(time / 60);\n    const seconds = Math.floor(time % 60);\n    return `${minutes}:${seconds.toString().padStart(2, \"0\")}`;\n  };\n\n  // Fix playhead positioning with better edge case handling\n  const getProgressPercent = (): number => {\n    if (duration <= 0) return 0;\n\n    // Handle the end case - if we're within 0.1 seconds of the end, show 100%\n    if (duration - currentTime < 0.1) return 100;\n\n    const percent = (currentTime / duration) * 100;\n    return Math.min(100, Math.max(0, percent));\n  };\n\n  const progressPercent = getProgressPercent();\n\n  return (\n    <div className={`flex items-center gap-3 ${className}`}>\n      <audio ref={audioRef} src={src ?? undefined} preload=\"metadata\" />\n\n      <button\n        onClick={togglePlay}\n        disabled={isLoading}\n        className=\"transition-colors cursor-pointer text-text hover:text-logo-primary disabled:opacity-50\"\n        aria-label={isPlaying ? \"Pause\" : \"Play\"}\n      >\n        {isPlaying ? (\n          <Pause width={20} height={20} fill=\"currentColor\" />\n        ) : (\n          <Play width={20} height={20} fill=\"currentColor\" />\n        )}\n      </button>\n\n      <div className=\"flex-1 flex items-center gap-2\">\n        <span className=\"text-xs text-text/60 min-w-[30px] tabular-nums\">\n          {formatTime(currentTime)}\n        </span>\n\n        <input\n          type=\"range\"\n          min=\"0\"\n          max={duration || 0}\n          step=\"0.01\"\n          value={currentTime}\n          onChange={handleSeek}\n          onMouseDown={handleSliderMouseDown}\n          onTouchStart={handleSliderTouchStart}\n          className={`flex-1 h-1 rounded-lg appearance-none cursor-pointer focus:outline-none focus:ring-1 focus:ring-logo-primary ${progressPercent >= 99.5 ? \"[&::-webkit-slider-thumb]:translate-x-0.5 [&::-moz-range-thumb]:translate-x-0.5\" : \"\"}`}\n          style={{\n            background: `linear-gradient(to right, #FAA2CA 0%, #FAA2CA ${progressPercent}%, rgba(128, 128, 128, 0.2) ${progressPercent}%, rgba(128, 128, 128, 0.2) 100%)`,\n          }}\n        />\n\n        <span className=\"text-xs text-text/60 min-w-[30px] tabular-nums\">\n          {formatTime(duration)}\n        </span>\n      </div>\n    </div>\n  );\n};\n"
  },
  {
    "path": "src/components/ui/Badge.tsx",
    "content": "import React from \"react\";\n\ninterface BadgeProps {\n  children: React.ReactNode;\n  variant?: \"primary\" | \"success\" | \"secondary\";\n  className?: string;\n}\n\nconst Badge: React.FC<BadgeProps> = ({\n  children,\n  variant = \"primary\",\n  className = \"\",\n}) => {\n  const variantClasses = {\n    primary: \"bg-logo-primary\",\n    success: \"bg-green-500/20 text-green-400\",\n    secondary: \"bg-mid-gray/20 text-text/70\",\n  };\n\n  return (\n    <span\n      className={`inline-flex items-center px-3 py-1 rounded-full text-xs font-medium ${variantClasses[variant]} ${className}`}\n    >\n      {children}\n    </span>\n  );\n};\n\nexport default Badge;\n"
  },
  {
    "path": "src/components/ui/Button.tsx",
    "content": "import React from \"react\";\n\ninterface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {\n  variant?:\n    | \"primary\"\n    | \"primary-soft\"\n    | \"secondary\"\n    | \"danger\"\n    | \"danger-ghost\"\n    | \"ghost\";\n  size?: \"sm\" | \"md\" | \"lg\";\n}\n\nexport const Button: React.FC<ButtonProps> = ({\n  children,\n  className = \"\",\n  variant = \"primary\",\n  size = \"md\",\n  ...props\n}) => {\n  const baseClasses =\n    \"font-medium rounded-lg border focus:outline-none transition-colors disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer\";\n\n  const variantClasses = {\n    primary:\n      \"text-white bg-background-ui border-background-ui hover:bg-background-ui/80 hover:border-background-ui/80 focus:ring-1 focus:ring-background-ui\",\n    \"primary-soft\":\n      \"text-text bg-logo-primary/20 border-transparent hover:bg-logo-primary/30 focus:ring-1 focus:ring-logo-primary\",\n    secondary:\n      \"bg-mid-gray/10 border-mid-gray/20 hover:bg-background-ui/30 hover:border-logo-primary focus:outline-none\",\n    danger:\n      \"text-white bg-red-600 border-mid-gray/20 hover:bg-red-700 hover:border-red-700 focus:ring-1 focus:ring-red-500\",\n    \"danger-ghost\":\n      \"text-red-400 border-transparent hover:text-red-300 hover:bg-red-500/10 focus:bg-red-500/20\",\n    ghost:\n      \"text-current border-transparent hover:bg-mid-gray/10 hover:border-logo-primary focus:bg-mid-gray/20\",\n  };\n\n  const sizeClasses = {\n    sm: \"px-2 py-1 text-xs\",\n    md: \"px-4 py-[5px] text-sm\",\n    lg: \"px-4 py-2 text-base\",\n  };\n\n  return (\n    <button\n      className={`${baseClasses} ${variantClasses[variant]} ${sizeClasses[size]} ${className}`}\n      {...props}\n    >\n      {children}\n    </button>\n  );\n};\n"
  },
  {
    "path": "src/components/ui/Dropdown.tsx",
    "content": "import React, { useEffect, useRef, useState } from \"react\";\nimport { useTranslation } from \"react-i18next\";\n\nexport interface DropdownOption {\n  value: string;\n  label: string;\n  disabled?: boolean;\n}\n\ninterface DropdownProps {\n  options: DropdownOption[];\n  className?: string;\n  selectedValue: string | null;\n  onSelect: (value: string) => void;\n  placeholder?: string;\n  disabled?: boolean;\n  onRefresh?: () => void;\n}\n\nexport const Dropdown: React.FC<DropdownProps> = ({\n  options,\n  selectedValue,\n  onSelect,\n  className = \"\",\n  placeholder = \"Select an option...\",\n  disabled = false,\n  onRefresh,\n}) => {\n  const { t } = useTranslation();\n  const [isOpen, setIsOpen] = useState(false);\n  const dropdownRef = useRef<HTMLDivElement>(null);\n\n  useEffect(() => {\n    const handleClickOutside = (event: MouseEvent) => {\n      if (\n        dropdownRef.current &&\n        !dropdownRef.current.contains(event.target as Node)\n      ) {\n        setIsOpen(false);\n      }\n    };\n    document.addEventListener(\"mousedown\", handleClickOutside);\n    return () => document.removeEventListener(\"mousedown\", handleClickOutside);\n  }, []);\n\n  const selectedOption = options.find(\n    (option) => option.value === selectedValue,\n  );\n\n  const handleSelect = (value: string) => {\n    onSelect(value);\n    setIsOpen(false);\n  };\n\n  const handleToggle = () => {\n    if (disabled) return;\n    if (!isOpen && onRefresh) onRefresh();\n    setIsOpen(!isOpen);\n  };\n\n  return (\n    <div className={`relative ${className}`} ref={dropdownRef}>\n      <button\n        type=\"button\"\n        className={`px-2 py-1 text-sm font-semibold bg-mid-gray/10 border border-mid-gray/80 rounded-md min-w-[200px] text-start flex items-center justify-between transition-all duration-150 ${\n          disabled\n            ? \"opacity-50 cursor-not-allowed\"\n            : \"hover:bg-logo-primary/10 cursor-pointer hover:border-logo-primary\"\n        }`}\n        onClick={handleToggle}\n        disabled={disabled}\n      >\n        <span className=\"truncate\">{selectedOption?.label || placeholder}</span>\n        <svg\n          className={`w-4 h-4 ms-2 transition-transform duration-200 ${isOpen ? \"transform rotate-180\" : \"\"}`}\n          fill=\"none\"\n          stroke=\"currentColor\"\n          viewBox=\"0 0 24 24\"\n        >\n          <path\n            strokeLinecap=\"round\"\n            strokeLinejoin=\"round\"\n            strokeWidth={2}\n            d=\"M19 9l-7 7-7-7\"\n          />\n        </svg>\n      </button>\n      {isOpen && !disabled && (\n        <div className=\"absolute top-full left-0 right-0 mt-1 bg-background border border-mid-gray/80 rounded-md shadow-lg z-50 max-h-60 overflow-y-auto\">\n          {options.length === 0 ? (\n            <div className=\"px-2 py-1 text-sm text-mid-gray\">\n              {t(\"common.noOptionsFound\")}\n            </div>\n          ) : (\n            options.map((option) => (\n              <button\n                key={option.value}\n                type=\"button\"\n                className={`w-full px-2 py-1 text-sm text-start hover:bg-logo-primary/10 transition-colors duration-150 ${\n                  selectedValue === option.value\n                    ? \"bg-logo-primary/20 font-semibold\"\n                    : \"\"\n                } ${option.disabled ? \"opacity-50 cursor-not-allowed\" : \"\"}`}\n                onClick={() => handleSelect(option.value)}\n                disabled={option.disabled}\n              >\n                <span className=\"truncate\">{option.label}</span>\n              </button>\n            ))\n          )}\n        </div>\n      )}\n    </div>\n  );\n};\n"
  },
  {
    "path": "src/components/ui/Input.tsx",
    "content": "import React from \"react\";\n\ninterface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {\n  variant?: \"default\" | \"compact\";\n}\n\nexport const Input: React.FC<InputProps> = ({\n  className = \"\",\n  variant = \"default\",\n  disabled,\n  ...props\n}) => {\n  const baseClasses =\n    \"px-2 py-1 text-sm font-semibold bg-mid-gray/10 border border-mid-gray/80 rounded-md text-start transition-all duration-150\";\n\n  const interactiveClasses = disabled\n    ? \"opacity-60 cursor-not-allowed bg-mid-gray/10 border-mid-gray/40\"\n    : \"hover:bg-logo-primary/10 hover:border-logo-primary focus:outline-none focus:bg-logo-primary/20 focus:border-logo-primary\";\n\n  const variantClasses = {\n    default: \"px-3 py-2\",\n    compact: \"px-2 py-1\",\n  } as const;\n\n  return (\n    <input\n      className={`${baseClasses} ${variantClasses[variant]} ${interactiveClasses} ${className}`}\n      disabled={disabled}\n      {...props}\n    />\n  );\n};\n"
  },
  {
    "path": "src/components/ui/PathDisplay.tsx",
    "content": "import React from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { Button } from \"./Button\";\n\ninterface PathDisplayProps {\n  path: string;\n  onOpen: () => void;\n  disabled?: boolean;\n}\n\nexport const PathDisplay: React.FC<PathDisplayProps> = ({\n  path,\n  onOpen,\n  disabled = false,\n}) => {\n  const { t } = useTranslation();\n\n  return (\n    <div className=\"flex items-center gap-2\">\n      <div className=\"flex-1 min-w-0 px-2 py-2 bg-mid-gray/10 border border-mid-gray/80 rounded-lg text-xs font-mono break-all select-text cursor-text\">\n        {path}\n      </div>\n      <Button\n        onClick={onOpen}\n        variant=\"secondary\"\n        size=\"sm\"\n        disabled={disabled}\n        className=\"px-3 py-2\"\n      >\n        {t(\"common.open\")}\n      </Button>\n    </div>\n  );\n};\n"
  },
  {
    "path": "src/components/ui/ResetButton.tsx",
    "content": "import React from \"react\";\nimport ResetIcon from \"../icons/ResetIcon\";\n\ninterface ResetButtonProps {\n  onClick: () => void;\n  disabled?: boolean;\n  className?: string;\n  ariaLabel?: string;\n  children?: React.ReactNode;\n}\n\nexport const ResetButton: React.FC<ResetButtonProps> = React.memo(\n  ({ onClick, disabled = false, className = \"\", ariaLabel, children }) => (\n    <button\n      type=\"button\"\n      aria-label={ariaLabel}\n      className={`p-1 rounded-md border border-transparent transition-all duration-150 ${\n        disabled\n          ? \"opacity-50 cursor-not-allowed text-text/40\"\n          : \"hover:bg-logo-primary/30 active:bg-logo-primary/50 active:translate-y-[1px] hover:cursor-pointer hover:border-logo-primary text-text/80\"\n      } ${className}`}\n      onClick={onClick}\n      disabled={disabled}\n    >\n      {children ?? <ResetIcon />}\n    </button>\n  ),\n);\n"
  },
  {
    "path": "src/components/ui/Select.tsx",
    "content": "import React from \"react\";\nimport SelectComponent from \"react-select\";\nimport CreatableSelect from \"react-select/creatable\";\nimport type {\n  ActionMeta,\n  Props as ReactSelectProps,\n  SingleValue,\n  StylesConfig,\n} from \"react-select\";\n\nexport type SelectOption = {\n  value: string;\n  label: string;\n  isDisabled?: boolean;\n};\n\ntype BaseProps = {\n  value: string | null;\n  options: SelectOption[];\n  placeholder?: string;\n  disabled?: boolean;\n  isLoading?: boolean;\n  isClearable?: boolean;\n  onChange: (value: string | null, action: ActionMeta<SelectOption>) => void;\n  onBlur?: () => void;\n  className?: string;\n  formatCreateLabel?: (input: string) => string;\n};\n\ntype CreatableProps = {\n  isCreatable: true;\n  onCreateOption: (value: string) => void;\n};\n\ntype NonCreatableProps = {\n  isCreatable?: false;\n  onCreateOption?: never;\n};\n\nexport type SelectProps = BaseProps & (CreatableProps | NonCreatableProps);\n\nconst baseBackground =\n  \"color-mix(in srgb, var(--color-mid-gray) 10%, transparent)\";\nconst hoverBackground =\n  \"color-mix(in srgb, var(--color-logo-primary) 12%, transparent)\";\nconst focusBackground =\n  \"color-mix(in srgb, var(--color-logo-primary) 20%, transparent)\";\nconst neutralBorder =\n  \"color-mix(in srgb, var(--color-mid-gray) 80%, transparent)\";\n\nconst selectStyles: StylesConfig<SelectOption, false> = {\n  control: (base, state) => ({\n    ...base,\n    minHeight: 40,\n    borderRadius: 6,\n    borderColor: state.isFocused ? \"var(--color-logo-primary)\" : neutralBorder,\n    boxShadow: state.isFocused ? \"0 0 0 1px var(--color-logo-primary)\" : \"none\",\n    backgroundColor: state.isFocused ? focusBackground : baseBackground,\n    fontSize: \"0.875rem\",\n    color: \"var(--color-text)\",\n    transition: \"all 150ms ease\",\n    \":hover\": {\n      borderColor: \"var(--color-logo-primary)\",\n      backgroundColor: hoverBackground,\n    },\n  }),\n  valueContainer: (base) => ({\n    ...base,\n    paddingInline: 10,\n    paddingBlock: 6,\n  }),\n  input: (base) => ({\n    ...base,\n    color: \"var(--color-text)\",\n  }),\n  singleValue: (base) => ({\n    ...base,\n    color: \"var(--color-text)\",\n  }),\n  dropdownIndicator: (base, state) => ({\n    ...base,\n    color: state.isFocused\n      ? \"var(--color-logo-primary)\"\n      : \"color-mix(in srgb, var(--color-mid-gray) 80%, transparent)\",\n    \":hover\": {\n      color: \"var(--color-logo-primary)\",\n    },\n  }),\n  clearIndicator: (base) => ({\n    ...base,\n    color: \"color-mix(in srgb, var(--color-mid-gray) 80%, transparent)\",\n    \":hover\": {\n      color: \"var(--color-logo-primary)\",\n    },\n  }),\n  menu: (provided) => ({\n    ...provided,\n    zIndex: 30,\n    backgroundColor: \"var(--color-background)\",\n    color: \"var(--color-text)\",\n    border:\n      \"1px solid color-mix(in srgb, var(--color-mid-gray) 30%, transparent)\",\n    boxShadow: \"0 10px 30px rgba(15, 15, 15, 0.2)\",\n  }),\n  option: (base, state) => ({\n    ...base,\n    backgroundColor: state.isSelected\n      ? focusBackground\n      : state.isFocused\n        ? hoverBackground\n        : \"transparent\",\n    color: \"var(--color-text)\",\n    cursor: state.isDisabled ? \"not-allowed\" : base.cursor,\n    opacity: state.isDisabled ? 0.5 : 1,\n  }),\n  placeholder: (base) => ({\n    ...base,\n    color: \"color-mix(in srgb, var(--color-mid-gray) 65%, transparent)\",\n  }),\n};\n\nexport const Select: React.FC<SelectProps> = React.memo(\n  ({\n    value,\n    options,\n    placeholder,\n    disabled,\n    isLoading,\n    isClearable = true,\n    onChange,\n    onBlur,\n    className = \"\",\n    isCreatable,\n    formatCreateLabel,\n    onCreateOption,\n  }) => {\n    const selectValue = React.useMemo(() => {\n      if (!value) return null;\n      const existing = options.find((option) => option.value === value);\n      if (existing) return existing;\n      return { value, label: value, isDisabled: false };\n    }, [value, options]);\n\n    const handleChange = (\n      option: SingleValue<SelectOption>,\n      action: ActionMeta<SelectOption>,\n    ) => {\n      onChange(option?.value ?? null, action);\n    };\n\n    const sharedProps: Partial<ReactSelectProps<SelectOption, false>> = {\n      className,\n      classNamePrefix: \"app-select\",\n      value: selectValue,\n      options,\n      onChange: handleChange,\n      placeholder,\n      isDisabled: disabled,\n      isLoading,\n      onBlur,\n      isClearable,\n      styles: selectStyles,\n    };\n\n    if (isCreatable) {\n      return (\n        <CreatableSelect<SelectOption, false>\n          {...sharedProps}\n          onCreateOption={onCreateOption}\n          formatCreateLabel={formatCreateLabel}\n        />\n      );\n    }\n\n    return <SelectComponent<SelectOption, false> {...sharedProps} />;\n  },\n);\n\nSelect.displayName = \"Select\";\n"
  },
  {
    "path": "src/components/ui/SettingContainer.tsx",
    "content": "import React, { useEffect, useRef, useState } from \"react\";\nimport { Tooltip } from \"./Tooltip\";\n\ninterface SettingContainerProps {\n  title: string;\n  description: string;\n  children: React.ReactNode;\n  descriptionMode?: \"inline\" | \"tooltip\";\n  grouped?: boolean;\n  layout?: \"horizontal\" | \"stacked\";\n  disabled?: boolean;\n  tooltipPosition?: \"top\" | \"bottom\";\n}\n\nexport const SettingContainer: React.FC<SettingContainerProps> = ({\n  title,\n  description,\n  children,\n  descriptionMode = \"tooltip\",\n  grouped = false,\n  layout = \"horizontal\",\n  disabled = false,\n  tooltipPosition = \"top\",\n}) => {\n  const [showTooltip, setShowTooltip] = useState(false);\n  const tooltipRef = useRef<HTMLDivElement>(null);\n\n  // Handle click outside to close tooltip\n  useEffect(() => {\n    const handleClickOutside = (event: MouseEvent) => {\n      if (\n        tooltipRef.current &&\n        !tooltipRef.current.contains(event.target as Node)\n      ) {\n        setShowTooltip(false);\n      }\n    };\n\n    if (showTooltip) {\n      document.addEventListener(\"mousedown\", handleClickOutside);\n      return () =>\n        document.removeEventListener(\"mousedown\", handleClickOutside);\n    }\n  }, [showTooltip]);\n\n  const toggleTooltip = () => {\n    setShowTooltip(!showTooltip);\n  };\n\n  const containerClasses = grouped\n    ? \"px-4 p-2\"\n    : \"px-4 p-2 rounded-lg border border-mid-gray/20\";\n\n  if (layout === \"stacked\") {\n    if (descriptionMode === \"tooltip\") {\n      return (\n        <div className={containerClasses}>\n          <div className=\"flex items-center gap-2 mb-2\">\n            <h3\n              className={`text-sm font-medium ${disabled ? \"opacity-50\" : \"\"}`}\n            >\n              {title}\n            </h3>\n            <div\n              ref={tooltipRef}\n              className=\"relative\"\n              onMouseEnter={() => setShowTooltip(true)}\n              onMouseLeave={() => setShowTooltip(false)}\n              onClick={toggleTooltip}\n            >\n              <svg\n                className=\"w-4 h-4 text-mid-gray cursor-help hover:text-logo-primary transition-colors duration-200 select-none\"\n                fill=\"none\"\n                stroke=\"currentColor\"\n                viewBox=\"0 0 24 24\"\n                aria-label=\"More information\"\n                role=\"button\"\n                tabIndex={0}\n                onKeyDown={(e) => {\n                  if (e.key === \"Enter\" || e.key === \" \") {\n                    e.preventDefault();\n                    toggleTooltip();\n                  }\n                }}\n              >\n                <path\n                  strokeLinecap=\"round\"\n                  strokeLinejoin=\"round\"\n                  strokeWidth={2}\n                  d=\"M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z\"\n                />\n              </svg>\n              {showTooltip && (\n                <Tooltip targetRef={tooltipRef} position=\"top\">\n                  <p className=\"text-sm text-center leading-relaxed\">\n                    {description}\n                  </p>\n                </Tooltip>\n              )}\n            </div>\n          </div>\n          <div className=\"w-full\">{children}</div>\n        </div>\n      );\n    }\n\n    return (\n      <div className={containerClasses}>\n        <div className=\"mb-2\">\n          <h3 className={`text-sm font-medium ${disabled ? \"opacity-50\" : \"\"}`}>\n            {title}\n          </h3>\n          <p className={`text-sm ${disabled ? \"opacity-50\" : \"\"}`}>\n            {description}\n          </p>\n        </div>\n        <div className=\"w-full\">{children}</div>\n      </div>\n    );\n  }\n\n  // Horizontal layout (default)\n  const horizontalContainerClasses = grouped\n    ? \"flex items-center justify-between px-4 p-2\"\n    : \"flex items-center justify-between px-4 p-2 rounded-lg border border-mid-gray/20\";\n\n  if (descriptionMode === \"tooltip\") {\n    return (\n      <div className={horizontalContainerClasses}>\n        <div className=\"max-w-2/3\">\n          <div className=\"flex items-center gap-2\">\n            <h3\n              className={`text-sm font-medium ${disabled ? \"opacity-50\" : \"\"}`}\n            >\n              {title}\n            </h3>\n            <div\n              ref={tooltipRef}\n              className=\"relative\"\n              onMouseEnter={() => setShowTooltip(true)}\n              onMouseLeave={() => setShowTooltip(false)}\n              onClick={toggleTooltip}\n            >\n              <svg\n                className=\"w-4 h-4 text-mid-gray cursor-help hover:text-logo-primary transition-colors duration-200 select-none\"\n                fill=\"none\"\n                stroke=\"currentColor\"\n                viewBox=\"0 0 24 24\"\n                aria-label=\"More information\"\n                role=\"button\"\n                tabIndex={0}\n                onKeyDown={(e) => {\n                  if (e.key === \"Enter\" || e.key === \" \") {\n                    e.preventDefault();\n                    toggleTooltip();\n                  }\n                }}\n              >\n                <path\n                  strokeLinecap=\"round\"\n                  strokeLinejoin=\"round\"\n                  strokeWidth={2}\n                  d=\"M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z\"\n                />\n              </svg>\n              {showTooltip && (\n                <Tooltip targetRef={tooltipRef} position={tooltipPosition}>\n                  <p className=\"text-sm text-center leading-relaxed\">\n                    {description}\n                  </p>\n                </Tooltip>\n              )}\n            </div>\n          </div>\n        </div>\n        <div className=\"relative\">{children}</div>\n      </div>\n    );\n  }\n\n  return (\n    <div className={horizontalContainerClasses}>\n      <div className=\"max-w-2/3\">\n        <h3 className={`text-sm font-medium ${disabled ? \"opacity-50\" : \"\"}`}>\n          {title}\n        </h3>\n        <p className={`text-sm ${disabled ? \"opacity-50\" : \"\"}`}>\n          {description}\n        </p>\n      </div>\n      <div className=\"relative\">{children}</div>\n    </div>\n  );\n};\n"
  },
  {
    "path": "src/components/ui/SettingsGroup.tsx",
    "content": "import React from \"react\";\n\ninterface SettingsGroupProps {\n  title?: string;\n  description?: string;\n  children: React.ReactNode;\n}\n\nexport const SettingsGroup: React.FC<SettingsGroupProps> = ({\n  title,\n  description,\n  children,\n}) => {\n  return (\n    <div className=\"space-y-2\">\n      {title && (\n        <div className=\"px-4\">\n          <h2 className=\"text-xs font-medium text-mid-gray uppercase tracking-wide\">\n            {title}\n          </h2>\n          {description && (\n            <p className=\"text-xs text-mid-gray mt-1\">{description}</p>\n          )}\n        </div>\n      )}\n      <div className=\"bg-background border border-mid-gray/20 rounded-lg overflow-visible\">\n        <div className=\"divide-y divide-mid-gray/20\">{children}</div>\n      </div>\n    </div>\n  );\n};\n"
  },
  {
    "path": "src/components/ui/Slider.tsx",
    "content": "import React from \"react\";\nimport { SettingContainer } from \"./SettingContainer\";\n\ninterface SliderProps {\n  value: number;\n  onChange: (value: number) => void;\n  min: number;\n  max: number;\n  step?: number;\n  disabled?: boolean;\n  label: string;\n  description: string;\n  descriptionMode?: \"inline\" | \"tooltip\";\n  grouped?: boolean;\n  showValue?: boolean;\n  formatValue?: (value: number) => string;\n}\n\nexport const Slider: React.FC<SliderProps> = ({\n  value,\n  onChange,\n  min,\n  max,\n  step = 0.01,\n  disabled = false,\n  label,\n  description,\n  descriptionMode = \"tooltip\",\n  grouped = false,\n  showValue = true,\n  formatValue = (v) => v.toFixed(2),\n}) => {\n  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {\n    onChange(parseFloat(e.target.value));\n  };\n\n  return (\n    <SettingContainer\n      title={label}\n      description={description}\n      descriptionMode={descriptionMode}\n      grouped={grouped}\n      layout=\"horizontal\"\n      disabled={disabled}\n    >\n      <div className=\"w-full\">\n        <div className=\"flex items-center space-x-1 h-6\">\n          <input\n            type=\"range\"\n            min={min}\n            max={max}\n            step={step}\n            value={value}\n            onChange={handleChange}\n            disabled={disabled}\n            className=\"flex-grow h-2 rounded-lg appearance-none cursor-pointer focus:outline-none focus:ring-2 focus:ring-logo-primary disabled:opacity-50 disabled:cursor-not-allowed\"\n            style={{\n              background: `linear-gradient(to right, var(--color-background-ui) ${\n                ((value - min) / (max - min)) * 100\n              }%, rgba(128, 128, 128, 0.2) ${\n                ((value - min) / (max - min)) * 100\n              }%)`,\n            }}\n          />\n          {showValue && (\n            <span className=\"text-sm font-medium text-text/90 w-12 text-end\">\n              {formatValue(value)}\n            </span>\n          )}\n        </div>\n      </div>\n    </SettingContainer>\n  );\n};\n"
  },
  {
    "path": "src/components/ui/TextDisplay.tsx",
    "content": "import React, { useState } from \"react\";\nimport { SettingContainer } from \"./SettingContainer\";\n\ninterface TextDisplayProps {\n  label: string;\n  description: string;\n  value: string;\n  descriptionMode?: \"inline\" | \"tooltip\";\n  grouped?: boolean;\n  placeholder?: string;\n  copyable?: boolean;\n  monospace?: boolean;\n  onCopy?: (value: string) => void;\n}\n\nexport const TextDisplay: React.FC<TextDisplayProps> = ({\n  label,\n  description,\n  value,\n  descriptionMode = \"tooltip\",\n  grouped = false,\n  placeholder = \"Not available\",\n  copyable = false,\n  monospace = false,\n  onCopy,\n}) => {\n  const [showCopied, setShowCopied] = useState(false);\n\n  const handleCopy = async () => {\n    if (!value || !copyable) return;\n\n    try {\n      await navigator.clipboard.writeText(value);\n      setShowCopied(true);\n      setTimeout(() => setShowCopied(false), 1500);\n      if (onCopy) {\n        onCopy(value);\n      }\n    } catch (err) {\n      console.error(\"Failed to copy to clipboard:\", err);\n    }\n  };\n\n  const displayValue = value || placeholder;\n  const textClasses = monospace ? \"font-mono break-all\" : \"break-words\";\n\n  return (\n    <SettingContainer\n      title={label}\n      description={description}\n      descriptionMode={descriptionMode}\n      grouped={grouped}\n      layout=\"stacked\"\n    >\n      <div className=\"flex items-center space-x-2\">\n        <div className=\"flex-1 min-w-0\">\n          <div\n            className={`px-2 min-h-8 flex items-center bg-mid-gray/10 border border-mid-gray/80 rounded-md text-xs ${textClasses} ${!value ? \"opacity-60\" : \"\"}`}\n          >\n            {displayValue}\n          </div>\n        </div>\n        {copyable && value && (\n          <button\n            onClick={handleCopy}\n            className=\"flex items-center justify-center px-2 py-1 w-12 min-h-8 text-xs font-semibold bg-mid-gray/10 hover:bg-logo-primary/10 border border-mid-gray/80 hover:border-logo-primary hover:text-logo-primary rounded-md transition-all duration-150 flex-shrink-0 cursor-pointer\"\n            title=\"Copy to clipboard\"\n          >\n            {showCopied ? (\n              <div className=\"flex items-center space-x-1\">\n                <svg\n                  className=\"w-4 h-4\"\n                  fill=\"none\"\n                  stroke=\"currentColor\"\n                  viewBox=\"0 0 24 24\"\n                >\n                  <path\n                    strokeLinecap=\"round\"\n                    strokeLinejoin=\"round\"\n                    strokeWidth={2}\n                    d=\"M5 13l4 4L19 7\"\n                  />\n                </svg>\n              </div>\n            ) : (\n              \"Copy\"\n            )}\n          </button>\n        )}\n      </div>\n    </SettingContainer>\n  );\n};\n"
  },
  {
    "path": "src/components/ui/Textarea.tsx",
    "content": "import React from \"react\";\n\ninterface TextareaProps\n  extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {\n  variant?: \"default\" | \"compact\";\n}\n\nexport const Textarea: React.FC<TextareaProps> = ({\n  className = \"\",\n  variant = \"default\",\n  ...props\n}) => {\n  const baseClasses =\n    \"px-2 py-1 text-sm font-semibold bg-mid-gray/10 border border-mid-gray/80 rounded-md text-start transition-[background-color,border-color] duration-150 hover:bg-logo-primary/10 hover:border-logo-primary focus:outline-none focus:bg-logo-primary/10 focus:border-logo-primary resize-y\";\n\n  const variantClasses = {\n    default: \"px-3 py-2 min-h-[100px]\",\n    compact: \"px-2 py-1 min-h-[80px]\",\n  };\n\n  return (\n    <textarea\n      className={`${baseClasses} ${variantClasses[variant]} ${className}`}\n      {...props}\n    />\n  );\n};\n"
  },
  {
    "path": "src/components/ui/ToggleSwitch.tsx",
    "content": "import React from \"react\";\nimport { SettingContainer } from \"./SettingContainer\";\n\ninterface ToggleSwitchProps {\n  checked: boolean;\n  onChange: (checked: boolean) => void;\n  disabled?: boolean;\n  isUpdating?: boolean;\n  label: string;\n  description: string;\n  descriptionMode?: \"inline\" | \"tooltip\";\n  grouped?: boolean;\n  tooltipPosition?: \"top\" | \"bottom\";\n}\n\nexport const ToggleSwitch: React.FC<ToggleSwitchProps> = ({\n  checked,\n  onChange,\n  disabled = false,\n  isUpdating = false,\n  label,\n  description,\n  descriptionMode = \"tooltip\",\n  grouped = false,\n  tooltipPosition = \"top\",\n}) => {\n  return (\n    <SettingContainer\n      title={label}\n      description={description}\n      descriptionMode={descriptionMode}\n      grouped={grouped}\n      disabled={disabled}\n      tooltipPosition={tooltipPosition}\n    >\n      <label\n        className={`inline-flex items-center ${disabled || isUpdating ? \"cursor-not-allowed\" : \"cursor-pointer\"}`}\n      >\n        <input\n          type=\"checkbox\"\n          value=\"\"\n          className=\"sr-only peer\"\n          checked={checked}\n          disabled={disabled || isUpdating}\n          onChange={(e) => onChange(e.target.checked)}\n        />\n        <div className=\"relative w-11 h-6 bg-mid-gray/20 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-logo-primary rounded-full peer peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-background-ui peer-disabled:opacity-50\"></div>\n      </label>\n      {isUpdating && (\n        <div className=\"absolute inset-0 flex items-center justify-center\">\n          <div className=\"w-4 h-4 border-2 border-logo-primary border-t-transparent rounded-full animate-spin\"></div>\n        </div>\n      )}\n    </SettingContainer>\n  );\n};\n"
  },
  {
    "path": "src/components/ui/Tooltip.tsx",
    "content": "import React, { useCallback, useEffect, useRef, useState } from \"react\";\nimport { createPortal } from \"react-dom\";\n\ntype TooltipPosition = \"top\" | \"bottom\";\n\ninterface TooltipCoords {\n  top: number;\n  left: number;\n  arrowLeft: number;\n  actualPosition: TooltipPosition;\n}\n\ninterface TooltipProps {\n  targetRef: React.RefObject<HTMLElement>;\n  position?: TooltipPosition;\n  children: React.ReactNode;\n}\n\nconst TOOLTIP_WIDTH = 200;\nconst VIEWPORT_PADDING = 12;\nconst GAP = 8;\nconst ARROW_MARGIN = 12;\nconst DEFAULT_HEIGHT = 60;\n\nexport const Tooltip: React.FC<TooltipProps> = ({\n  targetRef,\n  position = \"top\",\n  children,\n}) => {\n  const [coords, setCoords] = useState<TooltipCoords | null>(null);\n  const tooltipRef = useRef<HTMLDivElement>(null);\n\n  const updatePosition = useCallback(() => {\n    if (!targetRef.current) return;\n\n    const targetRect = targetRef.current.getBoundingClientRect();\n    const tooltipHeight = tooltipRef.current?.offsetHeight || DEFAULT_HEIGHT;\n\n    let actualPosition = position;\n    let top: number;\n\n    if (position === \"top\") {\n      const spaceAbove = targetRect.top - tooltipHeight - GAP;\n      if (spaceAbove < VIEWPORT_PADDING) {\n        actualPosition = \"bottom\";\n        top = targetRect.bottom + GAP;\n      } else {\n        top = targetRect.top - GAP - tooltipHeight;\n      }\n    } else {\n      const spaceBelow =\n        window.innerHeight - targetRect.bottom - tooltipHeight - GAP;\n      if (spaceBelow < VIEWPORT_PADDING) {\n        actualPosition = \"top\";\n        top = targetRect.top - GAP - tooltipHeight;\n      } else {\n        top = targetRect.bottom + GAP;\n      }\n    }\n\n    const targetCenter = targetRect.left + targetRect.width / 2;\n    let left = targetCenter - TOOLTIP_WIDTH / 2;\n\n    if (left < VIEWPORT_PADDING) {\n      left = VIEWPORT_PADDING;\n    } else if (left + TOOLTIP_WIDTH > window.innerWidth - VIEWPORT_PADDING) {\n      left = window.innerWidth - TOOLTIP_WIDTH - VIEWPORT_PADDING;\n    }\n\n    const arrowLeft = Math.min(\n      Math.max(targetCenter - left, ARROW_MARGIN),\n      TOOLTIP_WIDTH - ARROW_MARGIN,\n    );\n\n    setCoords({ top, left, arrowLeft, actualPosition });\n  }, [targetRef, position]);\n\n  useEffect(() => {\n    updatePosition();\n\n    window.addEventListener(\"scroll\", updatePosition, true);\n    window.addEventListener(\"resize\", updatePosition);\n\n    return () => {\n      window.removeEventListener(\"scroll\", updatePosition, true);\n      window.removeEventListener(\"resize\", updatePosition);\n    };\n  }, [updatePosition]);\n\n  const arrowClasses =\n    coords?.actualPosition === \"top\" ? \"top-full\" : \"bottom-full rotate-180\";\n\n  return createPortal(\n    <div\n      ref={tooltipRef}\n      style={{\n        position: \"fixed\",\n        top: coords?.top ?? -9999,\n        left: coords?.left ?? -9999,\n        width: TOOLTIP_WIDTH,\n        zIndex: 9999,\n        opacity: coords ? 1 : 0,\n      }}\n      className=\"px-3 py-2 bg-background border border-mid-gray/80 rounded-lg shadow-lg whitespace-normal transition-opacity duration-150\"\n    >\n      {children}\n      <div\n        style={{ left: coords?.arrowLeft ?? 0 }}\n        className={`absolute ${arrowClasses} transform -translate-x-1/2 w-0 h-0 border-l-[6px] border-r-[6px] border-t-[6px] border-l-transparent border-r-transparent border-t-mid-gray/80`}\n      />\n    </div>,\n    document.body,\n  );\n};\n"
  },
  {
    "path": "src/components/ui/index.ts",
    "content": "export { Dropdown } from \"./Dropdown\";\nexport { Slider } from \"./Slider\";\nexport { ToggleSwitch } from \"./ToggleSwitch\";\nexport { SettingContainer } from \"./SettingContainer\";\nexport { SettingsGroup } from \"./SettingsGroup\";\nexport { TextDisplay } from \"./TextDisplay\";\nexport { Textarea } from \"./Textarea\";\nexport { Tooltip } from \"./Tooltip\";\n"
  },
  {
    "path": "src/components/update-checker/UpdateChecker.tsx",
    "content": "import React, { useState, useEffect, useRef } from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport { check } from \"@tauri-apps/plugin-updater\";\nimport { relaunch } from \"@tauri-apps/plugin-process\";\nimport { listen } from \"@tauri-apps/api/event\";\nimport { ProgressBar } from \"../shared\";\nimport { useSettings } from \"../../hooks/useSettings\";\n\ninterface UpdateCheckerProps {\n  className?: string;\n}\n\nconst UpdateChecker: React.FC<UpdateCheckerProps> = ({ className = \"\" }) => {\n  const { t } = useTranslation();\n  // Update checking state\n  const [isChecking, setIsChecking] = useState(false);\n  const [updateAvailable, setUpdateAvailable] = useState(false);\n  const [isInstalling, setIsInstalling] = useState(false);\n  const [downloadProgress, setDownloadProgress] = useState(0);\n  const [showUpToDate, setShowUpToDate] = useState(false);\n\n  const { settings, isLoading } = useSettings();\n  const settingsLoaded = !isLoading && settings !== null;\n  const updateChecksEnabled = settings?.update_checks_enabled ?? false;\n\n  const upToDateTimeoutRef = useRef<ReturnType<typeof setTimeout>>();\n  const isManualCheckRef = useRef(false);\n  const downloadedBytesRef = useRef(0);\n  const contentLengthRef = useRef(0);\n\n  useEffect(() => {\n    // Wait for settings to load before doing anything\n    if (!settingsLoaded) return;\n\n    if (!updateChecksEnabled) {\n      if (upToDateTimeoutRef.current) {\n        clearTimeout(upToDateTimeoutRef.current);\n      }\n      setIsChecking(false);\n      setUpdateAvailable(false);\n      setShowUpToDate(false);\n      return;\n    }\n\n    checkForUpdates();\n\n    // Listen for update check events\n    const updateUnlisten = listen(\"check-for-updates\", () => {\n      handleManualUpdateCheck();\n    });\n\n    return () => {\n      if (upToDateTimeoutRef.current) {\n        clearTimeout(upToDateTimeoutRef.current);\n      }\n      updateUnlisten.then((fn) => fn());\n    };\n  }, [settingsLoaded, updateChecksEnabled]);\n\n  // Update checking functions\n  const checkForUpdates = async () => {\n    if (!updateChecksEnabled || isChecking) return;\n\n    try {\n      setIsChecking(true);\n      const update = await check();\n\n      if (update) {\n        setUpdateAvailable(true);\n        setShowUpToDate(false);\n      } else {\n        setUpdateAvailable(false);\n\n        if (isManualCheckRef.current) {\n          setShowUpToDate(true);\n          if (upToDateTimeoutRef.current) {\n            clearTimeout(upToDateTimeoutRef.current);\n          }\n          upToDateTimeoutRef.current = setTimeout(() => {\n            setShowUpToDate(false);\n          }, 3000);\n        }\n      }\n    } catch (error) {\n      console.error(\"Failed to check for updates:\", error);\n    } finally {\n      setIsChecking(false);\n      isManualCheckRef.current = false;\n    }\n  };\n\n  const handleManualUpdateCheck = () => {\n    if (!updateChecksEnabled) return;\n    isManualCheckRef.current = true;\n    checkForUpdates();\n  };\n\n  const installUpdate = async () => {\n    if (!updateChecksEnabled) return;\n    try {\n      setIsInstalling(true);\n      setDownloadProgress(0);\n      downloadedBytesRef.current = 0;\n      contentLengthRef.current = 0;\n      const update = await check();\n\n      if (!update) {\n        console.log(\"No update available during install attempt\");\n        return;\n      }\n\n      await update.downloadAndInstall((event) => {\n        switch (event.event) {\n          case \"Started\":\n            downloadedBytesRef.current = 0;\n            contentLengthRef.current = event.data.contentLength ?? 0;\n            break;\n          case \"Progress\":\n            downloadedBytesRef.current += event.data.chunkLength;\n            const progress =\n              contentLengthRef.current > 0\n                ? Math.round(\n                    (downloadedBytesRef.current / contentLengthRef.current) *\n                      100,\n                  )\n                : 0;\n            setDownloadProgress(Math.min(progress, 100));\n            break;\n        }\n      });\n      await relaunch();\n    } catch (error) {\n      console.error(\"Failed to install update:\", error);\n    } finally {\n      setIsInstalling(false);\n      setDownloadProgress(0);\n      downloadedBytesRef.current = 0;\n      contentLengthRef.current = 0;\n    }\n  };\n\n  // Update status functions\n  const getUpdateStatusText = () => {\n    if (!updateChecksEnabled) {\n      return t(\"footer.updateCheckingDisabled\");\n    }\n    if (isInstalling) {\n      return downloadProgress > 0 && downloadProgress < 100\n        ? t(\"footer.downloading\", {\n            progress: downloadProgress.toString().padStart(3),\n          })\n        : downloadProgress === 100\n          ? t(\"footer.installing\")\n          : t(\"footer.preparing\");\n    }\n    if (isChecking) return t(\"footer.checkingUpdates\");\n    if (showUpToDate) return t(\"footer.upToDate\");\n    if (updateAvailable) return t(\"footer.updateAvailableShort\");\n    return t(\"footer.checkForUpdates\");\n  };\n\n  const getUpdateStatusAction = () => {\n    if (!updateChecksEnabled) return undefined;\n    if (updateAvailable && !isInstalling) return installUpdate;\n    if (!isChecking && !isInstalling && !updateAvailable)\n      return handleManualUpdateCheck;\n    return undefined;\n  };\n\n  const isUpdateDisabled = !updateChecksEnabled || isChecking || isInstalling;\n  const isUpdateClickable =\n    !isUpdateDisabled && (updateAvailable || (!isChecking && !showUpToDate));\n\n  return (\n    <div className={`flex items-center gap-3 ${className}`}>\n      {isUpdateClickable ? (\n        <button\n          onClick={getUpdateStatusAction()}\n          disabled={isUpdateDisabled}\n          className={`transition-colors disabled:opacity-50 tabular-nums ${\n            updateAvailable\n              ? \"text-logo-primary hover:text-logo-primary/80 font-medium\"\n              : \"text-text/60 hover:text-text/80\"\n          }`}\n        >\n          {getUpdateStatusText()}\n        </button>\n      ) : (\n        <span className=\"text-text/60 tabular-nums\">\n          {getUpdateStatusText()}\n        </span>\n      )}\n\n      {isInstalling && downloadProgress > 0 && downloadProgress < 100 && (\n        <ProgressBar\n          progress={[\n            {\n              id: \"update\",\n              percentage: downloadProgress,\n            },\n          ]}\n          size=\"large\"\n        />\n      )}\n    </div>\n  );\n};\n\nexport default UpdateChecker;\n"
  },
  {
    "path": "src/components/update-checker/index.ts",
    "content": "export { default } from \"./UpdateChecker\";\n"
  },
  {
    "path": "src/hooks/useOsType.ts",
    "content": "import { type } from \"@tauri-apps/plugin-os\";\nimport { type OSType } from \"../lib/utils/keyboard\";\n\n/**\n * Get the current OS type for keyboard handling.\n * This is a simple wrapper - type() is synchronous.\n */\nexport function useOsType(): OSType {\n  const osType = type();\n  // type() returns \"macos\" | \"windows\" | \"linux\" | \"ios\" | \"android\"\n  // OSType expects \"macos\" | \"windows\" | \"linux\" | \"unknown\"\n  if (osType === \"macos\" || osType === \"windows\" || osType === \"linux\") {\n    return osType;\n  }\n  return \"unknown\";\n}\n"
  },
  {
    "path": "src/hooks/useSettings.ts",
    "content": "import { useEffect } from \"react\";\nimport { useSettingsStore } from \"../stores/settingsStore\";\nimport type { AppSettings as Settings, AudioDevice } from \"@/bindings\";\n\ninterface UseSettingsReturn {\n  // State\n  settings: Settings | null;\n  isLoading: boolean;\n  isUpdating: (key: string) => boolean;\n  audioDevices: AudioDevice[];\n  outputDevices: AudioDevice[];\n  audioFeedbackEnabled: boolean;\n  postProcessModelOptions: Record<string, string[]>;\n\n  // Actions\n  updateSetting: <K extends keyof Settings>(\n    key: K,\n    value: Settings[K],\n  ) => Promise<void>;\n  resetSetting: (key: keyof Settings) => Promise<void>;\n  refreshSettings: () => Promise<void>;\n  refreshAudioDevices: () => Promise<void>;\n  refreshOutputDevices: () => Promise<void>;\n\n  // Binding-specific actions\n  updateBinding: (id: string, binding: string) => Promise<void>;\n  resetBinding: (id: string) => Promise<void>;\n\n  // Convenience getters\n  getSetting: <K extends keyof Settings>(key: K) => Settings[K] | undefined;\n\n  // Post-processing helpers\n  setPostProcessProvider: (providerId: string) => Promise<void>;\n  updatePostProcessBaseUrl: (\n    providerId: string,\n    baseUrl: string,\n  ) => Promise<void>;\n  updatePostProcessApiKey: (\n    providerId: string,\n    apiKey: string,\n  ) => Promise<void>;\n  updatePostProcessModel: (providerId: string, model: string) => Promise<void>;\n  fetchPostProcessModels: (providerId: string) => Promise<string[]>;\n}\n\nexport const useSettings = (): UseSettingsReturn => {\n  const store = useSettingsStore();\n\n  // Initialize on first mount\n  useEffect(() => {\n    if (store.isLoading) {\n      store.initialize();\n    }\n  }, [store.initialize, store.isLoading]);\n\n  return {\n    settings: store.settings,\n    isLoading: store.isLoading,\n    isUpdating: store.isUpdatingKey,\n    audioDevices: store.audioDevices,\n    outputDevices: store.outputDevices,\n    audioFeedbackEnabled: store.settings?.audio_feedback || false,\n    postProcessModelOptions: store.postProcessModelOptions,\n    updateSetting: store.updateSetting,\n    resetSetting: store.resetSetting,\n    refreshSettings: store.refreshSettings,\n    refreshAudioDevices: store.refreshAudioDevices,\n    refreshOutputDevices: store.refreshOutputDevices,\n    updateBinding: store.updateBinding,\n    resetBinding: store.resetBinding,\n    getSetting: store.getSetting,\n    setPostProcessProvider: store.setPostProcessProvider,\n    updatePostProcessBaseUrl: store.updatePostProcessBaseUrl,\n    updatePostProcessApiKey: store.updatePostProcessApiKey,\n    updatePostProcessModel: store.updatePostProcessModel,\n    fetchPostProcessModels: store.fetchPostProcessModels,\n  };\n};\n"
  },
  {
    "path": "src/i18n/index.ts",
    "content": "import i18n from \"i18next\";\nimport { initReactI18next } from \"react-i18next\";\nimport { locale } from \"@tauri-apps/plugin-os\";\nimport { LANGUAGE_METADATA } from \"./languages\";\nimport { commands } from \"@/bindings\";\nimport {\n  getLanguageDirection,\n  updateDocumentDirection,\n  updateDocumentLanguage,\n} from \"@/lib/utils/rtl\";\n\n// Auto-discover translation files using Vite's glob import\nconst localeModules = import.meta.glob<{ default: Record<string, unknown> }>(\n  \"./locales/*/translation.json\",\n  { eager: true },\n);\n\n// Build resources from discovered locale files\nconst resources: Record<string, { translation: Record<string, unknown> }> = {};\nfor (const [path, module] of Object.entries(localeModules)) {\n  const langCode = path.match(/\\.\\/locales\\/(.+)\\/translation\\.json/)?.[1];\n  if (langCode) {\n    resources[langCode] = { translation: module.default };\n  }\n}\n\n// Build supported languages list from discovered locales + metadata\nexport const SUPPORTED_LANGUAGES = Object.keys(resources)\n  .map((code) => {\n    const meta = LANGUAGE_METADATA[code];\n    if (!meta) {\n      console.warn(`Missing metadata for locale \"${code}\" in languages.ts`);\n      return { code, name: code, nativeName: code, priority: undefined };\n    }\n    return {\n      code,\n      name: meta.name,\n      nativeName: meta.nativeName,\n      priority: meta.priority,\n    };\n  })\n  .sort((a, b) => {\n    // Sort by priority first (lower = higher), then alphabetically\n    if (a.priority !== undefined && b.priority !== undefined) {\n      return a.priority - b.priority;\n    }\n    if (a.priority !== undefined) return -1;\n    if (b.priority !== undefined) return 1;\n    return a.name.localeCompare(b.name);\n  });\n\nexport type SupportedLanguageCode = string;\n\n// Check if a language code is supported\nconst getSupportedLanguage = (\n  langCode: string | null | undefined,\n): SupportedLanguageCode | null => {\n  if (!langCode) return null;\n  const normalized = langCode.toLowerCase();\n  // Try exact match first\n  let supported = SUPPORTED_LANGUAGES.find(\n    (lang) => lang.code.toLowerCase() === normalized,\n  );\n  if (!supported) {\n    // Fall back to prefix match (language only, without region)\n    const prefix = normalized.split(\"-\")[0];\n    supported = SUPPORTED_LANGUAGES.find(\n      (lang) => lang.code.toLowerCase() === prefix,\n    );\n  }\n  return supported ? supported.code : null;\n};\n\n// Initialize i18n with English as default\n// Language will be synced from settings after init\ni18n.use(initReactI18next).init({\n  resources,\n  lng: \"en\",\n  fallbackLng: \"en\",\n  interpolation: {\n    escapeValue: false, // React already escapes values\n  },\n  react: {\n    useSuspense: false, // Disable suspense for SSR compatibility\n  },\n});\n\n// Sync language from app settings\nexport const syncLanguageFromSettings = async () => {\n  try {\n    const result = await commands.getAppSettings();\n    if (result.status === \"ok\" && result.data.app_language) {\n      const supported = getSupportedLanguage(result.data.app_language);\n      if (supported && supported !== i18n.language) {\n        await i18n.changeLanguage(supported);\n      }\n    } else {\n      // Fall back to system locale detection if no saved preference\n      const systemLocale = await locale();\n      const supported = getSupportedLanguage(systemLocale);\n      if (supported && supported !== i18n.language) {\n        await i18n.changeLanguage(supported);\n      }\n    }\n  } catch (e) {\n    console.warn(\"Failed to sync language from settings:\", e);\n  }\n};\n\n// Run language sync on init\nsyncLanguageFromSettings();\n\n// Listen for language changes to update HTML dir and lang attributes\ni18n.on(\"languageChanged\", (lng) => {\n  const dir = getLanguageDirection(lng);\n  updateDocumentDirection(dir);\n  updateDocumentLanguage(lng);\n});\n\n// Re-export RTL utilities for convenience\nexport { getLanguageDirection, isRTLLanguage } from \"@/lib/utils/rtl\";\n\nexport default i18n;\n"
  },
  {
    "path": "src/i18n/languages.ts",
    "content": "/**\n * Language metadata for supported locales.\n *\n * To add a new language:\n * 1. Create a new folder: src/i18n/locales/{code}/translation.json\n * 2. Add an entry here with the language code, English name, and native name\n * 3. Optionally add a priority (lower = higher in dropdown, no priority = alphabetical at end)\n * 4. For RTL languages, add direction: 'rtl'\n */\nexport const LANGUAGE_METADATA: Record<\n  string,\n  {\n    name: string;\n    nativeName: string;\n    priority?: number;\n    direction?: \"ltr\" | \"rtl\";\n  }\n> = {\n  en: { name: \"English\", nativeName: \"English\", priority: 1 },\n  zh: { name: \"Simplified Chinese\", nativeName: \"简体中文\", priority: 2 },\n  \"zh-TW\": { name: \"Traditional Chinese\", nativeName: \"繁體中文\", priority: 3 },\n  es: { name: \"Spanish\", nativeName: \"Español\", priority: 4 },\n  fr: { name: \"French\", nativeName: \"Français\", priority: 5 },\n  de: { name: \"German\", nativeName: \"Deutsch\", priority: 6 },\n  ja: { name: \"Japanese\", nativeName: \"日本語\", priority: 7 },\n  ko: { name: \"Korean\", nativeName: \"한국어\", priority: 8 },\n  vi: { name: \"Vietnamese\", nativeName: \"Tiếng Việt\", priority: 9 },\n  pl: { name: \"Polish\", nativeName: \"Polski\", priority: 10 },\n  it: { name: \"Italian\", nativeName: \"Italiano\", priority: 11 },\n  ru: { name: \"Russian\", nativeName: \"Русский\", priority: 12 },\n  uk: { name: \"Ukrainian\", nativeName: \"Українська\", priority: 13 },\n  pt: { name: \"Portuguese\", nativeName: \"Português\", priority: 14 },\n  cs: { name: \"Czech\", nativeName: \"Čeština\", priority: 15 },\n  tr: { name: \"Turkish\", nativeName: \"Türkçe\", priority: 16 },\n  ar: { name: \"Arabic\", nativeName: \"العربية\", priority: 17, direction: \"rtl\" },\n};\n"
  },
  {
    "path": "src/i18n/locales/ar/translation.json",
    "content": "{\n  \"tray\": {\n    \"settings\": \"...الإعدادات\",\n    \"checkUpdates\": \"...التحقق من وجود تحديثات\",\n    \"copyLastTranscript\": \"نسخ آخر نص تم تفريغه\",\n    \"unloadModel\": \"تفريغ النموذج\",\n    \"model\": \"النموذج\",\n    \"quit\": \"إنهاء\",\n    \"cancel\": \"إلغاء\"\n  },\n  \"sidebar\": {\n    \"general\": \"عام\",\n    \"advanced\": \"متقدم\",\n    \"postProcessing\": \"معالجة لاحقة\",\n    \"history\": \"السجل\",\n    \"debug\": \"تصحيح الأخطاء\",\n    \"about\": \"حول\",\n    \"models\": \"النماذج\"\n  },\n  \"onboarding\": {\n    \"subtitle\": \"للبدء، اختر نموذج التفريغ الصوتي\",\n    \"recommended\": \"موصى به\",\n    \"download\": \"تنزيل\",\n    \"downloading\": \"...جاري التنزيل\",\n    \"customModelDescription\": \"غير مدعوم رسميًا\",\n    \"downloadFailed\": \"فشل التنزيل. يرجى المحاولة مرة أخرى.\",\n    \"modelCard\": {\n      \"accuracy\": \"الدقة\",\n      \"speed\": \"السرعة\"\n    },\n    \"models\": {\n      \"small\": {\n        \"name\": \"Whisper Small\",\n        \"description\": \".سريع ودقيق إلى حد ما\"\n      },\n      \"medium\": {\n        \"name\": \"Whisper Medium\",\n        \"description\": \".دقة جيدة، سرعة متوسطة\"\n      },\n      \"turbo\": {\n        \"name\": \"Whisper Turbo\",\n        \"description\": \".توازن بين الدقة والسرعة\"\n      },\n      \"large\": {\n        \"name\": \"Whisper Large\",\n        \"description\": \".دقة جيدة، لكنه بطيء\"\n      },\n      \"parakeet-tdt-0.6b-v2\": {\n        \"name\": \"Parakeet V2\",\n        \"description\": \".الإنجليزية فقط. أفضل نموذج للمتحدثين بالإنجليزية\"\n      },\n      \"parakeet-tdt-0.6b-v3\": {\n        \"name\": \"Parakeet V3\",\n        \"description\": \".سريع ودقيق\"\n      },\n      \"moonshine-base\": {\n        \"name\": \"Moonshine Base\",\n        \"description\": \".سريع جداً، الإنجليزية فقط. يتعامل جيداً مع اللهجات\"\n      },\n      \"moonshine-tiny-streaming-en\": {\n        \"name\": \"Moonshine V2 Tiny\",\n        \"description\": \"سريع للغاية، الإنجليزية فقط\"\n      },\n      \"moonshine-small-streaming-en\": {\n        \"name\": \"Moonshine V2 Small\",\n        \"description\": \"سريع، الإنجليزية فقط. توازن جيد بين السرعة والدقة.\"\n      },\n      \"moonshine-medium-streaming-en\": {\n        \"name\": \"Moonshine V2 Medium\",\n        \"description\": \"الإنجليزية فقط. جودة عالية.\"\n      },\n      \"breeze-asr\": {\n        \"name\": \"Breeze ASR\",\n        \"description\": \"محسّن للماندرين التايوانية. دعم التبديل بين اللغات.\"\n      },\n      \"sense-voice-int8\": {\n        \"name\": \"SenseVoice\",\n        \"description\": \"سريع جداً. الصينية، الإنجليزية، اليابانية، الكورية، الكانتونية.\"\n      },\n      \"gigaam-v3-e2e-ctc\": {\n        \"name\": \"GigaAM v3\",\n        \"description\": \"التعرف على الكلام الروسي. سريع ودقيق.\"\n      },\n      \"canary-180m-flash\": {\n        \"name\": \"Canary 180M Flash\",\n        \"description\": \"سريع جداً. الإنجليزية، الألمانية، الإسبانية، الفرنسية. يدعم الترجمة.\"\n      },\n      \"canary-1b-v2\": {\n        \"name\": \"Canary 1B v2\",\n        \"description\": \"متعدد اللغات ودقيق. 25 لغة أوروبية. يدعم الترجمة.\"\n      }\n    },\n    \"errors\": {\n      \"loadModels\": \"فشل تحميل النماذج المتاحة\",\n      \"downloadModel\": \"فشل تنزيل النموذج: {{error}}\"\n    },\n    \"permissions\": {\n      \"title\": \"الأذونات المطلوبة\",\n      \"description\": \".يحتاج Handy إلى بعض الأذونات ليعمل بشكل صحيح\",\n      \"microphone\": {\n        \"title\": \"الوصول إلى الميكروفون\",\n        \"description\": \".مطلوب لسماع صوتك من أجل التفريغ الصوتي\"\n      },\n      \"accessibility\": {\n        \"title\": \"إمكانية الوصول\",\n        \"description\": \".مطلوب لكتابة النص المفرغ في تطبيقاتك\"\n      },\n      \"grant\": \"منح الإذن\",\n      \"granted\": \"تم المنح\",\n      \"waiting\": \"...في الانتظار\",\n      \"allGranted\": \"كل شيء جاهز!\",\n      \"errors\": {\n        \"checkFailed\": \"فشل التحقق من الأذونات. يرجير المحاولة مرة أخرى.\",\n        \"requestFailed\": \"فشل طلب الإذن. يرجى المحاولة مرة أخرى.\"\n      }\n    }\n  },\n  \"modelSelector\": {\n    \"custom\": \"مخصص\",\n    \"active\": \"نشط\",\n    \"noModelsAvailable\": \"لا توجد نماذج متاحة\",\n    \"extracting\": \"...جاري استخراج {{modelName}}\",\n    \"extractingMultiple\": \"...جاري استخراج {{count}} من النماذج\",\n    \"extractingGeneric\": \"...جاري الاستخراج\",\n    \"downloading\": \"جاري التنزيل {{percentage}}%\",\n    \"downloadingMultiple\": \"جاري تنزيل {{count}} من النماذج...\",\n    \"modelReady\": \"النموذج جاهز\",\n    \"loading\": \"...جاري تحميل {{modelName}}\",\n    \"loadingGeneric\": \"...جاري التحميل\",\n    \"modelError\": \"خطأ في النموذج\",\n    \"modelUnloaded\": \"تم إلغاء تحميل النموذج\",\n    \"noModelDownloadRequired\": \"لا يوجد نموذج - التنزيل مطلوب\",\n    \"deleteModel\": \"حذف {{modelName}}\",\n    \"switching\": \"جارٍ التبديل...\",\n    \"downloadSpeed\": \"{{speed}} ميجابايت/ث\",\n    \"cancel\": \"إلغاء\",\n    \"cancelDownload\": \"إلغاء التنزيل\",\n    \"capabilities\": {\n      \"languageSelection\": \"يدعم اختيار اللغة\",\n      \"singleLanguage\": \"يدعم هذه اللغة فقط\",\n      \"multiLanguage\": \"متعدد اللغات\",\n      \"languageOnly\": \"{{language}} فقط\",\n      \"translation\": \"يدعم الترجمة\",\n      \"translate\": \"ترجمة\"\n    }\n  },\n  \"settings\": {\n    \"general\": {\n      \"title\": \"عام\",\n      \"shortcut\": {\n        \"title\": \"اختصارات Handy\",\n        \"description\": \"إعداد اختصارات لوحة المفاتيح لبدء تسجيل التفريغ الصوتي\",\n        \"loading\": \"...جاري تحميل الاختصارات\",\n        \"none\": \"لا توجد اختصارات مجهزة\",\n        \"notFound\": \"الاختصار غير موجود\",\n        \"pressKeys\": \"...اضغط على المفاتيح\",\n        \"bindings\": {\n          \"transcribe\": {\n            \"name\": \"اختصار التفريغ الصوتي\",\n            \"description\": \".اختصار لوحة المفاتيح لتسجيل وتفريغ صوتك\"\n          },\n          \"cancel\": {\n            \"name\": \"اختصار الإلغاء\",\n            \"description\": \".اختصار لوحة المفاتيح لإلغاء التسجيل الحالي\"\n          },\n          \"transcribe_with_post_process\": {\n            \"name\": \"مفتاح المعالجة اللاحقة\",\n            \"description\": \"اختياري: مفتاح اختصار مخصص يطبق دائماً المعالجة اللاحقة بالذكاء الاصطناعي على التفريغ الصوتي.\"\n          }\n        },\n        \"errors\": {\n          \"restore\": \"فشل استعادة الاختصار الأصلي\",\n          \"set\": \"فشل تعيين الاختصار: {{error}}\",\n          \"reset\": \"فشل إعادة تعيين الاختصار إلى قيمته الأصلية\"\n        }\n      },\n      \"language\": {\n        \"title\": \"اللغة\",\n        \"description\": \".اختر لغة التعرف على الكلام. سيحدد 'تلقائي' اللغة تلقائياً، بينما يؤدي اختيار لغة معينة إلى تحسين الدقة لتلك اللغة\",\n        \"descriptionUnsupported\": \".يكتشف نموذج Parakeet اللغة تلقائياً. لا يلزم الاختيار اليدوي\",\n        \"searchPlaceholder\": \"البحث عن اللغات...\",\n        \"noResults\": \"لم يتم العثور على لغات\",\n        \"auto\": \"تلقائي\"\n      },\n      \"pushToTalk\": {\n        \"label\": \"اضغط للتحدث\",\n        \"description\": \"استمر في الضغط للتسجيل، واترك للتوقف\"\n      }\n    },\n    \"sound\": {\n      \"title\": \"الصوت\",\n      \"microphone\": {\n        \"title\": \"الميكروفون\",\n        \"description\": \"اختر جهاز الميكروفون المفضل لديك\",\n        \"placeholder\": \"...اختر الميكروفون\",\n        \"loading\": \"...جاري التحميل\"\n      },\n      \"audioFeedback\": {\n        \"label\": \"تنبيهات صوتية\",\n        \"description\": \"تشغيل صوت عند بدء التسجيل وتوقفه\"\n      },\n      \"outputDevice\": {\n        \"title\": \"جهاز الإخراج\",\n        \"description\": \"اختر جهاز إخراج الصوت المفضل لتنبيهات الصوت\",\n        \"placeholder\": \"...اختر جهاز الإخراج\",\n        \"loading\": \"...جاري التحميل\"\n      },\n      \"volume\": {\n        \"title\": \"مستوى الصوت\",\n        \"description\": \"ضبط مستوى صوت تنبيهات الصوت\"\n      }\n    },\n    \"advanced\": {\n      \"title\": \"متقدم\",\n      \"groups\": {\n        \"app\": \"التطبيق\",\n        \"output\": \"الإخراج\",\n        \"transcription\": \"التفريغ الصوتي\",\n        \"history\": \"السجل\",\n        \"experimental\": \"تجريبي\"\n      },\n      \"experimentalToggle\": {\n        \"label\": \"الميزات التجريبية\",\n        \"description\": \".تمكين الميزات التجريبية التي لا تزال قيد التطوير\"\n      },\n      \"lazyStreamClose\": {\n        \"label\": \"إبقاء الميكروفون مفتوحًا بين عمليات النسخ\",\n        \"description\": \"يبقي بث الميكروفون مفتوحًا لمدة 30 ثانية بعد توقف التسجيل، مما يقلل التأخير عند النسخ المتتالي. قد يؤثر على جودة صوت البلوتوث أثناء التفعيل.\"\n      },\n      \"acceleration\": {\n        \"whisper\": {\n          \"title\": \"تسريع Whisper\",\n          \"description\": \"تسريع الأجهزة لنماذج Whisper. الوضع التلقائي يستخدم GPU إن توفر (Metal على macOS، Vulkan على Windows/Linux).\"\n        },\n        \"ort\": {\n          \"title\": \"تسريع ONNX\",\n          \"description\": \"تسريع الأجهزة لنماذج ONNX (Parakeet، Canary، Moonshine، إلخ). DirectML على Windows تجريبي. قد تفشل النماذج في النسخ.\"\n        }\n      },\n      \"startHidden\": {\n        \"label\": \"بدء مخفي\",\n        \"description\": \".التشغيل في صينية النظام دون فتح النافذة\"\n      },\n      \"autostart\": {\n        \"label\": \"التشغيل عند بدء التشغيل\",\n        \"description\": \".بدء تشغيل Handy تلقائياً عند تسجيل الدخول إلى جهاز الكمبيوتر الخاص بك\"\n      },\n      \"showTrayIcon\": {\n        \"label\": \"إظهار أيقونة شريط النظام\",\n        \"description\": \".عرض أيقونة Handy في شريط النظام\"\n      },\n      \"overlay\": {\n        \"title\": \"موقع التراكب\",\n        \"description\": \"عرض تراكب الملاحظات المرئية أثناء التسجيل والتفريغ. على نظام Linux يوصى بـ 'بلا'.\",\n        \"options\": {\n          \"none\": \"بلا\",\n          \"bottom\": \"أسفل\",\n          \"top\": \"أعلى\"\n        }\n      },\n      \"pasteMethod\": {\n        \"title\": \"طريقة اللصق\",\n        \"description\": \".اختر كيفية إدراج النص. مباشر: يحاكي الكتابة عبر إدخال النظام. بلا: يتخطى اللصق، ويحدث السجل/الحافظة فقط\",\n        \"options\": {\n          \"clipboard\": \"الحافظة ({{modifier}}+V)\",\n          \"clipboardCtrlShiftV\": \"الحافظة (Ctrl+Shift+V)\",\n          \"clipboardShiftInsert\": \"الحافظة (Shift+Insert)\",\n          \"direct\": \"مباشر\",\n          \"none\": \"بلا\",\n          \"externalScript\": \"نص برمجي خارجي\"\n        },\n        \"externalScriptPlaceholder\": \"/path/to/your/script.sh\"\n      },\n      \"typingTool\": {\n        \"title\": \"أداة الكتابة\",\n        \"description\": \".اختر أداة الكتابة في Linux لاستخدامها مع طريقة اللصق المباشر. سيكتشف \\\"تلقائي\\\" تلقائياً أفضل أداة متاحة لنظامك ويستخدمها\",\n        \"options\": {\n          \"auto\": \"تلقائي (موصى به)\"\n        }\n      },\n      \"clipboardHandling\": {\n        \"title\": \"التعامل مع الحافظة\",\n        \"description\": \".'عدم تعديل الحافظة' يحافظ على محتويات حافظتك الحالية بعد التفريغ. 'نسخ إلى الحافظة' يترك نتيجة التفريغ في حافظتك بعد اللصق\",\n        \"options\": {\n          \"dontModify\": \"عدم تعديل الحافظة\",\n          \"copyToClipboard\": \"نسخ إلى الحافظة\"\n        }\n      },\n      \"autoSubmit\": {\n        \"title\": \"إرسال تلقائي\",\n        \"description\": \"إرسال مجموعة المفاتيح المحددة تلقائياً بعد إدراج النص. Cmd+Enter ينطبق على macOS، بينما يستخدم Windows/Linux مفتاح Super+Enter.\",\n        \"options\": {\n          \"off\": \"إيقاف\",\n          \"enter\": \"Enter\",\n          \"cmdEnter\": \"Cmd+Enter\",\n          \"superEnter\": \"Super+Enter\",\n          \"ctrlEnter\": \"Ctrl+Enter\"\n        }\n      },\n      \"translateToEnglish\": {\n        \"label\": \"الترجمة إلى الإنجليزية\",\n        \"description\": \".ترجمة الكلام من اللغات الأخرى تلقائياً إلى الإنجليزية أثناء التفريغ\",\n        \"descriptionUnsupported\": \".الترجمة غير مدعومة من قبل نموذج {{model}}\"\n      },\n      \"modelUnload\": {\n        \"title\": \"إلغاء تحميل النموذج\",\n        \"description\": \"تحرير ذاكرة GPU/CPU تلقائياً عندما لا يتم استخدام النموذج للوقت المحدد\",\n        \"options\": {\n          \"never\": \"أبداً\",\n          \"immediately\": \"فوراً\",\n          \"min2\": \"بعد دقيقتين\",\n          \"min5\": \"بعد 5 دقائق\",\n          \"min10\": \"بعد 10 دقائق\",\n          \"min15\": \"بعد 15 دقيقة\",\n          \"hour1\": \"بعد ساعة واحدة\",\n          \"sec15\": \"بعد 15 ثوانٍ (تصحيح أخطاء)\"\n        }\n      },\n      \"customWords\": {\n        \"title\": \"كلمات مخصصة\",\n        \"description\": \".أضف الكلمات التي غالباً ما يتم فهمها بشكل خاطئ أو كتابتها بشكل خاطئ أثناء التفريغ. سيقوم النظام تلقائياً بتصحيح الكلمات ذات الصوت المماثل لتطابق قائمتك\",\n        \"placeholder\": \"أضف كلمة\",\n        \"add\": \"إضافة\",\n        \"remove\": \"إزالة {{word}}\",\n        \"duplicate\": \"\\\"{{word}}\\\" موجود بالفعل\"\n      }\n    },\n    \"postProcessing\": {\n      \"title\": \"معالجة لاحقة\",\n      \"hotkey\": {\n        \"title\": \"مفتاح الاختصار\"\n      },\n      \"api\": {\n        \"title\": \"API (متوافق مع OpenAI)\",\n        \"provider\": {\n          \"title\": \"المزود\",\n          \"description\": \"اختر مزوداً متوافقاً مع OpenAI.\"\n        },\n        \"appleIntelligence\": {\n          \"title\": \"Apple Intelligence\",\n          \"description\": \"يعمل بالكامل على الجهاز. لا يلزم وجود مفتاح API أو وصول إلى الشبكة.\",\n          \"requirements\": \"يتطلب جهاز Mac بمعالج Apple Silicon يعمل بنظام macOS Tahoe (26.0) أو أحدث. يجب تمكين Apple Intelligence في إعدادات النظام.\",\n          \"unavailable\": \"Apple Intelligence غير متاح على هذا الجهاز. يتطلب جهاز Mac بمعالج Apple Silicon يعمل بنظام macOS Tahoe (26.0) أو أحدث مع تمكين Apple Intelligence في إعدادات النظام.\"\n        },\n        \"baseUrl\": {\n          \"title\": \"URL الأساسي\",\n          \"description\": \"URL الأساسي لـ API للمزود المختار. يمكن تعديل المزود المخصص فقط.\",\n          \"placeholder\": \"https://api.openai.com/v1\"\n        },\n        \"apiKey\": {\n          \"title\": \"مفتاح API\",\n          \"description\": \"مفتاح API للمزود المختار.\",\n          \"placeholder\": \"sk-...\"\n        },\n        \"model\": {\n          \"title\": \"النموذج\",\n          \"descriptionApple\": \"قدم حداً اختيارياً للرموز الرقمية أو احتفظ بالإعداد المسبق الافتراضي على الجهاز.\",\n          \"descriptionCustom\": \"قدم معرف النموذج المتوقع من قبل نقطة النهاية المخصصة الخاصة بك.\",\n          \"descriptionDefault\": \"اختر نموذجاً يعرضه المزود المختار.\",\n          \"placeholderApple\": \"Apple Intelligence\",\n          \"placeholderWithOptions\": \"ابحث عن نموذج أو اختره\",\n          \"placeholderNoOptions\": \"اكتب اسم النموذج\",\n          \"refreshModels\": \"تحديث النماذج\"\n        }\n      },\n      \"prompts\": {\n        \"title\": \"المطالبة\",\n        \"selectedPrompt\": {\n          \"title\": \"المطالبة المختارة\",\n          \"description\": \".اختر قالباً لتحسين التفريغ الصوتي أو أنشئ قالباً جديداً. استخدم ${output} داخل نص المطالبة للإشارة إلى النص الملتقط\"\n        },\n        \"noPrompts\": \"لا توجد مطالبات متاحة\",\n        \"selectPrompt\": \"اختر مطالبة\",\n        \"createNew\": \"إنشاء مطالبة جديدة\",\n        \"promptLabel\": \"تسمية المطالبة\",\n        \"promptLabelPlaceholder\": \"أدخل اسم المطالبة\",\n        \"promptInstructions\": \"تعليمات المطالبة\",\n        \"promptInstructionsPlaceholder\": \".اكتب التعليمات المراد تشغيلها بعد التفريغ الصوتي. مثال: قم بتحسين القواعد والوضوح للنص التالي: ${output}\",\n        \"promptTip\": \"تلميح: استخدم <code>${output}</code> لإدراج النص المفرغ في مطالبتك.\",\n        \"updatePrompt\": \"تحديث المطالبة\",\n        \"deletePrompt\": \"حذف المطالبة\",\n        \"createPrompt\": \"إنشاء مطالبة\",\n        \"cancel\": \"إلغاء\",\n        \"selectToEdit\": \".اختر مطالبة أعلاه لعرض وتعديل تفاصيلها\",\n        \"createFirst\": \".انقر على 'إنشاء مطالبة جديدة' أعلاه لإنشاء أول مطالبة معالجة لاحقة لك\"\n      }\n    },\n    \"history\": {\n      \"title\": \"السجل\",\n      \"openFolder\": \"فتح مجلد التسجيلات\",\n      \"loading\": \"...جاري تحميل السجل\",\n      \"empty\": \"!لا يوجد تفريغ صوتي بعد. ابدأ التسجيل لبناء سجلك\",\n      \"copyToClipboard\": \"نسخ التفريغ إلى الحافظة\",\n      \"save\": \"حفظ التفريغ\",\n      \"unsave\": \"إزالة من المحفوظات\",\n      \"delete\": \"حذف الإدخال\",\n      \"deleteError\": \".فشل حذف الإدخال. يرجى المحاولة مرة أخرى\"\n    },\n    \"debug\": {\n      \"title\": \"تصحيح الأخطاء\",\n      \"logDirectory\": {\n        \"title\": \"مجلد السجلات\",\n        \"description\": \"الموقع الذي يتم فيه تخزين ملفات السجل\"\n      },\n      \"logLevel\": {\n        \"title\": \"مستوى السجل\",\n        \"description\": \"ضبط درجة تفصيل السجلات\"\n      },\n      \"updateChecks\": {\n        \"label\": \"التحقق من وجود تحديثات\",\n        \"description\": \"التحقق تلقائياً من وجود إصدارات جديدة من Handy\"\n      },\n      \"soundTheme\": {\n        \"label\": \"سمة الصوت\",\n        \"description\": \"اختر سمة صوت لتنبيهات بدء وتوقف التسجيل\"\n      },\n      \"wordCorrectionThreshold\": {\n        \"title\": \"عتبة تصحيح الكلمات\",\n        \"description\": \"حساسية تصحيح الكلمات المخصصة\"\n      },\n      \"historyLimit\": {\n        \"title\": \"حد السجل\",\n        \"description\": \"الحد الأقصى لعدد إدخالات السجل المراد الاحتفاظ بها\",\n        \"entries\": \"إدخالات\"\n      },\n      \"recordingRetention\": {\n        \"title\": \"حذف التسجيلات تلقائياً\",\n        \"description\": \"حذف التسجيلات القديمة تلقائياً لتوفير المساحة\",\n        \"never\": \"أبداً\",\n        \"preserveLimit\": \"الاحتفاظ بآخر {{count}}\",\n        \"days3\": \"بعد 3 أيام\",\n        \"weeks2\": \"بعد أسبوعين\",\n        \"months3\": \"بعد 3 أشهر\",\n        \"placeholder\": \"اختر فترة الاحتفاظ...\"\n      },\n      \"alwaysOnMicrophone\": {\n        \"label\": \"ميكروفون يعمل دائماً\",\n        \"description\": \"إبقاء الميكروفون نشطاً لاستجابة أسرع\"\n      },\n      \"clamshellMicrophone\": {\n        \"title\": \"ميكروفون وضع الإغلاق\",\n        \"description\": \"الميكروفون المراد استخدامه عندما يكون غطاء المحمول مغلقاً\"\n      },\n      \"postProcessingToggle\": {\n        \"label\": \"معالجة لاحقة\",\n        \"description\": \"تمكين تحسين النص المدعوم بالذكاء الاصطناعي بعد التفريغ الصوتي\"\n      },\n      \"muteWhileRecording\": {\n        \"label\": \"كتم الصوت أثناء التسجيل\",\n        \"description\": \"كتم صوت النظام أثناء التسجيل\"\n      },\n      \"appendTrailingSpace\": {\n        \"label\": \"إضافة مسافة تابعة\",\n        \"description\": \"إضافة مسافة بعد التفريغ الملصق\"\n      },\n      \"keyboardImplementation\": {\n        \"title\": \"تنفيذ لوحة المفاتيح\",\n        \"description\": \".اختر الواجهة الخلفية لاختصارات لوحة المفاتيح\",\n        \"bindingsReset\": \"كانت اختصارات لوحة المفاتيح غير متوافقة وتمت إعادة تعيينها إلى القيم الافتراضية\"\n      },\n      \"paths\": {\n        \"appData\": \"بيانات التطبيق:\",\n        \"models\": \"النماذج:\",\n        \"settings\": \"الإعدادات:\"\n      },\n      \"pasteDelay\": {\n        \"title\": \"تأخير اللصق\",\n        \"description\": \"التأخير قبل إرسال ضغطة مفتاح اللصق (بالمللي ثانية). قم بزيادتها إذا تم لصق نص خاطئ.\"\n      },\n      \"recordingBuffer\": {\n        \"title\": \"مخزن التسجيل الإضافي\",\n        \"description\": \"وقت إضافي (بالمللي ثانية) للاستمرار في التسجيل بعد تحرير المفتاح، لالتقاط الصوت المتبقي. 0 = لا مخزن إضافي.\"\n      }\n    },\n    \"about\": {\n      \"title\": \"حول\",\n      \"version\": {\n        \"title\": \"الإصدار\",\n        \"description\": \"الإصدار الحالي من Handy\"\n      },\n      \"appDataDirectory\": {\n        \"title\": \"مجلد بيانات التطبيق\",\n        \"description\": \"الموقع الذي يخزن فيه Handy بياناته\"\n      },\n      \"sourceCode\": {\n        \"title\": \"كود المصدر\",\n        \"description\": \"عرض كود المصدر والمساهمة\",\n        \"button\": \"عرض على GitHub\"\n      },\n      \"supportDevelopment\": {\n        \"title\": \"دعم التطوير\",\n        \"description\": \"ساعدنا في مواصلة بناء Handy\",\n        \"button\": \"تبرع\"\n      },\n      \"acknowledgments\": {\n        \"title\": \"شكر وتقدير\",\n        \"whisper\": {\n          \"title\": \"Whisper.cpp\",\n          \"description\": \"استنتاج عالي الأداء لنموذج التعرف التلقائي على الكلام Whisper من OpenAI\",\n          \"details\": \".يستخدم Handy برنامج Whisper.cpp لمعالجة سريعة ومحلية لتحويل الكلام إلى نص. شكراً للعمل الرائع الذي قام به Georgi Gerganov والمساهمون\"\n        }\n      }\n    },\n    \"modelSettings\": {\n      \"title\": \"إعدادات النموذج\",\n      \"noSettingsNeeded\": \"لا توجد إعدادات مطلوبة لهذا النموذج\"\n    },\n    \"models\": {\n      \"title\": \"نماذج النسخ\",\n      \"description\": \"اختر نموذج نسخ أو قم بتنزيل نماذج إضافية. تقدم النماذج المختلفة مستويات متفاوتة من الدقة والسرعة.\",\n      \"yourModels\": \"النماذج المحمّلة\",\n      \"availableModels\": \"متاح للتنزيل\",\n      \"downloaded\": \"تم التنزيل\",\n      \"available\": \"متاح للتنزيل\",\n      \"deleteConfirm\": \"هل أنت متأكد أنك تريد حذف {{modelName}}؟ ستحتاج إلى تنزيله مرة أخرى لاستخدامه.\",\n      \"deleteActiveConfirm\": \"{{modelName}} هو النموذج النشط حاليًا. حذفه سيوقف النسخ حتى تختار نموذجًا جديدًا. هل أنت متأكد؟\",\n      \"deleteTitle\": \"حذف النموذج\",\n      \"filters\": {\n        \"all\": \"الكل\",\n        \"multiLanguage\": \"متعدد اللغات\",\n        \"translation\": \"ترجمة\",\n        \"allLanguages\": \"جميع اللغات\"\n      },\n      \"noModelsMatch\": \"لا توجد نماذج مطابقة لهذا الفلتر.\"\n    }\n  },\n  \"footer\": {\n    \"downloadingModel\": \"...جاري تنزيل {{model}}\",\n    \"checkingUpdates\": \"...جاري التحقق من وجود تحديثات\",\n    \"updateAvailable\": \"تحديث متاح: {{version}}\",\n    \"updateAvailableShort\": \"تحديث متاح\",\n    \"upToDate\": \"محدث\",\n    \"downloadUpdate\": \"تنزيل التحديث\",\n    \"restart\": \"إعادة تشغيل\",\n    \"updateCheckingDisabled\": \"تم تعطيل التحقق من التحديث\",\n    \"downloading\": \"...جاري التنزيل {{progress}}%\",\n    \"installing\": \"...جاري التثبيت\",\n    \"preparing\": \"...جاري التحضير\",\n    \"checkForUpdates\": \"التحقق من وجود تحديثات\"\n  },\n  \"common\": {\n    \"loading\": \"...جاري التحميل\",\n    \"save\": \"حفظ\",\n    \"cancel\": \"إلغاء\",\n    \"reset\": \"إعادة تعيين\",\n    \"add\": \"إضافة\",\n    \"remove\": \"إزالة\",\n    \"delete\": \"حذف\",\n    \"edit\": \"تعديل\",\n    \"create\": \"إنشاء\",\n    \"update\": \"تحديث\",\n    \"close\": \"إغلاق\",\n    \"open\": \"فتح\",\n    \"default\": \"افتراضي\",\n    \"enabled\": \"ممكن\",\n    \"disabled\": \"معطل\",\n    \"on\": \"تشغيل\",\n    \"off\": \"إيقاف\",\n    \"yes\": \"نعم\",\n    \"no\": \"لا\",\n    \"noOptionsFound\": \"لم يتم العثور على خيارات\"\n  },\n  \"accessibility\": {\n    \"permissionsRequired\": \"أذونات إمكانية الوصول مطلوبة\",\n    \"permissionsDescription\": \".يحتاج Handy إلى أذونات إمكانية الوصول لكتابة النص المفرغ\",\n    \"openSettings\": \"فتح إعدادات النظام\",\n    \"dismiss\": \"تجاهل\"\n  },\n  \"errors\": {\n    \"loadDirectory\": \"خطأ في تحميل المجلد: {{error}}\",\n    \"micPermissionDeniedTitle\": \"تم رفض الوصول إلى الميكروفون\",\n    \"micPermissionDenied\": {\n      \"generic\": \"تم رفض الوصول إلى الميكروفون من قبل نظام التشغيل. يرجى منح إذن الميكروفون في إعدادات النظام.\",\n      \"windows\": \"قم بتمكين الوصول إلى الميكروفون في الإعدادات ← الخصوصية والأمان ← الميكروفون (بما في ذلك وصول تطبيقات سطح المكتب).\",\n      \"macos\": \"امنح الوصول إلى الميكروفون في إعدادات النظام ← الخصوصية والأمان ← الميكروفون.\",\n      \"linux\": \"امنح الوصول إلى الميكروفون في إعدادات الصوت أو الخصوصية في نظامك.\"\n    },\n    \"recordingFailed\": \"فشل في بدء التسجيل: {{error}}\",\n    \"modelLoadFailed\": \"فشل في تحميل النموذج: {{model}}\",\n    \"modelLoadFailedUnknown\": \"نموذج غير معروف\"\n  },\n  \"appLanguage\": {\n    \"title\": \"لغة التطبيق\",\n    \"description\": \"تغيير لغة واجهة Handy\"\n  },\n  \"overlay\": {\n    \"transcribing\": \"...جاري التفريغ\",\n    \"processing\": \"...جاري المعالجة\"\n  }\n}\n"
  },
  {
    "path": "src/i18n/locales/cs/translation.json",
    "content": "{\n  \"tray\": {\n    \"settings\": \"Nastavení...\",\n    \"checkUpdates\": \"Zkontrolovat aktualizace...\",\n    \"copyLastTranscript\": \"Zkopírovat poslední přepis\",\n    \"unloadModel\": \"Uvolnit model\",\n    \"model\": \"Model\",\n    \"quit\": \"Ukončit\",\n    \"cancel\": \"Zrušit\"\n  },\n  \"sidebar\": {\n    \"general\": \"Obecné\",\n    \"models\": \"Modely\",\n    \"advanced\": \"Pokročilé\",\n    \"postProcessing\": \"Následné zpracování\",\n    \"history\": \"Historie\",\n    \"debug\": \"Ladění\",\n    \"about\": \"O aplikaci\"\n  },\n  \"onboarding\": {\n    \"subtitle\": \"Pro začátek vyberte model pro přepis\",\n    \"recommended\": \"Doporučeno\",\n    \"download\": \"Stáhnout\",\n    \"downloading\": \"Stahování...\",\n    \"customModelDescription\": \"Oficiálně nepodporováno\",\n    \"downloadFailed\": \"Stahování se nezdařilo. Zkuste to prosím znovu.\",\n    \"modelCard\": {\n      \"accuracy\": \"přesnost\",\n      \"speed\": \"rychlost\"\n    },\n    \"models\": {\n      \"small\": {\n        \"name\": \"Whisper Small\",\n        \"description\": \"Rychlý a poměrně přesný.\"\n      },\n      \"medium\": {\n        \"name\": \"Whisper Medium\",\n        \"description\": \"Dobrá přesnost, střední rychlost\"\n      },\n      \"turbo\": {\n        \"name\": \"Whisper Turbo\",\n        \"description\": \"Vyvážená přesnost a rychlost.\"\n      },\n      \"large\": {\n        \"name\": \"Whisper Large\",\n        \"description\": \"Dobrá přesnost, ale pomalý.\"\n      },\n      \"parakeet-tdt-0.6b-v2\": {\n        \"name\": \"Parakeet V2\",\n        \"description\": \"Pouze angličtina. Nejlepší model pro anglicky mluvící.\"\n      },\n      \"parakeet-tdt-0.6b-v3\": {\n        \"name\": \"Parakeet V3\",\n        \"description\": \"Rychlý a přesný\"\n      },\n      \"moonshine-base\": {\n        \"name\": \"Moonshine Base\",\n        \"description\": \"Velmi rychlý, pouze angličtina. Dobře zvládá přízvuky.\"\n      },\n      \"moonshine-tiny-streaming-en\": {\n        \"name\": \"Moonshine V2 Tiny\",\n        \"description\": \"Ultrarychlý, pouze angličtina\"\n      },\n      \"moonshine-small-streaming-en\": {\n        \"name\": \"Moonshine V2 Small\",\n        \"description\": \"Rychlý, pouze angličtina. Dobrý poměr rychlosti a přesnosti.\"\n      },\n      \"moonshine-medium-streaming-en\": {\n        \"name\": \"Moonshine V2 Medium\",\n        \"description\": \"Pouze angličtina. Vysoká kvalita.\"\n      },\n      \"breeze-asr\": {\n        \"name\": \"Breeze ASR\",\n        \"description\": \"Optimalizováno pro tchajwanskou mandarínštinu. Podpora přepínání jazyků.\"\n      },\n      \"sense-voice-int8\": {\n        \"name\": \"SenseVoice\",\n        \"description\": \"Velmi rychlý. Čínština, angličtina, japonština, korejština, kantonština.\"\n      },\n      \"gigaam-v3-e2e-ctc\": {\n        \"name\": \"GigaAM v3\",\n        \"description\": \"Rozpoznávání ruské řeči. Rychlé a přesné.\"\n      },\n      \"canary-180m-flash\": {\n        \"name\": \"Canary 180M Flash\",\n        \"description\": \"Velmi rychlý. Angličtina, němčina, španělština, francouzština. Podporuje překlad.\"\n      },\n      \"canary-1b-v2\": {\n        \"name\": \"Canary 1B v2\",\n        \"description\": \"Přesný vícejazyčný. 25 evropských jazyků. Podporuje překlad.\"\n      }\n    },\n    \"errors\": {\n      \"loadModels\": \"Nepodařilo se načíst dostupné modely\",\n      \"downloadModel\": \"Nepodařilo se stáhnout model: {{error}}\"\n    },\n    \"permissions\": {\n      \"title\": \"Vyžadována oprávnění\",\n      \"description\": \"Handy potřebuje několik oprávnění pro správné fungování.\",\n      \"microphone\": {\n        \"title\": \"Přístup k mikrofonu\",\n        \"description\": \"Potřebný pro zachycení vašeho hlasu k přepisu.\"\n      },\n      \"accessibility\": {\n        \"title\": \"Přístup k usnadnění\",\n        \"description\": \"Potřebný pro psaní přepsaného textu do vašich aplikací.\"\n      },\n      \"grant\": \"Udělit oprávnění\",\n      \"granted\": \"Uděleno\",\n      \"waiting\": \"Čekání...\",\n      \"allGranted\": \"Vše připraveno!\",\n      \"errors\": {\n        \"checkFailed\": \"Nepodařilo se zkontrolovat oprávnění. Zkuste to prosím znovu.\",\n        \"requestFailed\": \"Nepodařilo se požádat o oprávnění. Zkuste to prosím znovu.\"\n      }\n    }\n  },\n  \"modelSelector\": {\n    \"custom\": \"Vlastní\",\n    \"active\": \"Aktivní\",\n    \"noModelsAvailable\": \"Nejsou dostupné žádné modely\",\n    \"extracting\": \"Rozbaluji {{modelName}}...\",\n    \"extractingMultiple\": \"Rozbaluji {{count}} modelů...\",\n    \"extractingGeneric\": \"Rozbalování...\",\n    \"downloading\": \"Stahování {{percentage}}%\",\n    \"downloadingMultiple\": \"Stahování {{count}} modelů...\",\n    \"modelReady\": \"Model připraven\",\n    \"loading\": \"Načítám {{modelName}}...\",\n    \"loadingGeneric\": \"Načítání...\",\n    \"modelError\": \"Chyba modelu\",\n    \"modelUnloaded\": \"Model uvolněn\",\n    \"noModelDownloadRequired\": \"Žádný model - je nutné stáhnout\",\n    \"deleteModel\": \"Smazat {{modelName}}\",\n    \"switching\": \"Přepínání...\",\n    \"downloadSpeed\": \"{{speed}} MB/s\",\n    \"capabilities\": {\n      \"languageSelection\": \"Podporuje více vstupních jazyků\",\n      \"multiLanguage\": \"Vícejazyčný\",\n      \"translation\": \"Umí překládat do angličtiny\",\n      \"translate\": \"Přeložit do angličtiny\",\n      \"singleLanguage\": \"Podporuje pouze tento jazyk\",\n      \"languageOnly\": \"Pouze {{language}}\"\n    },\n    \"cancel\": \"Zrušit\",\n    \"cancelDownload\": \"Zrušit stahování\"\n  },\n  \"settings\": {\n    \"modelSettings\": {\n      \"title\": \"Nastavení {{model}}\",\n      \"noSettingsNeeded\": \"Tento model funguje automaticky bez nutnosti konfigurace.\"\n    },\n    \"models\": {\n      \"title\": \"Modely přepisu\",\n      \"description\": \"Vyberte model přepisu nebo stáhněte další modely. Různé modely nabízejí různé úrovně přesnosti a rychlosti.\",\n      \"downloaded\": \"Staženo\",\n      \"available\": \"K dispozici ke stažení\",\n      \"deleteConfirm\": \"Opravdu chcete smazat {{modelName}}? Pro opětovné použití jej budete muset znovu stáhnout.\",\n      \"deleteActiveConfirm\": \"{{modelName}} je váš aktivní model. Jeho smazáním se zastaví přepisy, dokud nevyberete nový model. Opravdu chcete pokračovat?\",\n      \"deleteTitle\": \"Smazat model\",\n      \"filters\": {\n        \"all\": \"Vše\",\n        \"multiLanguage\": \"Vícejazyčný\",\n        \"translation\": \"Překlad\",\n        \"allLanguages\": \"Všechny jazyky\"\n      },\n      \"noModelsMatch\": \"Tomuto filtru neodpovídají žádné modely.\",\n      \"yourModels\": \"Stažené modely\",\n      \"availableModels\": \"Dostupné ke stažení\"\n    },\n    \"general\": {\n      \"title\": \"Obecné\",\n      \"shortcut\": {\n        \"title\": \"Zkratky Handy\",\n        \"description\": \"Nastavte klávesové zkratky pro spuštění nahrávání řeči na text\",\n        \"loading\": \"Načítám zkratky...\",\n        \"none\": \"Žádné zkratky nejsou nastavené\",\n        \"notFound\": \"Zkratka nenalezena\",\n        \"pressKeys\": \"Stiskněte klávesy...\",\n        \"bindings\": {\n          \"transcribe\": {\n            \"name\": \"Zkratka přepisu\",\n            \"description\": \"Klávesová zkratka pro nahrávání a přepis vašeho hlasu.\"\n          },\n          \"cancel\": {\n            \"name\": \"Zkratka zrušení\",\n            \"description\": \"Klávesová zkratka pro zrušení aktuálního nahrávání.\"\n          },\n          \"transcribe_with_post_process\": {\n            \"name\": \"Klávesa pro následné zpracování\",\n            \"description\": \"Volitelné: Vyhrazená klávesová zkratka, která vždy použije AI následné zpracování na váš přepis.\"\n          }\n        },\n        \"errors\": {\n          \"restore\": \"Nepodařilo se obnovit původní zkratku\",\n          \"set\": \"Nepodařilo se nastavit zkratku: {{error}}\",\n          \"reset\": \"Nepodařilo se vrátit zkratku na původní hodnotu\"\n        }\n      },\n      \"language\": {\n        \"title\": \"Jazyk\",\n        \"description\": \"Vyberte jazyk rozpoznávání řeči. Auto jazyk určí automaticky, zatímco výběr konkrétního jazyka může zlepšit přesnost.\",\n        \"descriptionUnsupported\": \"Model Parakeet rozpozná jazyk automaticky. Ruční výběr není potřeba.\",\n        \"searchPlaceholder\": \"Hledat jazyky...\",\n        \"noResults\": \"Žádné jazyky nenalezeny\",\n        \"auto\": \"Auto\"\n      },\n      \"pushToTalk\": {\n        \"label\": \"Stisk a mluv\",\n        \"description\": \"Podržte pro nahrávání, uvolněte pro zastavení\"\n      }\n    },\n    \"sound\": {\n      \"title\": \"Zvuk\",\n      \"microphone\": {\n        \"title\": \"Mikrofon\",\n        \"description\": \"Vyberte preferované zařízení mikrofonu\",\n        \"placeholder\": \"Vyberte mikrofon...\",\n        \"loading\": \"Načítání...\"\n      },\n      \"audioFeedback\": {\n        \"label\": \"Zvuková odezva\",\n        \"description\": \"Přehrát zvuk při zahájení a ukončení nahrávání\"\n      },\n      \"outputDevice\": {\n        \"title\": \"Výstupní zařízení\",\n        \"description\": \"Vyberte preferované výstupní zařízení pro zvuky odezvy\",\n        \"placeholder\": \"Vyberte výstupní zařízení...\",\n        \"loading\": \"Načítání...\"\n      },\n      \"volume\": {\n        \"title\": \"Hlasitost\",\n        \"description\": \"Upravte hlasitost zvukové odezvy\"\n      }\n    },\n    \"advanced\": {\n      \"title\": \"Pokročilé\",\n      \"groups\": {\n        \"app\": \"Aplikace\",\n        \"output\": \"Výstup\",\n        \"transcription\": \"Přepis\",\n        \"history\": \"Historie\",\n        \"experimental\": \"Experimentální\"\n      },\n      \"experimentalToggle\": {\n        \"label\": \"Experimentální funkce\",\n        \"description\": \"Povolit experimentální funkce, které jsou stále ve vývoji.\"\n      },\n      \"lazyStreamClose\": {\n        \"label\": \"Ponechat mikrofon zapnutý mezi přepisy\",\n        \"description\": \"Ponechá stream mikrofonu otevřený po dobu 30 sekund po zastavení nahrávání, čímž se sníží latence při opakovaném přepisu. Může zhoršit kvalitu zvuku Bluetooth.\"\n      },\n      \"acceleration\": {\n        \"whisper\": {\n          \"title\": \"Akcelerace Whisper\",\n          \"description\": \"Hardwarová akcelerace pro modely Whisper. Automatický režim používá GPU, pokud je k dispozici (Metal na macOS, Vulkan na Windows/Linux).\"\n        },\n        \"ort\": {\n          \"title\": \"Akcelerace ONNX\",\n          \"description\": \"Hardwarová akcelerace pro modely ONNX (Parakeet, Canary, Moonshine atd.). DirectML na Windows je experimentální. Modely nemusí správně přepisovat.\"\n        }\n      },\n      \"startHidden\": {\n        \"label\": \"Spouštět skrytě\",\n        \"description\": \"Spustit do systémové lišty bez otevření okna.\"\n      },\n      \"autostart\": {\n        \"label\": \"Spouštět při startu\",\n        \"description\": \"Automaticky spustit Handy po přihlášení do počítače.\"\n      },\n      \"showTrayIcon\": {\n        \"label\": \"Zobrazit ikonu v systémové liště\",\n        \"description\": \"Zobrazit ikonu Handy v systémové liště.\"\n      },\n      \"overlay\": {\n        \"title\": \"Pozice překryvu\",\n        \"description\": \"Zobrazovat vizuální překryv během nahrávání a přepisu. Na Linuxu je doporučeno 'Žádné'.\",\n        \"options\": {\n          \"none\": \"Žádné\",\n          \"bottom\": \"Dole\",\n          \"top\": \"Nahoře\"\n        }\n      },\n      \"pasteMethod\": {\n        \"title\": \"Způsob vložení\",\n        \"description\": \"Vyberte, jak se text vkládá. Přímé: simuluje psaní přes systémový vstup. Žádné: přeskočí vložení a aktualizuje pouze historii/schránku.\",\n        \"options\": {\n          \"clipboard\": \"Schránka ({{modifier}}+V)\",\n          \"clipboardCtrlShiftV\": \"Schránka (Ctrl+Shift+V)\",\n          \"clipboardShiftInsert\": \"Schránka (Shift+Insert)\",\n          \"direct\": \"Přímé\",\n          \"none\": \"Žádné\",\n          \"externalScript\": \"Externí skript\"\n        },\n        \"externalScriptPlaceholder\": \"/cesta/k/vasemu/skriptu.sh\"\n      },\n      \"typingTool\": {\n        \"title\": \"Nástroj pro psaní\",\n        \"description\": \"Vyberte, který linuxový nástroj pro psaní použít pro metodu přímého vložení. Auto automaticky zjistí a použije nejlepší dostupný nástroj pro váš systém.\",\n        \"options\": {\n          \"auto\": \"Auto (Doporučeno)\"\n        }\n      },\n      \"clipboardHandling\": {\n        \"title\": \"Práce se schránkou\",\n        \"description\": \"'Neměnit schránku' zachová po přepisu aktuální obsah schránky. 'Kopírovat do schránky' ponechá po vložení výsledek přepisu ve schránce.\",\n        \"options\": {\n          \"dontModify\": \"Neměnit schránku\",\n          \"copyToClipboard\": \"Kopírovat do schránky\"\n        }\n      },\n      \"autoSubmit\": {\n        \"title\": \"Automatické odeslání\",\n        \"description\": \"Automaticky odešle vybranou kombinaci kláves po vložení textu. Cmd+Enter platí pro macOS, zatímco Windows/Linux používají Super+Enter.\",\n        \"options\": {\n          \"off\": \"Vypnuto\",\n          \"enter\": \"Enter\",\n          \"cmdEnter\": \"Cmd+Enter\",\n          \"superEnter\": \"Super+Enter\",\n          \"ctrlEnter\": \"Ctrl+Enter\"\n        }\n      },\n      \"translateToEnglish\": {\n        \"label\": \"Překládat do angličtiny\",\n        \"description\": \"Během přepisu automaticky překládat řeč z jiných jazyků do angličtiny.\",\n        \"descriptionUnsupported\": \"Překlad není podporován modelem {{model}}.\"\n      },\n      \"modelUnload\": {\n        \"title\": \"Uvolnění modelu\",\n        \"description\": \"Automaticky uvolnit paměť GPU/CPU, když model nebyl použit po zadanou dobu\",\n        \"options\": {\n          \"never\": \"Nikdy\",\n          \"immediately\": \"Okamžitě\",\n          \"min2\": \"Po 2 minutách\",\n          \"min5\": \"Po 5 minutách\",\n          \"min10\": \"Po 10 minutách\",\n          \"min15\": \"Po 15 minutách\",\n          \"hour1\": \"Po 1 hodině\",\n          \"sec15\": \"Po 15 sekundách (Debug)\"\n        }\n      },\n      \"customWords\": {\n        \"title\": \"Vlastní slova\",\n        \"description\": \"Přidejte slova, která jsou při přepisu často špatně rozpoznána nebo napsána. Systém automaticky opraví podobně znějící slova podle vašeho seznamu.\",\n        \"placeholder\": \"Přidat slovo\",\n        \"add\": \"Přidat\",\n        \"remove\": \"Odebrat {{word}}\",\n        \"duplicate\": \"\\\"{{word}}\\\" již existuje\"\n      }\n    },\n    \"postProcessing\": {\n      \"title\": \"Následné zpracování\",\n      \"hotkey\": {\n        \"title\": \"Klávesová zkratka\"\n      },\n      \"api\": {\n        \"title\": \"API (kompatibilní s OpenAI)\",\n        \"provider\": {\n          \"title\": \"Poskytovatel\",\n          \"description\": \"Vyberte poskytovatele kompatibilního s OpenAI.\"\n        },\n        \"appleIntelligence\": {\n          \"title\": \"Apple Intelligence\",\n          \"description\": \"Běží plně na zařízení. Není potřeba API klíč ani síťové připojení.\",\n          \"requirements\": \"Vyžaduje Mac s Apple Silicon a macOS Tahoe (26.0) nebo novější. Apple Intelligence musí být povoleno v Nastavení systému.\",\n          \"unavailable\": \"Apple Intelligence není na tomto zařízení k dispozici. Vyžaduje Mac s Apple Silicon a macOS Tahoe (26.0) nebo novější a povolené Apple Intelligence v Nastavení systému.\"\n        },\n        \"baseUrl\": {\n          \"title\": \"Základní URL\",\n          \"description\": \"Základní URL API pro vybraného poskytovatele. Upravitelné je pouze u vlastního poskytovatele.\",\n          \"placeholder\": \"https://api.openai.com/v1\"\n        },\n        \"apiKey\": {\n          \"title\": \"API klíč\",\n          \"description\": \"API klíč pro vybraného poskytovatele.\",\n          \"placeholder\": \"sk-...\"\n        },\n        \"model\": {\n          \"title\": \"Model\",\n          \"descriptionApple\": \"Zadejte volitelný číselný limit tokenů nebo ponechte výchozí předvolbu na zařízení.\",\n          \"descriptionCustom\": \"Zadejte identifikátor modelu očekávaný vaším vlastním endpointem.\",\n          \"descriptionDefault\": \"Vyberte model dostupný u vybraného poskytovatele.\",\n          \"placeholderApple\": \"Apple Intelligence\",\n          \"placeholderWithOptions\": \"Vyhledejte nebo vyberte model\",\n          \"placeholderNoOptions\": \"Zadejte název modelu\",\n          \"refreshModels\": \"Obnovit modely\"\n        }\n      },\n      \"prompts\": {\n        \"title\": \"Prompt\",\n        \"selectedPrompt\": {\n          \"title\": \"Vybraný prompt\",\n          \"description\": \"Vyberte šablonu pro zpřesnění přepisu nebo vytvořte novou. V textu promptu použijte ${output} pro vložení zachyceného přepisu.\"\n        },\n        \"noPrompts\": \"Nejsou k dispozici žádné prompty\",\n        \"selectPrompt\": \"Vyberte prompt\",\n        \"createNew\": \"Vytvořit nový prompt\",\n        \"promptLabel\": \"Název promptu\",\n        \"promptLabelPlaceholder\": \"Zadejte název promptu\",\n        \"promptInstructions\": \"Instrukce promptu\",\n        \"promptInstructionsPlaceholder\": \"Napište instrukce, které se mají spustit po přepisu. Příklad: Zlepšete gramatiku a srozumitelnost následujícího textu: ${output}\",\n        \"promptTip\": \"Tip: Použijte <code>${output}</code> pro vložení přepsaného textu do promptu.\",\n        \"updatePrompt\": \"Aktualizovat prompt\",\n        \"deletePrompt\": \"Smazat prompt\",\n        \"createPrompt\": \"Vytvořit prompt\",\n        \"cancel\": \"Zrušit\",\n        \"selectToEdit\": \"Vyberte výše prompt, abyste zobrazili a upravili jeho podrobnosti.\",\n        \"createFirst\": \"Klikněte nahoře na 'Vytvořit nový prompt' a vytvořte svůj první prompt pro následné zpracování.\"\n      }\n    },\n    \"history\": {\n      \"title\": \"Historie\",\n      \"openFolder\": \"Otevřít složku nahrávek\",\n      \"loading\": \"Načítám historii...\",\n      \"empty\": \"Zatím žádné přepisy. Začněte nahrávat a vytvořte si historii!\",\n      \"copyToClipboard\": \"Kopírovat přepis do schránky\",\n      \"save\": \"Uložit přepis\",\n      \"unsave\": \"Odebrat z uložených\",\n      \"delete\": \"Smazat záznam\",\n      \"deleteError\": \"Nepodařilo se smazat záznam. Zkuste to prosím znovu.\"\n    },\n    \"debug\": {\n      \"title\": \"Ladění\",\n      \"logDirectory\": {\n        \"title\": \"Složka protokolů\",\n        \"description\": \"Umístění, kde jsou uloženy soubory protokolu\"\n      },\n      \"logLevel\": {\n        \"title\": \"Úroveň logování\",\n        \"description\": \"Nastavte úroveň podrobnosti logování\"\n      },\n      \"updateChecks\": {\n        \"label\": \"Kontrolovat aktualizace\",\n        \"description\": \"Automaticky kontrolovat nové verze Handy\"\n      },\n      \"soundTheme\": {\n        \"label\": \"Zvukový motiv\",\n        \"description\": \"Vyberte zvukový motiv pro odezvu při startu a ukončení nahrávání\"\n      },\n      \"wordCorrectionThreshold\": {\n        \"title\": \"Práh korekce slov\",\n        \"description\": \"Citlivost pro opravy vlastních slov\"\n      },\n      \"historyLimit\": {\n        \"title\": \"Limit historie\",\n        \"description\": \"Maximální počet záznamů historie k uchování\",\n        \"entries\": \"záznamů\"\n      },\n      \"recordingRetention\": {\n        \"title\": \"Automatické mazání nahrávek\",\n        \"description\": \"Automaticky mazat staré nahrávky pro úsporu místa\",\n        \"never\": \"Nikdy\",\n        \"preserveLimit\": \"Ponechat posledních {{count}}\",\n        \"days3\": \"Po 3 dnech\",\n        \"weeks2\": \"Po 2 týdnech\",\n        \"months3\": \"Po 3 měsících\",\n        \"placeholder\": \"Vyberte dobu uchování...\"\n      },\n      \"alwaysOnMicrophone\": {\n        \"label\": \"Vždy zapnutý mikrofon\",\n        \"description\": \"Udržovat mikrofon aktivní pro rychlejší odezvu\"\n      },\n      \"clamshellMicrophone\": {\n        \"title\": \"Mikrofon při zavřeném víku\",\n        \"description\": \"Mikrofon, který se použije při zavřeném víku notebooku\"\n      },\n      \"postProcessingToggle\": {\n        \"label\": \"Následné zpracování\",\n        \"description\": \"Povolit AI vylepšení textu po přepisu\"\n      },\n      \"muteWhileRecording\": {\n        \"label\": \"Ztlumit při nahrávání\",\n        \"description\": \"Ztlumit systémový zvuk během nahrávání\"\n      },\n      \"appendTrailingSpace\": {\n        \"label\": \"Přidat koncovou mezeru\",\n        \"description\": \"Přidat mezeru po vloženém přepisu\"\n      },\n      \"keyboardImplementation\": {\n        \"title\": \"Implementace klávesnice\",\n        \"description\": \"Zvolte backend pro klávesové zkratky.\",\n        \"bindingsReset\": \"Klávesové zkratky byly nekompatibilní a byly obnoveny na výchozí hodnoty\"\n      },\n      \"paths\": {\n        \"appData\": \"Data aplikace:\",\n        \"models\": \"Modely:\",\n        \"settings\": \"Nastavení:\"\n      },\n      \"pasteDelay\": {\n        \"title\": \"Zpoždění vložení\",\n        \"description\": \"Zpoždění před odesláním klávesy pro vložení (v milisekundách). Zvyšte, pokud se vkládá špatný text.\"\n      },\n      \"recordingBuffer\": {\n        \"title\": \"Extra vyrovnávací paměť nahrávání\",\n        \"description\": \"Extra čas (v milisekundách) pro pokračování nahrávání po uvolnění klávesy, pro zachycení zbývajícího zvuku. 0 = žádná extra vyrovnávací paměť.\"\n      }\n    },\n    \"about\": {\n      \"title\": \"O aplikaci\",\n      \"version\": {\n        \"title\": \"Verze\",\n        \"description\": \"Aktuální verze Handy\"\n      },\n      \"appDataDirectory\": {\n        \"title\": \"Adresář dat aplikace\",\n        \"description\": \"Umístění, kde Handy ukládá data\"\n      },\n      \"sourceCode\": {\n        \"title\": \"Zdrojový kód\",\n        \"description\": \"Zobrazit zdrojový kód a přispět\",\n        \"button\": \"Zobrazit na GitHubu\"\n      },\n      \"supportDevelopment\": {\n        \"title\": \"Podpořte vývoj\",\n        \"description\": \"Pomozte nám pokračovat ve vývoji Handy\",\n        \"button\": \"Přispět\"\n      },\n      \"acknowledgments\": {\n        \"title\": \"Poděkování\",\n        \"whisper\": {\n          \"title\": \"Whisper.cpp\",\n          \"description\": \"Vysoce výkonné inferenční zpracování modelu automatického rozpoznávání řeči Whisper od OpenAI\",\n          \"details\": \"Handy používá Whisper.cpp pro rychlé lokální zpracování řeči na text. Díky skvělé práci Georgiho Gerganova a přispěvatelů.\"\n        }\n      }\n    }\n  },\n  \"footer\": {\n    \"downloadingModel\": \"Stahuji {{model}}...\",\n    \"checkingUpdates\": \"Kontroluji aktualizace...\",\n    \"updateAvailable\": \"Dostupná aktualizace: {{version}}\",\n    \"updateAvailableShort\": \"Dostupná aktualizace\",\n    \"upToDate\": \"Vše aktuální\",\n    \"downloadUpdate\": \"Stáhnout aktualizaci\",\n    \"restart\": \"Restartovat\",\n    \"updateCheckingDisabled\": \"Kontrola aktualizací vypnuta\",\n    \"downloading\": \"Stahování... {{progress}}%\",\n    \"installing\": \"Instalace...\",\n    \"preparing\": \"Příprava...\",\n    \"checkForUpdates\": \"Zkontrolovat aktualizace\"\n  },\n  \"common\": {\n    \"loading\": \"Načítání...\",\n    \"save\": \"Uložit\",\n    \"cancel\": \"Zrušit\",\n    \"reset\": \"Obnovit\",\n    \"add\": \"Přidat\",\n    \"remove\": \"Odebrat\",\n    \"delete\": \"Smazat\",\n    \"edit\": \"Upravit\",\n    \"create\": \"Vytvořit\",\n    \"update\": \"Aktualizovat\",\n    \"close\": \"Zavřít\",\n    \"open\": \"Otevřít\",\n    \"default\": \"Výchozí\",\n    \"enabled\": \"Zapnuto\",\n    \"disabled\": \"Vypnuto\",\n    \"on\": \"Zapnuto\",\n    \"off\": \"Vypnuto\",\n    \"yes\": \"Ano\",\n    \"no\": \"Ne\",\n    \"noOptionsFound\": \"Nenalezeny žádné možnosti\"\n  },\n  \"accessibility\": {\n    \"permissionsRequired\": \"Vyžadována oprávnění usnadnění přístupu\",\n    \"permissionsDescription\": \"Handy potřebuje oprávnění usnadnění přístupu, aby mohlo psát přepsaný text.\",\n    \"openSettings\": \"Otevřít Nastavení systému\",\n    \"dismiss\": \"Zavřít\"\n  },\n  \"errors\": {\n    \"loadDirectory\": \"Chyba při načítání adresáře: {{error}}\",\n    \"micPermissionDeniedTitle\": \"Přístup k mikrofonu byl odepřen\",\n    \"micPermissionDenied\": {\n      \"generic\": \"Přístup k mikrofonu byl odepřen operačním systémem. Povolte prosím přístup k mikrofonu v nastavení systému.\",\n      \"windows\": \"Povolte přístup k mikrofonu v Nastavení → Soukromí a zabezpečení → Mikrofon (včetně přístupu desktopových aplikací).\",\n      \"macos\": \"Povolte přístup k mikrofonu v Nastavení systému → Soukromí a zabezpečení → Mikrofon.\",\n      \"linux\": \"Povolte přístup k mikrofonu v nastavení zvuku nebo soukromí vašeho systému.\"\n    },\n    \"recordingFailed\": \"Nepodařilo se spustit nahrávání: {{error}}\",\n    \"modelLoadFailed\": \"Nepodařilo se načíst model: {{model}}\",\n    \"modelLoadFailedUnknown\": \"neznámý model\"\n  },\n  \"appLanguage\": {\n    \"title\": \"Jazyk aplikace\",\n    \"description\": \"Změňte jazyk rozhraní Handy\"\n  },\n  \"overlay\": {\n    \"transcribing\": \"Přepisuji...\",\n    \"processing\": \"Zpracovávám...\"\n  }\n}\n"
  },
  {
    "path": "src/i18n/locales/de/translation.json",
    "content": "{\n  \"tray\": {\n    \"settings\": \"Einstellungen...\",\n    \"checkUpdates\": \"Nach Updates suchen...\",\n    \"copyLastTranscript\": \"Letzte Transkription kopieren\",\n    \"unloadModel\": \"Modell entladen\",\n    \"model\": \"Modell\",\n    \"quit\": \"Beenden\",\n    \"cancel\": \"Abbrechen\"\n  },\n  \"sidebar\": {\n    \"general\": \"Allgemein\",\n    \"models\": \"Modelle\",\n    \"advanced\": \"Erweitert\",\n    \"postProcessing\": \"Nachbearbeitung\",\n    \"history\": \"Verlauf\",\n    \"debug\": \"Debug\",\n    \"about\": \"Info\"\n  },\n  \"onboarding\": {\n    \"subtitle\": \"Wähle ein Transkriptionsmodell, um loszulegen\",\n    \"recommended\": \"Empfohlen\",\n    \"download\": \"Herunterladen\",\n    \"downloading\": \"Wird heruntergeladen...\",\n    \"customModelDescription\": \"Nicht offiziell unterstützt\",\n    \"downloadFailed\": \"Download fehlgeschlagen. Bitte erneut versuchen.\",\n    \"modelCard\": {\n      \"accuracy\": \"Genauigkeit\",\n      \"speed\": \"Geschwindigkeit\"\n    },\n    \"models\": {\n      \"small\": {\n        \"name\": \"Whisper Small\",\n        \"description\": \"Schnell und recht genau.\"\n      },\n      \"medium\": {\n        \"name\": \"Whisper Medium\",\n        \"description\": \"Gute Genauigkeit, mittlere Geschwindigkeit\"\n      },\n      \"turbo\": {\n        \"name\": \"Whisper Turbo\",\n        \"description\": \"Ausgewogene Genauigkeit und Geschwindigkeit.\"\n      },\n      \"large\": {\n        \"name\": \"Whisper Large\",\n        \"description\": \"Gute Genauigkeit, aber langsam.\"\n      },\n      \"parakeet-tdt-0.6b-v2\": {\n        \"name\": \"Parakeet V2\",\n        \"description\": \"Nur Englisch. Das beste Modell für englischsprachige Nutzer.\"\n      },\n      \"parakeet-tdt-0.6b-v3\": {\n        \"name\": \"Parakeet V3\",\n        \"description\": \"Schnell und genau\"\n      },\n      \"moonshine-base\": {\n        \"name\": \"Moonshine Base\",\n        \"description\": \"Sehr schnell, nur Englisch. Verarbeitet Akzente gut.\"\n      },\n      \"moonshine-tiny-streaming-en\": {\n        \"name\": \"Moonshine V2 Tiny\",\n        \"description\": \"Ultraschnell, nur Englisch\"\n      },\n      \"moonshine-small-streaming-en\": {\n        \"name\": \"Moonshine V2 Small\",\n        \"description\": \"Schnell, nur Englisch. Gute Balance zwischen Geschwindigkeit und Genauigkeit.\"\n      },\n      \"moonshine-medium-streaming-en\": {\n        \"name\": \"Moonshine V2 Medium\",\n        \"description\": \"Nur Englisch. Hohe Qualität.\"\n      },\n      \"breeze-asr\": {\n        \"name\": \"Breeze ASR\",\n        \"description\": \"Optimiert für taiwanesisches Mandarin. Unterstützung für Code-Switching.\"\n      },\n      \"sense-voice-int8\": {\n        \"name\": \"SenseVoice\",\n        \"description\": \"Sehr schnell. Chinesisch, Englisch, Japanisch, Koreanisch, Kantonesisch.\"\n      },\n      \"gigaam-v3-e2e-ctc\": {\n        \"name\": \"GigaAM v3\",\n        \"description\": \"Russische Spracherkennung. Schnell und präzise.\"\n      },\n      \"canary-180m-flash\": {\n        \"name\": \"Canary 180M Flash\",\n        \"description\": \"Sehr schnell. Englisch, Deutsch, Spanisch, Französisch. Unterstützt Übersetzung.\"\n      },\n      \"canary-1b-v2\": {\n        \"name\": \"Canary 1B v2\",\n        \"description\": \"Genaue mehrsprachige Erkennung. 25 europäische Sprachen. Unterstützt Übersetzung.\"\n      }\n    },\n    \"errors\": {\n      \"loadModels\": \"Verfügbare Modelle konnten nicht geladen werden\",\n      \"downloadModel\": \"Modell konnte nicht heruntergeladen werden: {{error}}\"\n    },\n    \"permissions\": {\n      \"title\": \"Berechtigungen erforderlich\",\n      \"description\": \"Handy benötigt einige Berechtigungen, um richtig zu funktionieren.\",\n      \"microphone\": {\n        \"title\": \"Mikrofonzugriff\",\n        \"description\": \"Erforderlich, um Ihre Stimme für die Transkription zu hören.\"\n      },\n      \"accessibility\": {\n        \"title\": \"Bedienungshilfen-Zugriff\",\n        \"description\": \"Erforderlich, um transkribierten Text in Ihre Anwendungen einzugeben.\"\n      },\n      \"grant\": \"Berechtigung erteilen\",\n      \"granted\": \"Erteilt\",\n      \"waiting\": \"Warten...\",\n      \"allGranted\": \"Alles bereit!\",\n      \"errors\": {\n        \"checkFailed\": \"Berechtigungsprüfung fehlgeschlagen. Bitte erneut versuchen.\",\n        \"requestFailed\": \"Berechtigungsanfrage fehlgeschlagen. Bitte erneut versuchen.\"\n      }\n    }\n  },\n  \"modelSelector\": {\n    \"custom\": \"Benutzerdefiniert\",\n    \"active\": \"Aktiv\",\n    \"switching\": \"Wechseln...\",\n    \"noModelsAvailable\": \"Keine Modelle verfügbar\",\n    \"extracting\": \"Entpacke {{modelName}}...\",\n    \"extractingMultiple\": \"Entpacke {{count}} Modelle...\",\n    \"extractingGeneric\": \"Wird entpackt...\",\n    \"downloading\": \"Herunterladen {{percentage}}%\",\n    \"downloadingMultiple\": \"Lade {{count}} Modelle herunter...\",\n    \"modelReady\": \"Modell bereit\",\n    \"loading\": \"Lade {{modelName}}...\",\n    \"loadingGeneric\": \"Wird geladen...\",\n    \"modelError\": \"Modellfehler\",\n    \"modelUnloaded\": \"Modell entladen\",\n    \"noModelDownloadRequired\": \"Kein Modell - Download erforderlich\",\n    \"deleteModel\": \"{{modelName}} löschen\",\n    \"downloadSpeed\": \"{{speed}} MB/s\",\n    \"capabilities\": {\n      \"languageSelection\": \"Unterstützt mehrere Eingabesprachen\",\n      \"multiLanguage\": \"Mehrsprachig\",\n      \"translation\": \"Kann ins Englische übersetzen\",\n      \"translate\": \"Ins Englische übersetzen\",\n      \"singleLanguage\": \"Unterstützt nur diese Sprache\",\n      \"languageOnly\": \"Nur {{language}}\"\n    },\n    \"cancel\": \"Abbrechen\",\n    \"cancelDownload\": \"Download abbrechen\"\n  },\n  \"settings\": {\n    \"modelSettings\": {\n      \"title\": \"{{model}}-Einstellungen\",\n      \"noSettingsNeeded\": \"Dieses Modell funktioniert automatisch ohne Konfiguration.\"\n    },\n    \"models\": {\n      \"title\": \"Transkriptionsmodelle\",\n      \"description\": \"Wähle ein Transkriptionsmodell aus oder lade zusätzliche Modelle herunter. Verschiedene Modelle bieten unterschiedliche Genauigkeits- und Geschwindigkeitsstufen.\",\n      \"downloaded\": \"Heruntergeladen\",\n      \"available\": \"Zum Download verfügbar\",\n      \"deleteConfirm\": \"Bist du sicher, dass du {{modelName}} löschen möchtest? Du musst es erneut herunterladen, um es zu verwenden.\",\n      \"deleteActiveConfirm\": \"{{modelName}} ist dein aktives Modell. Das Löschen stoppt Transkriptionen, bis du ein neues Modell auswählst. Bist du sicher?\",\n      \"deleteTitle\": \"Modell löschen\",\n      \"filters\": {\n        \"all\": \"Alle\",\n        \"multiLanguage\": \"Mehrsprachig\",\n        \"translation\": \"Übersetzung\",\n        \"allLanguages\": \"Alle Sprachen\"\n      },\n      \"noModelsMatch\": \"Keine Modelle entsprechen diesem Filter.\",\n      \"yourModels\": \"Heruntergeladene Modelle\",\n      \"availableModels\": \"Zum Download verfügbar\"\n    },\n    \"general\": {\n      \"title\": \"Allgemein\",\n      \"shortcut\": {\n        \"title\": \"Handy-Tastenkürzel\",\n        \"description\": \"Tastenkürzel für die Sprachaufnahme konfigurieren\",\n        \"loading\": \"Tastenkürzel werden geladen...\",\n        \"none\": \"Keine Tastenkürzel konfiguriert\",\n        \"notFound\": \"Tastenkürzel nicht gefunden\",\n        \"pressKeys\": \"Tasten drücken...\",\n        \"bindings\": {\n          \"transcribe\": {\n            \"name\": \"Transkriptions-Tastenkürzel\",\n            \"description\": \"Das Tastenkürzel zum Aufnehmen und Transkribieren Ihrer Stimme.\"\n          },\n          \"cancel\": {\n            \"name\": \"Abbrechen-Tastenkürzel\",\n            \"description\": \"Das Tastenkürzel zum Abbrechen der aktuellen Aufnahme.\"\n          },\n          \"transcribe_with_post_process\": {\n            \"name\": \"Nachbearbeitungs-Tastenkürzel\",\n            \"description\": \"Optional: Ein dediziertes Tastenkürzel, das immer die KI-Nachbearbeitung auf Ihre Transkription anwendet.\"\n          }\n        },\n        \"errors\": {\n          \"restore\": \"Ursprüngliches Tastenkürzel konnte nicht wiederhergestellt werden\",\n          \"set\": \"Tastenkürzel konnte nicht gesetzt werden: {{error}}\",\n          \"reset\": \"Tastenkürzel konnte nicht auf Originalwert zurückgesetzt werden\"\n        }\n      },\n      \"language\": {\n        \"title\": \"Sprache\",\n        \"description\": \"Wähle die Sprache für die Spracherkennung. Auto erkennt die Sprache automatisch, die Auswahl einer bestimmten Sprache kann die Genauigkeit verbessern.\",\n        \"descriptionUnsupported\": \"Das Parakeet-Modell erkennt die Sprache automatisch. Keine manuelle Auswahl erforderlich.\",\n        \"searchPlaceholder\": \"Sprachen suchen...\",\n        \"noResults\": \"Keine Sprachen gefunden\",\n        \"auto\": \"Auto\"\n      },\n      \"pushToTalk\": {\n        \"label\": \"Push-to-Talk\",\n        \"description\": \"Gedrückt halten zum Aufnehmen, loslassen zum Stoppen\"\n      }\n    },\n    \"sound\": {\n      \"title\": \"Ton\",\n      \"microphone\": {\n        \"title\": \"Mikrofon\",\n        \"description\": \"Bevorzugtes Mikrofon auswählen\",\n        \"placeholder\": \"Mikrofon auswählen...\",\n        \"loading\": \"Wird geladen...\"\n      },\n      \"audioFeedback\": {\n        \"label\": \"Audio-Feedback\",\n        \"description\": \"Ton bei Start und Ende der Aufnahme abspielen\"\n      },\n      \"outputDevice\": {\n        \"title\": \"Ausgabegerät\",\n        \"description\": \"Bevorzugtes Audioausgabegerät für Feedback-Töne auswählen\",\n        \"placeholder\": \"Ausgabegerät auswählen...\",\n        \"loading\": \"Wird geladen...\"\n      },\n      \"volume\": {\n        \"title\": \"Lautstärke\",\n        \"description\": \"Lautstärke der Audio-Feedback-Töne anpassen\"\n      }\n    },\n    \"advanced\": {\n      \"title\": \"Erweitert\",\n      \"groups\": {\n        \"app\": \"App\",\n        \"output\": \"Ausgabe\",\n        \"transcription\": \"Transkription\",\n        \"history\": \"Verlauf\",\n        \"experimental\": \"Experimentell\"\n      },\n      \"experimentalToggle\": {\n        \"label\": \"Experimentelle Funktionen\",\n        \"description\": \"Experimentelle Funktionen aktivieren, die sich noch in Entwicklung befinden.\"\n      },\n      \"lazyStreamClose\": {\n        \"label\": \"Mikrofon zwischen Transkriptionen offen halten\",\n        \"description\": \"Hält den Mikrofon-Stream nach dem Aufnahmestopp 30 Sekunden lang offen, um die Latenz bei aufeinanderfolgenden Transkriptionen zu verringern. Kann die Bluetooth-Audioqualität beeinträchtigen.\"\n      },\n      \"acceleration\": {\n        \"whisper\": {\n          \"title\": \"Whisper-Beschleunigung\",\n          \"description\": \"Hardwarebeschleunigung für Whisper-Modelle. Automatisch nutzt GPU falls verfügbar (Metal auf macOS, Vulkan auf Windows/Linux).\"\n        },\n        \"ort\": {\n          \"title\": \"ONNX-Beschleunigung\",\n          \"description\": \"Hardwarebeschleunigung für ONNX-Modelle (Parakeet, Canary, Moonshine usw.). DirectML unter Windows ist experimentell. Modelle können bei der Transkription fehlschlagen.\"\n        }\n      },\n      \"startHidden\": {\n        \"label\": \"Versteckt starten\",\n        \"description\": \"In den Systembereich starten, ohne das Fenster zu öffnen.\"\n      },\n      \"autostart\": {\n        \"label\": \"Beim Start ausführen\",\n        \"description\": \"Handy automatisch beim Anmelden starten.\"\n      },\n      \"showTrayIcon\": {\n        \"label\": \"Taskleistensymbol anzeigen\",\n        \"description\": \"Das Handy-Symbol in der Taskleiste anzeigen.\"\n      },\n      \"overlay\": {\n        \"title\": \"Overlay-Position\",\n        \"description\": \"Visuelles Feedback-Overlay während Aufnahme und Transkription anzeigen. Unter Linux wird 'Keine' empfohlen.\",\n        \"options\": {\n          \"none\": \"Keine\",\n          \"bottom\": \"Unten\",\n          \"top\": \"Oben\"\n        }\n      },\n      \"pasteMethod\": {\n        \"title\": \"Einfügemethode\",\n        \"description\": \"Wähle, wie Text eingefügt wird. Direkt: simuliert Tippen über Systemeingabe. Keine: überspringt Einfügen, aktualisiert nur Verlauf/Zwischenablage.\",\n        \"options\": {\n          \"clipboard\": \"Zwischenablage ({{modifier}}+V)\",\n          \"clipboardCtrlShiftV\": \"Zwischenablage (Strg+Umschalt+V)\",\n          \"clipboardShiftInsert\": \"Zwischenablage (Umschalt+Einfg)\",\n          \"direct\": \"Direkt\",\n          \"none\": \"Keine\",\n          \"externalScript\": \"Externes Skript\"\n        },\n        \"externalScriptPlaceholder\": \"/pfad/zu/ihrem/skript.sh\"\n      },\n      \"typingTool\": {\n        \"title\": \"Eingabetool\",\n        \"description\": \"Wählen Sie, welches Linux-Eingabetool für die Direkt-Einfügen-Methode verwendet werden soll. Auto erkennt und verwendet automatisch das beste verfügbare Tool für Ihr System.\",\n        \"options\": {\n          \"auto\": \"Auto (Empfohlen)\"\n        }\n      },\n      \"clipboardHandling\": {\n        \"title\": \"Zwischenablage-Verhalten\",\n        \"description\": \"Zwischenablage nicht ändern bewahrt den aktuellen Inhalt nach der Transkription. In Zwischenablage kopieren hinterlässt das Transkriptionsergebnis in der Zwischenablage.\",\n        \"options\": {\n          \"dontModify\": \"Zwischenablage nicht ändern\",\n          \"copyToClipboard\": \"In Zwischenablage kopieren\"\n        }\n      },\n      \"autoSubmit\": {\n        \"title\": \"Automatisch absenden\",\n        \"description\": \"Sendet nach dem Einfügen von Text automatisch die ausgewählte Tastenkombination. Cmd+Enter gilt für macOS, während Windows/Linux Super+Enter verwenden.\",\n        \"options\": {\n          \"off\": \"Aus\",\n          \"enter\": \"Enter\",\n          \"cmdEnter\": \"Cmd+Enter\",\n          \"superEnter\": \"Super+Enter\",\n          \"ctrlEnter\": \"Ctrl+Enter\"\n        }\n      },\n      \"translateToEnglish\": {\n        \"label\": \"Ins Englische übersetzen\",\n        \"description\": \"Sprache aus anderen Sprachen automatisch während der Transkription ins Englische übersetzen.\",\n        \"descriptionUnsupported\": \"Übersetzung wird vom {{model}}-Modell nicht unterstützt.\"\n      },\n      \"modelUnload\": {\n        \"title\": \"Modell entladen\",\n        \"description\": \"GPU/CPU-Speicher automatisch freigeben, wenn das Modell für die angegebene Zeit nicht verwendet wurde\",\n        \"options\": {\n          \"never\": \"Nie\",\n          \"immediately\": \"Sofort\",\n          \"min2\": \"Nach 2 Minuten\",\n          \"min5\": \"Nach 5 Minuten\",\n          \"min10\": \"Nach 10 Minuten\",\n          \"min15\": \"Nach 15 Minuten\",\n          \"hour1\": \"Nach 1 Stunde\",\n          \"sec15\": \"Nach 15 Sekunden (Debug)\"\n        }\n      },\n      \"customWords\": {\n        \"title\": \"Benutzerdefinierte Wörter\",\n        \"description\": \"Wörter hinzufügen, die oft falsch gehört oder geschrieben werden. Das System korrigiert automatisch ähnlich klingende Wörter entsprechend deiner Liste.\",\n        \"placeholder\": \"Wort hinzufügen\",\n        \"add\": \"Hinzufügen\",\n        \"remove\": \"{{word}} entfernen\",\n        \"duplicate\": \"\\\"{{word}}\\\" existiert bereits\"\n      }\n    },\n    \"postProcessing\": {\n      \"title\": \"Nachbearbeitung\",\n      \"hotkey\": {\n        \"title\": \"Tastenkürzel\"\n      },\n      \"api\": {\n        \"title\": \"API (OpenAI-kompatibel)\",\n        \"provider\": {\n          \"title\": \"Anbieter\",\n          \"description\": \"Wähle einen OpenAI-kompatiblen Anbieter.\"\n        },\n        \"appleIntelligence\": {\n          \"title\": \"Apple Intelligence\",\n          \"description\": \"Läuft vollständig auf dem Gerät. Kein API-Schlüssel oder Netzwerkzugriff erforderlich.\",\n          \"requirements\": \"Erfordert einen Apple Silicon Mac mit macOS Tahoe (26.0) oder neuer. Apple Intelligence muss in den Systemeinstellungen aktiviert sein.\",\n          \"unavailable\": \"Apple Intelligence ist auf diesem Gerät nicht verfügbar. Erfordert einen Apple Silicon Mac mit macOS Tahoe (26.0) oder neuer und aktiviertem Apple Intelligence in den Systemeinstellungen.\"\n        },\n        \"baseUrl\": {\n          \"title\": \"Basis-URL\",\n          \"description\": \"API-Basis-URL für den ausgewählten Anbieter. Nur der benutzerdefinierte Anbieter kann bearbeitet werden.\",\n          \"placeholder\": \"https://api.openai.com/v1\"\n        },\n        \"apiKey\": {\n          \"title\": \"API-Schlüssel\",\n          \"description\": \"API-Schlüssel für den ausgewählten Anbieter.\",\n          \"placeholder\": \"sk-...\"\n        },\n        \"model\": {\n          \"title\": \"Modell\",\n          \"descriptionApple\": \"Gib ein optionales numerisches Token-Limit an oder behalte die Standard-Gerätevorgabe.\",\n          \"descriptionCustom\": \"Gib die Modellkennung an, die von deinem benutzerdefinierten Endpunkt erwartet wird.\",\n          \"descriptionDefault\": \"Wähle ein Modell des ausgewählten Anbieters.\",\n          \"placeholderApple\": \"Apple Intelligence\",\n          \"placeholderWithOptions\": \"Modell suchen oder auswählen\",\n          \"placeholderNoOptions\": \"Modellnamen eingeben\",\n          \"refreshModels\": \"Modelle aktualisieren\"\n        }\n      },\n      \"prompts\": {\n        \"title\": \"Prompt\",\n        \"selectedPrompt\": {\n          \"title\": \"Ausgewählter Prompt\",\n          \"description\": \"Wähle eine Vorlage zur Verfeinerung von Transkriptionen oder erstelle eine neue. Verwende ${output} im Prompt-Text, um auf das erfasste Transkript zu verweisen.\"\n        },\n        \"noPrompts\": \"Keine Prompts verfügbar\",\n        \"selectPrompt\": \"Prompt auswählen\",\n        \"createNew\": \"Neuen Prompt erstellen\",\n        \"promptLabel\": \"Prompt-Name\",\n        \"promptLabelPlaceholder\": \"Prompt-Namen eingeben\",\n        \"promptInstructions\": \"Prompt-Anweisungen\",\n        \"promptInstructionsPlaceholder\": \"Schreibe die Anweisungen, die nach der Transkription ausgeführt werden sollen. Beispiel: Verbessere Grammatik und Klarheit für folgenden Text: ${output}\",\n        \"promptTip\": \"Tipp: Verwende <code>${output}</code>, um den transkribierten Text in deinen Prompt einzufügen.\",\n        \"updatePrompt\": \"Prompt aktualisieren\",\n        \"deletePrompt\": \"Prompt löschen\",\n        \"createPrompt\": \"Prompt erstellen\",\n        \"cancel\": \"Abbrechen\",\n        \"selectToEdit\": \"Wähle oben einen Prompt aus, um dessen Details anzuzeigen und zu bearbeiten.\",\n        \"createFirst\": \"Klicke oben auf 'Neuen Prompt erstellen', um deinen ersten Nachbearbeitungs-Prompt zu erstellen.\"\n      }\n    },\n    \"history\": {\n      \"title\": \"Verlauf\",\n      \"openFolder\": \"Aufnahmeordner öffnen\",\n      \"loading\": \"Verlauf wird geladen...\",\n      \"empty\": \"Noch keine Transkriptionen. Starte eine Aufnahme, um deinen Verlauf aufzubauen!\",\n      \"copyToClipboard\": \"Transkription in Zwischenablage kopieren\",\n      \"save\": \"Transkription speichern\",\n      \"unsave\": \"Aus Gespeicherten entfernen\",\n      \"delete\": \"Eintrag löschen\",\n      \"deleteError\": \"Eintrag konnte nicht gelöscht werden. Bitte versuche es erneut.\"\n    },\n    \"debug\": {\n      \"title\": \"Debug\",\n      \"logDirectory\": {\n        \"title\": \"Log-Verzeichnis\",\n        \"description\": \"Speicherort der Log-Dateien\"\n      },\n      \"logLevel\": {\n        \"title\": \"Log-Level\",\n        \"description\": \"Ausführlichkeit der Protokollierung festlegen\"\n      },\n      \"updateChecks\": {\n        \"label\": \"Nach Updates suchen\",\n        \"description\": \"Automatisch nach neuen Versionen von Handy suchen\"\n      },\n      \"soundTheme\": {\n        \"label\": \"Sound-Thema\",\n        \"description\": \"Sound-Thema für Aufnahme-Start und -Stop-Feedback auswählen\"\n      },\n      \"wordCorrectionThreshold\": {\n        \"title\": \"Wortkorrektur-Schwelle\",\n        \"description\": \"Empfindlichkeit für benutzerdefinierte Wortkorrekturen\"\n      },\n      \"historyLimit\": {\n        \"title\": \"Verlaufslimit\",\n        \"description\": \"Maximale Anzahl der Verlaufseinträge\",\n        \"entries\": \"Einträge\"\n      },\n      \"recordingRetention\": {\n        \"title\": \"Aufnahmen automatisch löschen\",\n        \"description\": \"Alte Aufnahmen automatisch löschen, um Speicherplatz zu sparen\",\n        \"never\": \"Nie\",\n        \"preserveLimit\": \"Neueste {{count}} behalten\",\n        \"days3\": \"Nach 3 Tagen\",\n        \"weeks2\": \"Nach 2 Wochen\",\n        \"months3\": \"Nach 3 Monaten\",\n        \"placeholder\": \"Aufbewahrungszeitraum auswählen...\"\n      },\n      \"alwaysOnMicrophone\": {\n        \"label\": \"Mikrofon immer aktiv\",\n        \"description\": \"Mikrofon für schnellere Reaktion aktiv halten\"\n      },\n      \"clamshellMicrophone\": {\n        \"title\": \"Clamshell-Mikrofon\",\n        \"description\": \"Mikrofon bei geschlossenem Laptop-Deckel\"\n      },\n      \"postProcessingToggle\": {\n        \"label\": \"Nachbearbeitung\",\n        \"description\": \"KI-gestützte Textverfeinerung nach der Transkription aktivieren\"\n      },\n      \"muteWhileRecording\": {\n        \"label\": \"Während Aufnahme stummschalten\",\n        \"description\": \"Systemaudio während der Aufnahme stummschalten\"\n      },\n      \"appendTrailingSpace\": {\n        \"label\": \"Leerzeichen anhängen\",\n        \"description\": \"Leerzeichen nach eingefügter Transkription hinzufügen\"\n      },\n      \"keyboardImplementation\": {\n        \"title\": \"Tastatur-Implementierung\",\n        \"description\": \"Wähle das Backend für Tastaturkürzel.\",\n        \"bindingsReset\": \"Tastaturkürzel waren inkompatibel und wurden auf die Standardwerte zurückgesetzt\"\n      },\n      \"paths\": {\n        \"appData\": \"App-Daten:\",\n        \"models\": \"Modelle:\",\n        \"settings\": \"Einstellungen:\"\n      },\n      \"pasteDelay\": {\n        \"title\": \"Einfügeverzögerung\",\n        \"description\": \"Verzögerung vor dem Senden des Einfüge-Tastendrucks (in Millisekunden). Erhöhen Sie den Wert, wenn falscher Text eingefügt wird.\"\n      },\n      \"recordingBuffer\": {\n        \"title\": \"Zusätzlicher Aufnahmepuffer\",\n        \"description\": \"Zusätzliche Zeit (in Millisekunden), um nach dem Loslassen der Taste weiterzuzeichnen, um nachlaufendes Audio aufzunehmen. 0 = kein zusätzlicher Puffer.\"\n      }\n    },\n    \"about\": {\n      \"title\": \"Info\",\n      \"version\": {\n        \"title\": \"Version\",\n        \"description\": \"Aktuelle Version von Handy\"\n      },\n      \"appDataDirectory\": {\n        \"title\": \"App-Datenverzeichnis\",\n        \"description\": \"Speicherort der Handy-Daten\"\n      },\n      \"sourceCode\": {\n        \"title\": \"Quellcode\",\n        \"description\": \"Quellcode ansehen und beitragen\",\n        \"button\": \"Auf GitHub ansehen\"\n      },\n      \"supportDevelopment\": {\n        \"title\": \"Entwicklung unterstützen\",\n        \"description\": \"Hilf uns, Handy weiterzuentwickeln\",\n        \"button\": \"Spenden\"\n      },\n      \"acknowledgments\": {\n        \"title\": \"Danksagungen\",\n        \"whisper\": {\n          \"title\": \"Whisper.cpp\",\n          \"description\": \"Hochleistungs-Inferenz des Whisper-Spracherkennungsmodells von OpenAI\",\n          \"details\": \"Handy verwendet Whisper.cpp für schnelle, lokale Sprach-zu-Text-Verarbeitung. Dank an Georgi Gerganov und die Mitwirkenden für ihre großartige Arbeit.\"\n        }\n      }\n    }\n  },\n  \"footer\": {\n    \"downloadingModel\": \"Lade {{model}} herunter...\",\n    \"checkingUpdates\": \"Suche nach Updates...\",\n    \"updateAvailable\": \"Update verfügbar: {{version}}\",\n    \"updateAvailableShort\": \"Update verfügbar\",\n    \"upToDate\": \"Aktuell\",\n    \"downloadUpdate\": \"Update herunterladen\",\n    \"restart\": \"Neustart\",\n    \"updateCheckingDisabled\": \"Update-Prüfung deaktiviert\",\n    \"downloading\": \"Wird heruntergeladen... {{progress}}%\",\n    \"installing\": \"Wird installiert...\",\n    \"preparing\": \"Wird vorbereitet...\",\n    \"checkForUpdates\": \"Nach Updates suchen\"\n  },\n  \"common\": {\n    \"loading\": \"Wird geladen...\",\n    \"save\": \"Speichern\",\n    \"cancel\": \"Abbrechen\",\n    \"reset\": \"Zurücksetzen\",\n    \"add\": \"Hinzufügen\",\n    \"remove\": \"Entfernen\",\n    \"delete\": \"Löschen\",\n    \"edit\": \"Bearbeiten\",\n    \"create\": \"Erstellen\",\n    \"update\": \"Aktualisieren\",\n    \"close\": \"Schließen\",\n    \"open\": \"Öffnen\",\n    \"default\": \"Standard\",\n    \"enabled\": \"Aktiviert\",\n    \"disabled\": \"Deaktiviert\",\n    \"on\": \"An\",\n    \"off\": \"Aus\",\n    \"yes\": \"Ja\",\n    \"no\": \"Nein\",\n    \"noOptionsFound\": \"Keine Optionen gefunden\"\n  },\n  \"accessibility\": {\n    \"permissionsRequired\": \"Bedienungshilfen-Berechtigungen erforderlich\",\n    \"permissionsDescription\": \"Handy benötigt Bedienungshilfen-Berechtigungen, um transkribierten Text einzugeben.\",\n    \"openSettings\": \"Systemeinstellungen öffnen\",\n    \"dismiss\": \"Schließen\"\n  },\n  \"errors\": {\n    \"loadDirectory\": \"Fehler beim Laden des Verzeichnisses: {{error}}\",\n    \"micPermissionDeniedTitle\": \"Mikrofonzugriff verweigert\",\n    \"micPermissionDenied\": {\n      \"generic\": \"Der Mikrofonzugriff wurde vom Betriebssystem verweigert. Bitte erteilen Sie die Mikrofonberechtigung in Ihren Systemeinstellungen.\",\n      \"windows\": \"Aktivieren Sie den Mikrofonzugriff unter Einstellungen → Datenschutz und Sicherheit → Mikrofon (einschließlich Desktop-App-Zugriff).\",\n      \"macos\": \"Erteilen Sie den Mikrofonzugriff in Systemeinstellungen → Datenschutz & Sicherheit → Mikrofon.\",\n      \"linux\": \"Erteilen Sie den Mikrofonzugriff in den Sound- oder Datenschutzeinstellungen Ihres Systems.\"\n    },\n    \"recordingFailed\": \"Aufnahme konnte nicht gestartet werden: {{error}}\",\n    \"modelLoadFailed\": \"Modell konnte nicht geladen werden: {{model}}\",\n    \"modelLoadFailedUnknown\": \"unbekanntes Modell\"\n  },\n  \"appLanguage\": {\n    \"title\": \"Anwendungssprache\",\n    \"description\": \"Sprache der Handy-Oberfläche ändern\"\n  },\n  \"overlay\": {\n    \"transcribing\": \"Transkribiere...\",\n    \"processing\": \"Verarbeite...\"\n  }\n}\n"
  },
  {
    "path": "src/i18n/locales/en/translation.json",
    "content": "{\n  \"tray\": {\n    \"settings\": \"Settings...\",\n    \"checkUpdates\": \"Check for Updates...\",\n    \"copyLastTranscript\": \"Copy Last Transcript\",\n    \"unloadModel\": \"Unload Model\",\n    \"model\": \"Model\",\n    \"quit\": \"Quit\",\n    \"cancel\": \"Cancel\"\n  },\n  \"sidebar\": {\n    \"general\": \"General\",\n    \"models\": \"Models\",\n    \"advanced\": \"Advanced\",\n    \"postProcessing\": \"Post Process\",\n    \"history\": \"History\",\n    \"debug\": \"Debug\",\n    \"about\": \"About\"\n  },\n  \"onboarding\": {\n    \"subtitle\": \"To get started, choose a transcription model\",\n    \"recommended\": \"Recommended\",\n    \"download\": \"Download\",\n    \"downloading\": \"Downloading...\",\n    \"customModelDescription\": \"Not officially supported\",\n    \"downloadFailed\": \"Download failed. Please try again.\",\n    \"modelCard\": {\n      \"accuracy\": \"accuracy\",\n      \"speed\": \"speed\"\n    },\n    \"models\": {\n      \"small\": {\n        \"name\": \"Whisper Small\",\n        \"description\": \"Fast and fairly accurate.\"\n      },\n      \"medium\": {\n        \"name\": \"Whisper Medium\",\n        \"description\": \"Good accuracy, medium speed\"\n      },\n      \"turbo\": {\n        \"name\": \"Whisper Turbo\",\n        \"description\": \"Balanced accuracy and speed.\"\n      },\n      \"large\": {\n        \"name\": \"Whisper Large\",\n        \"description\": \"Good accuracy, but slow.\"\n      },\n      \"parakeet-tdt-0.6b-v2\": {\n        \"name\": \"Parakeet V2\",\n        \"description\": \"English only. The best model for English speakers.\"\n      },\n      \"parakeet-tdt-0.6b-v3\": {\n        \"name\": \"Parakeet V3\",\n        \"description\": \"Fast and accurate\"\n      },\n      \"moonshine-base\": {\n        \"name\": \"Moonshine Base\",\n        \"description\": \"Very fast, English only. Handles accents well.\"\n      },\n      \"moonshine-tiny-streaming-en\": {\n        \"name\": \"Moonshine V2 Tiny\",\n        \"description\": \"Ultra-fast, English only\"\n      },\n      \"moonshine-small-streaming-en\": {\n        \"name\": \"Moonshine V2 Small\",\n        \"description\": \"Fast, English only. Good balance of speed and accuracy.\"\n      },\n      \"moonshine-medium-streaming-en\": {\n        \"name\": \"Moonshine V2 Medium\",\n        \"description\": \"English only. High quality.\"\n      },\n      \"breeze-asr\": {\n        \"name\": \"Breeze ASR\",\n        \"description\": \"Optimized for Taiwanese Mandarin. Code-switching support.\"\n      },\n      \"sense-voice-int8\": {\n        \"name\": \"SenseVoice\",\n        \"description\": \"Very fast. Chinese, English, Japanese, Korean, Cantonese.\"\n      },\n      \"gigaam-v3-e2e-ctc\": {\n        \"name\": \"GigaAM v3\",\n        \"description\": \"Russian speech recognition. Fast and accurate.\"\n      },\n      \"canary-180m-flash\": {\n        \"name\": \"Canary 180M Flash\",\n        \"description\": \"Very fast. English, German, Spanish, French. Supports translation.\"\n      },\n      \"canary-1b-v2\": {\n        \"name\": \"Canary 1B v2\",\n        \"description\": \"Accurate multilingual. 25 European languages. Supports translation.\"\n      }\n    },\n    \"errors\": {\n      \"loadModels\": \"Failed to load available models\",\n      \"downloadModel\": \"Failed to download model: {{error}}\"\n    },\n    \"permissions\": {\n      \"title\": \"Permissions Required\",\n      \"description\": \"Handy needs a couple of permissions to work properly.\",\n      \"microphone\": {\n        \"title\": \"Microphone Access\",\n        \"description\": \"Required to hear your voice for transcription.\"\n      },\n      \"accessibility\": {\n        \"title\": \"Accessibility Access\",\n        \"description\": \"Required to type transcribed text into your applications.\"\n      },\n      \"grant\": \"Grant Permission\",\n      \"granted\": \"Granted\",\n      \"waiting\": \"Waiting...\",\n      \"allGranted\": \"All set!\",\n      \"errors\": {\n        \"checkFailed\": \"Failed to check permissions. Please try again.\",\n        \"requestFailed\": \"Failed to request permission. Please try again.\"\n      }\n    }\n  },\n  \"modelSelector\": {\n    \"custom\": \"Custom\",\n    \"active\": \"Active\",\n    \"switching\": \"Switching...\",\n    \"noModelsAvailable\": \"No models available\",\n    \"extracting\": \"Extracting {{modelName}}...\",\n    \"extractingMultiple\": \"Extracting {{count}} models...\",\n    \"extractingGeneric\": \"Extracting...\",\n    \"downloading\": \"Downloading {{percentage}}%\",\n    \"downloadingMultiple\": \"Downloading {{count}} models...\",\n    \"modelReady\": \"Model Ready\",\n    \"loading\": \"Loading {{modelName}}...\",\n    \"loadingGeneric\": \"Loading...\",\n    \"modelError\": \"Model Error\",\n    \"modelUnloaded\": \"Model Unloaded\",\n    \"noModelDownloadRequired\": \"No Model - Download Required\",\n    \"deleteModel\": \"Delete {{modelName}}\",\n    \"downloadSpeed\": \"{{speed}} MB/s\",\n    \"cancel\": \"Cancel\",\n    \"cancelDownload\": \"Cancel download\",\n    \"capabilities\": {\n      \"languageSelection\": \"Supports multiple input languages\",\n      \"singleLanguage\": \"Supports this language only\",\n      \"multiLanguage\": \"Multi-language\",\n      \"languageOnly\": \"{{language}} Only\",\n      \"translation\": \"Can translate to English\",\n      \"translate\": \"Translate to English\"\n    }\n  },\n  \"settings\": {\n    \"modelSettings\": {\n      \"title\": \"{{model}} Settings\",\n      \"noSettingsNeeded\": \"This model works automatically with no configuration needed.\"\n    },\n    \"general\": {\n      \"title\": \"General\",\n      \"shortcut\": {\n        \"title\": \"Handy Shortcuts\",\n        \"description\": \"Configure keyboard shortcuts to trigger speech-to-text recording\",\n        \"loading\": \"Loading shortcuts...\",\n        \"none\": \"No shortcuts configured\",\n        \"notFound\": \"Shortcut not found\",\n        \"pressKeys\": \"Press keys...\",\n        \"bindings\": {\n          \"transcribe\": {\n            \"name\": \"Transcribe Shortcut\",\n            \"description\": \"The keyboard shortcut to record and transcribe your voice.\"\n          },\n          \"cancel\": {\n            \"name\": \"Cancel Shortcut\",\n            \"description\": \"The keyboard shortcut to cancel the current recording.\"\n          },\n          \"transcribe_with_post_process\": {\n            \"name\": \"Post-Processing Hotkey\",\n            \"description\": \"Optional: A dedicated hotkey that always applies AI post-processing to your transcription.\"\n          }\n        },\n        \"errors\": {\n          \"restore\": \"Failed to restore original shortcut\",\n          \"set\": \"Failed to set shortcut: {{error}}\",\n          \"reset\": \"Failed to reset shortcut to original value\"\n        }\n      },\n      \"language\": {\n        \"title\": \"Language\",\n        \"description\": \"Select the language for speech recognition. Auto will automatically determine the language, while selecting a specific language can improve accuracy for that language.\",\n        \"descriptionUnsupported\": \"Parakeet model automatically detects the language. No manual selection is needed.\",\n        \"searchPlaceholder\": \"Search languages...\",\n        \"noResults\": \"No languages found\",\n        \"auto\": \"Auto\"\n      },\n      \"pushToTalk\": {\n        \"label\": \"Push To Talk\",\n        \"description\": \"Hold to record, release to stop\"\n      }\n    },\n    \"models\": {\n      \"title\": \"Transcription Models\",\n      \"description\": \"Select a transcription model or download additional models. Different models offer varying levels of accuracy and speed.\",\n      \"yourModels\": \"Downloaded Models\",\n      \"availableModels\": \"Available to Download\",\n      \"downloaded\": \"Downloaded\",\n      \"available\": \"Available to Download\",\n      \"deleteConfirm\": \"Are you sure you want to delete {{modelName}}? You will need to download it again to use it.\",\n      \"deleteActiveConfirm\": \"{{modelName}} is your active model. Deleting it will stop transcriptions until you select a new model. Are you sure?\",\n      \"deleteTitle\": \"Delete Model\",\n      \"filters\": {\n        \"all\": \"All\",\n        \"multiLanguage\": \"Multi-language\",\n        \"translation\": \"Translation\",\n        \"allLanguages\": \"All Languages\"\n      },\n      \"noModelsMatch\": \"No models match this filter.\"\n    },\n    \"sound\": {\n      \"title\": \"Sound\",\n      \"microphone\": {\n        \"title\": \"Microphone\",\n        \"description\": \"Select your preferred microphone device\",\n        \"placeholder\": \"Select microphone...\",\n        \"loading\": \"Loading...\"\n      },\n      \"audioFeedback\": {\n        \"label\": \"Audio Feedback\",\n        \"description\": \"Play sound when recording starts and stops\"\n      },\n      \"outputDevice\": {\n        \"title\": \"Output Device\",\n        \"description\": \"Select your preferred audio output device for feedback sounds\",\n        \"placeholder\": \"Select output device...\",\n        \"loading\": \"Loading...\"\n      },\n      \"volume\": {\n        \"title\": \"Volume\",\n        \"description\": \"Adjust the volume of audio feedback sounds\"\n      }\n    },\n    \"advanced\": {\n      \"title\": \"Advanced\",\n      \"groups\": {\n        \"app\": \"App\",\n        \"output\": \"Output\",\n        \"transcription\": \"Transcription\",\n        \"history\": \"History\",\n        \"experimental\": \"Experimental\"\n      },\n      \"experimentalToggle\": {\n        \"label\": \"Experimental Features\",\n        \"description\": \"Enable experimental features that are still in development.\"\n      },\n      \"lazyStreamClose\": {\n        \"label\": \"Keep Mic Open Between Transcriptions\",\n        \"description\": \"Keeps the microphone stream open for 30 seconds after recording stops, reducing latency for back-to-back transcriptions. May degrade Bluetooth audio quality while active.\"\n      },\n      \"acceleration\": {\n        \"whisper\": {\n          \"title\": \"Whisper Acceleration\",\n          \"description\": \"Hardware acceleration for Whisper models. Auto uses GPU if available (Metal on macOS, Vulkan on Windows/Linux).\"\n        },\n        \"ort\": {\n          \"title\": \"ONNX Acceleration\",\n          \"description\": \"Hardware acceleration for ONNX models (Parakeet, Canary, Moonshine, etc.). DirectML on Windows is experimental. Models may fail to transcribe.\"\n        }\n      },\n      \"startHidden\": {\n        \"label\": \"Start Hidden\",\n        \"description\": \"Launch to system tray without opening the window.\"\n      },\n      \"autostart\": {\n        \"label\": \"Launch on Startup\",\n        \"description\": \"Automatically start Handy when you log in to your computer.\"\n      },\n      \"showTrayIcon\": {\n        \"label\": \"Show Tray Icon\",\n        \"description\": \"Display the Handy icon in the system tray.\"\n      },\n      \"overlay\": {\n        \"title\": \"Overlay Position\",\n        \"description\": \"Display visual feedback overlay during recording and transcription. On Linux 'None' is recommended.\",\n        \"options\": {\n          \"none\": \"None\",\n          \"bottom\": \"Bottom\",\n          \"top\": \"Top\"\n        }\n      },\n      \"pasteMethod\": {\n        \"title\": \"Paste Method\",\n        \"description\": \"Choose how text is inserted. Direct: simulates typing via system input. None: skips paste, only updates history/clipboard.\",\n        \"options\": {\n          \"clipboard\": \"Clipboard ({{modifier}}+V)\",\n          \"clipboardCtrlShiftV\": \"Clipboard (Ctrl+Shift+V)\",\n          \"clipboardShiftInsert\": \"Clipboard (Shift+Insert)\",\n          \"direct\": \"Direct\",\n          \"none\": \"None\",\n          \"externalScript\": \"External Script\"\n        },\n        \"externalScriptPlaceholder\": \"/path/to/your/script.sh\"\n      },\n      \"typingTool\": {\n        \"title\": \"Typing Tool\",\n        \"description\": \"Choose which Linux typing tool to use for Direct paste method. Auto will automatically detect and use the best available tool for your system.\",\n        \"options\": {\n          \"auto\": \"Auto (Recommended)\"\n        }\n      },\n      \"clipboardHandling\": {\n        \"title\": \"Clipboard Handling\",\n        \"description\": \"Don't Modify Clipboard preserves your current clipboard contents after transcription. Copy to Clipboard leaves the transcription result in your clipboard after pasting.\",\n        \"options\": {\n          \"dontModify\": \"Don't Modify Clipboard\",\n          \"copyToClipboard\": \"Copy to Clipboard\"\n        }\n      },\n      \"autoSubmit\": {\n        \"title\": \"Auto Submit\",\n        \"description\": \"Automatically send the selected key combination after text insertion. Cmd+Enter applies on macOS, while Windows/Linux use Super+Enter.\",\n        \"options\": {\n          \"off\": \"Off\",\n          \"enter\": \"Enter\",\n          \"cmdEnter\": \"Cmd+Enter\",\n          \"superEnter\": \"Super+Enter\",\n          \"ctrlEnter\": \"Ctrl+Enter\"\n        }\n      },\n      \"translateToEnglish\": {\n        \"label\": \"Translate to English\",\n        \"description\": \"Automatically translate speech from other languages to English during transcription.\",\n        \"descriptionUnsupported\": \"Translation is not supported by the {{model}} model.\"\n      },\n      \"modelUnload\": {\n        \"title\": \"Unload Model\",\n        \"description\": \"Automatically free GPU/CPU memory when the model hasn't been used for the specified time\",\n        \"options\": {\n          \"never\": \"Never\",\n          \"immediately\": \"Immediately\",\n          \"min2\": \"After 2 minutes\",\n          \"min5\": \"After 5 minutes\",\n          \"min10\": \"After 10 minutes\",\n          \"min15\": \"After 15 minutes\",\n          \"hour1\": \"After 1 hour\",\n          \"sec15\": \"After 15 seconds (Debug)\"\n        }\n      },\n      \"customWords\": {\n        \"title\": \"Custom Words\",\n        \"description\": \"Add words that are often misheard or misspelled during transcription. The system will automatically correct similar-sounding words to match your list.\",\n        \"placeholder\": \"Add a word\",\n        \"add\": \"Add\",\n        \"remove\": \"Remove {{word}}\",\n        \"duplicate\": \"\\\"{{word}}\\\" already exists\"\n      }\n    },\n    \"postProcessing\": {\n      \"title\": \"Post Process\",\n      \"hotkey\": {\n        \"title\": \"Hotkey\"\n      },\n      \"api\": {\n        \"title\": \"API (OpenAI Compatible)\",\n        \"provider\": {\n          \"title\": \"Provider\",\n          \"description\": \"Select an OpenAI-compatible provider.\"\n        },\n        \"appleIntelligence\": {\n          \"title\": \"Apple Intelligence\",\n          \"description\": \"Runs fully on-device. No API key or network access is required.\",\n          \"requirements\": \"Requires an Apple Silicon Mac running macOS Tahoe (26.0) or later. Apple Intelligence must be enabled in System Settings.\",\n          \"unavailable\": \"Apple Intelligence is not available on this device. Requires an Apple Silicon Mac running macOS Tahoe (26.0) or later with Apple Intelligence enabled in System Settings.\"\n        },\n        \"baseUrl\": {\n          \"title\": \"Base URL\",\n          \"description\": \"API base URL for the selected provider. Only the custom provider can be edited.\",\n          \"placeholder\": \"https://api.openai.com/v1\"\n        },\n        \"apiKey\": {\n          \"title\": \"API Key\",\n          \"description\": \"API key for the selected provider.\",\n          \"placeholder\": \"sk-...\"\n        },\n        \"model\": {\n          \"title\": \"Model\",\n          \"descriptionApple\": \"Provide an optional numeric token limit or keep the default on-device preset.\",\n          \"descriptionCustom\": \"Provide the model identifier expected by your custom endpoint.\",\n          \"descriptionDefault\": \"Choose a model exposed by the selected provider.\",\n          \"placeholderApple\": \"Apple Intelligence\",\n          \"placeholderWithOptions\": \"Search or select a model\",\n          \"placeholderNoOptions\": \"Type a model name\",\n          \"refreshModels\": \"Refresh models\"\n        }\n      },\n      \"prompts\": {\n        \"title\": \"Prompt\",\n        \"selectedPrompt\": {\n          \"title\": \"Selected Prompt\",\n          \"description\": \"Select a template for refining transcriptions or create a new one. Use ${output} inside the prompt text to reference the captured transcript.\"\n        },\n        \"noPrompts\": \"No prompts available\",\n        \"selectPrompt\": \"Select a prompt\",\n        \"createNew\": \"Create New Prompt\",\n        \"promptLabel\": \"Prompt Label\",\n        \"promptLabelPlaceholder\": \"Enter prompt name\",\n        \"promptInstructions\": \"Prompt Instructions\",\n        \"promptInstructionsPlaceholder\": \"Write the instructions to run after transcription. Example: Improve grammar and clarity for the following text: ${output}\",\n        \"promptTip\": \"Tip: Use <code>${output}</code> to insert the transcribed text in your prompt.\",\n        \"updatePrompt\": \"Update Prompt\",\n        \"deletePrompt\": \"Delete Prompt\",\n        \"createPrompt\": \"Create Prompt\",\n        \"cancel\": \"Cancel\",\n        \"selectToEdit\": \"Select a prompt above to view and edit its details.\",\n        \"createFirst\": \"Click 'Create New Prompt' above to create your first post-processing prompt.\"\n      }\n    },\n    \"history\": {\n      \"title\": \"History\",\n      \"openFolder\": \"Open Recordings Folder\",\n      \"loading\": \"Loading history...\",\n      \"empty\": \"No transcriptions yet. Start recording to build your history!\",\n      \"copyToClipboard\": \"Copy transcription to clipboard\",\n      \"save\": \"Save transcription\",\n      \"unsave\": \"Remove from saved\",\n      \"delete\": \"Delete entry\",\n      \"deleteError\": \"Failed to delete entry. Please try again.\"\n    },\n    \"debug\": {\n      \"title\": \"Debug\",\n      \"logDirectory\": {\n        \"title\": \"Log Directory\",\n        \"description\": \"Location where log files are stored\"\n      },\n      \"logLevel\": {\n        \"title\": \"Log Level\",\n        \"description\": \"Set the verbosity of logging\"\n      },\n      \"updateChecks\": {\n        \"label\": \"Check for Updates\",\n        \"description\": \"Automatically check for new versions of Handy\"\n      },\n      \"soundTheme\": {\n        \"label\": \"Sound Theme\",\n        \"description\": \"Choose a sound theme for recording start and stop feedback\"\n      },\n      \"wordCorrectionThreshold\": {\n        \"title\": \"Word Correction Threshold\",\n        \"description\": \"Sensitivity for custom word corrections\"\n      },\n      \"historyLimit\": {\n        \"title\": \"History Limit\",\n        \"description\": \"Maximum number of history entries to keep\",\n        \"entries\": \"entries\"\n      },\n      \"recordingRetention\": {\n        \"title\": \"Auto-Delete Recordings\",\n        \"description\": \"Automatically delete old recordings to save space\",\n        \"never\": \"Never\",\n        \"preserveLimit\": \"Keep latest {{count}}\",\n        \"days3\": \"After 3 days\",\n        \"weeks2\": \"After 2 weeks\",\n        \"months3\": \"After 3 months\",\n        \"placeholder\": \"Select retention period...\"\n      },\n      \"alwaysOnMicrophone\": {\n        \"label\": \"Always-On Microphone\",\n        \"description\": \"Keep microphone active for faster response\"\n      },\n      \"clamshellMicrophone\": {\n        \"title\": \"Clamshell Microphone\",\n        \"description\": \"Microphone to use when laptop lid is closed\"\n      },\n      \"postProcessingToggle\": {\n        \"label\": \"Post Processing\",\n        \"description\": \"Enable AI-powered text refinement after transcription\"\n      },\n      \"muteWhileRecording\": {\n        \"label\": \"Mute While Recording\",\n        \"description\": \"Mute system audio during recording\"\n      },\n      \"appendTrailingSpace\": {\n        \"label\": \"Append Trailing Space\",\n        \"description\": \"Add a space after pasted transcription\"\n      },\n      \"keyboardImplementation\": {\n        \"title\": \"Keyboard Implementation\",\n        \"description\": \"Choose the keyboard shortcut backend.\",\n        \"bindingsReset\": \"Keyboard shortcuts were incompatible and reset to defaults\"\n      },\n      \"paths\": {\n        \"appData\": \"App Data:\",\n        \"models\": \"Models:\",\n        \"settings\": \"Settings:\"\n      },\n      \"pasteDelay\": {\n        \"title\": \"Paste Delay\",\n        \"description\": \"Delay before sending paste keystroke (in milliseconds). Increase if wrong text is being pasted.\"\n      },\n      \"recordingBuffer\": {\n        \"title\": \"Extra Recording Buffer\",\n        \"description\": \"Extra time (in milliseconds) to keep recording after you release the key, to capture trailing audio. 0 = no extra buffer.\"\n      }\n    },\n    \"about\": {\n      \"title\": \"About\",\n      \"version\": {\n        \"title\": \"Version\",\n        \"description\": \"Current version of Handy\"\n      },\n      \"appDataDirectory\": {\n        \"title\": \"App Data Directory\",\n        \"description\": \"Location where Handy stores its data\"\n      },\n      \"sourceCode\": {\n        \"title\": \"Source Code\",\n        \"description\": \"View source code and contribute\",\n        \"button\": \"View on GitHub\"\n      },\n      \"supportDevelopment\": {\n        \"title\": \"Support Development\",\n        \"description\": \"Help us continue building Handy\",\n        \"button\": \"Donate\"\n      },\n      \"acknowledgments\": {\n        \"title\": \"Acknowledgments\",\n        \"whisper\": {\n          \"title\": \"Whisper.cpp\",\n          \"description\": \"High-performance inference of OpenAI's Whisper automatic speech recognition model\",\n          \"details\": \"Handy uses Whisper.cpp for fast, local speech-to-text processing. Thanks to the amazing work by Georgi Gerganov and contributors.\"\n        }\n      }\n    }\n  },\n  \"footer\": {\n    \"downloadingModel\": \"Downloading {{model}}...\",\n    \"checkingUpdates\": \"Checking for updates...\",\n    \"updateAvailable\": \"Update available: {{version}}\",\n    \"updateAvailableShort\": \"Update available\",\n    \"upToDate\": \"Up to date\",\n    \"downloadUpdate\": \"Download Update\",\n    \"restart\": \"Restart\",\n    \"updateCheckingDisabled\": \"Update Checking Disabled\",\n    \"downloading\": \"Downloading... {{progress}}%\",\n    \"installing\": \"Installing...\",\n    \"preparing\": \"Preparing...\",\n    \"checkForUpdates\": \"Check for updates\"\n  },\n  \"common\": {\n    \"loading\": \"Loading...\",\n    \"save\": \"Save\",\n    \"cancel\": \"Cancel\",\n    \"reset\": \"Reset\",\n    \"add\": \"Add\",\n    \"remove\": \"Remove\",\n    \"delete\": \"Delete\",\n    \"edit\": \"Edit\",\n    \"create\": \"Create\",\n    \"update\": \"Update\",\n    \"close\": \"Close\",\n    \"open\": \"Open\",\n    \"default\": \"Default\",\n    \"enabled\": \"Enabled\",\n    \"disabled\": \"Disabled\",\n    \"on\": \"On\",\n    \"off\": \"Off\",\n    \"yes\": \"Yes\",\n    \"no\": \"No\",\n    \"noOptionsFound\": \"No options found\"\n  },\n  \"accessibility\": {\n    \"permissionsRequired\": \"Accessibility Permissions Required\",\n    \"permissionsDescription\": \"Handy needs accessibility permissions to type transcribed text.\",\n    \"openSettings\": \"Open System Settings\",\n    \"dismiss\": \"Dismiss\"\n  },\n  \"errors\": {\n    \"loadDirectory\": \"Error loading directory: {{error}}\",\n    \"micPermissionDeniedTitle\": \"Microphone Access Denied\",\n    \"micPermissionDenied\": {\n      \"generic\": \"Microphone access was denied by the operating system. Please grant microphone permission in your system settings.\",\n      \"windows\": \"Enable microphone access in Settings → Privacy & security → Microphone (including desktop app access).\",\n      \"macos\": \"Grant microphone access in System Settings → Privacy & Security → Microphone.\",\n      \"linux\": \"Grant microphone access in your system's sound or privacy settings.\"\n    },\n    \"recordingFailed\": \"Failed to start recording: {{error}}\",\n    \"modelLoadFailed\": \"Failed to load model: {{model}}\",\n    \"modelLoadFailedUnknown\": \"unknown model\"\n  },\n  \"appLanguage\": {\n    \"title\": \"Application Language\",\n    \"description\": \"Change the language of the Handy interface\"\n  },\n  \"overlay\": {\n    \"transcribing\": \"Transcribing...\",\n    \"processing\": \"Processing...\"\n  }\n}\n"
  },
  {
    "path": "src/i18n/locales/es/translation.json",
    "content": "{\n  \"tray\": {\n    \"settings\": \"Configuración...\",\n    \"checkUpdates\": \"Buscar actualizaciones...\",\n    \"copyLastTranscript\": \"Copiar la última transcripción\",\n    \"unloadModel\": \"Descargar modelo\",\n    \"model\": \"Modelo\",\n    \"quit\": \"Salir\",\n    \"cancel\": \"Cancelar\"\n  },\n  \"sidebar\": {\n    \"general\": \"General\",\n    \"models\": \"Modelos\",\n    \"advanced\": \"Avanzado\",\n    \"postProcessing\": \"Post Proceso\",\n    \"history\": \"Historial\",\n    \"debug\": \"Depuración\",\n    \"about\": \"Acerca de\"\n  },\n  \"onboarding\": {\n    \"subtitle\": \"Para comenzar, elige un modelo de transcripción\",\n    \"recommended\": \"Recomendado\",\n    \"download\": \"Descargar\",\n    \"downloading\": \"Descargando...\",\n    \"customModelDescription\": \"Sin soporte oficial\",\n    \"downloadFailed\": \"La descarga falló. Por favor, inténtalo de nuevo.\",\n    \"modelCard\": {\n      \"accuracy\": \"precisión\",\n      \"speed\": \"velocidad\"\n    },\n    \"models\": {\n      \"small\": {\n        \"name\": \"Whisper Small\",\n        \"description\": \"Rápido y bastante preciso.\"\n      },\n      \"medium\": {\n        \"name\": \"Whisper Medium\",\n        \"description\": \"Buena precisión, velocidad media\"\n      },\n      \"turbo\": {\n        \"name\": \"Whisper Turbo\",\n        \"description\": \"Equilibrio entre precisión y velocidad.\"\n      },\n      \"large\": {\n        \"name\": \"Whisper Large\",\n        \"description\": \"Buena precisión, pero lento.\"\n      },\n      \"parakeet-tdt-0.6b-v2\": {\n        \"name\": \"Parakeet V2\",\n        \"description\": \"Solo inglés. El mejor modelo para hablantes de inglés.\"\n      },\n      \"parakeet-tdt-0.6b-v3\": {\n        \"name\": \"Parakeet V3\",\n        \"description\": \"Rápido y preciso\"\n      },\n      \"moonshine-base\": {\n        \"name\": \"Moonshine Base\",\n        \"description\": \"Muy rápido, solo inglés. Maneja bien los acentos.\"\n      },\n      \"moonshine-tiny-streaming-en\": {\n        \"name\": \"Moonshine V2 Tiny\",\n        \"description\": \"Ultrarrápido, solo inglés\"\n      },\n      \"moonshine-small-streaming-en\": {\n        \"name\": \"Moonshine V2 Small\",\n        \"description\": \"Rápido, solo inglés. Buen equilibrio entre velocidad y precisión.\"\n      },\n      \"moonshine-medium-streaming-en\": {\n        \"name\": \"Moonshine V2 Medium\",\n        \"description\": \"Solo inglés. Alta calidad.\"\n      },\n      \"breeze-asr\": {\n        \"name\": \"Breeze ASR\",\n        \"description\": \"Optimizado para mandarín taiwanés. Soporte para cambio de código.\"\n      },\n      \"sense-voice-int8\": {\n        \"name\": \"SenseVoice\",\n        \"description\": \"Muy rápido. Chino, inglés, japonés, coreano, cantonés.\"\n      },\n      \"gigaam-v3-e2e-ctc\": {\n        \"name\": \"GigaAM v3\",\n        \"description\": \"Reconocimiento de voz en ruso. Rápido y preciso.\"\n      },\n      \"canary-180m-flash\": {\n        \"name\": \"Canary 180M Flash\",\n        \"description\": \"Muy rápido. Inglés, alemán, español, francés. Soporta traducción.\"\n      },\n      \"canary-1b-v2\": {\n        \"name\": \"Canary 1B v2\",\n        \"description\": \"Multilingüe preciso. 25 idiomas europeos. Soporta traducción.\"\n      }\n    },\n    \"errors\": {\n      \"loadModels\": \"Error al cargar los modelos disponibles\",\n      \"downloadModel\": \"Error al descargar el modelo: {{error}}\"\n    },\n    \"permissions\": {\n      \"title\": \"Permisos Requeridos\",\n      \"description\": \"Handy necesita algunos permisos para funcionar correctamente.\",\n      \"microphone\": {\n        \"title\": \"Acceso al Micrófono\",\n        \"description\": \"Necesario para escuchar tu voz para la transcripción.\"\n      },\n      \"accessibility\": {\n        \"title\": \"Acceso de Accesibilidad\",\n        \"description\": \"Necesario para escribir el texto transcrito en tus aplicaciones.\"\n      },\n      \"grant\": \"Conceder Permiso\",\n      \"granted\": \"Concedido\",\n      \"waiting\": \"Esperando...\",\n      \"allGranted\": \"¡Todo listo!\",\n      \"errors\": {\n        \"checkFailed\": \"Error al verificar permisos. Por favor, inténtalo de nuevo.\",\n        \"requestFailed\": \"Error al solicitar permiso. Por favor, inténtalo de nuevo.\"\n      }\n    }\n  },\n  \"modelSelector\": {\n    \"custom\": \"Personalizado\",\n    \"active\": \"Activo\",\n    \"switching\": \"Cambiando...\",\n    \"noModelsAvailable\": \"No hay modelos disponibles\",\n    \"extracting\": \"Extrayendo {{modelName}}...\",\n    \"extractingMultiple\": \"Extrayendo {{count}} modelos...\",\n    \"extractingGeneric\": \"Extrayendo...\",\n    \"downloading\": \"Descargando {{percentage}}%\",\n    \"downloadingMultiple\": \"Descargando {{count}} modelos...\",\n    \"modelReady\": \"Modelo Listo\",\n    \"loading\": \"Cargando {{modelName}}...\",\n    \"loadingGeneric\": \"Cargando...\",\n    \"modelError\": \"Error del Modelo\",\n    \"modelUnloaded\": \"Modelo Descargado\",\n    \"noModelDownloadRequired\": \"Sin Modelo - Descarga Requerida\",\n    \"deleteModel\": \"Eliminar {{modelName}}\",\n    \"downloadSpeed\": \"{{speed}} MB/s\",\n    \"capabilities\": {\n      \"languageSelection\": \"Soporta múltiples idiomas de entrada\",\n      \"multiLanguage\": \"Multiidioma\",\n      \"translation\": \"Puede traducir al inglés\",\n      \"translate\": \"Traducir al inglés\",\n      \"singleLanguage\": \"Solo admite este idioma\",\n      \"languageOnly\": \"Solo {{language}}\"\n    },\n    \"cancel\": \"Cancelar\",\n    \"cancelDownload\": \"Cancelar descarga\"\n  },\n  \"settings\": {\n    \"modelSettings\": {\n      \"title\": \"Configuración de {{model}}\",\n      \"noSettingsNeeded\": \"Este modelo funciona automáticamente sin necesidad de configuración.\"\n    },\n    \"models\": {\n      \"title\": \"Modelos de Transcripción\",\n      \"description\": \"Selecciona un modelo de transcripción o descarga modelos adicionales. Diferentes modelos ofrecen distintos niveles de precisión y velocidad.\",\n      \"downloaded\": \"Descargados\",\n      \"available\": \"Disponibles para Descargar\",\n      \"deleteConfirm\": \"¿Estás seguro de que deseas eliminar {{modelName}}? Necesitarás descargarlo de nuevo para usarlo.\",\n      \"deleteActiveConfirm\": \"{{modelName}} es tu modelo activo. Eliminarlo detendrá las transcripciones hasta que selecciones un nuevo modelo. ¿Estás seguro?\",\n      \"deleteTitle\": \"Eliminar modelo\",\n      \"filters\": {\n        \"all\": \"Todos\",\n        \"multiLanguage\": \"Multiidioma\",\n        \"translation\": \"Traducción\",\n        \"allLanguages\": \"Todos los idiomas\"\n      },\n      \"noModelsMatch\": \"Ningún modelo coincide con este filtro.\",\n      \"yourModels\": \"Modelos descargados\",\n      \"availableModels\": \"Disponibles para descargar\"\n    },\n    \"general\": {\n      \"title\": \"General\",\n      \"shortcut\": {\n        \"title\": \"Atajos de Handy\",\n        \"description\": \"Configura atajos de teclado para activar la grabación de voz a texto\",\n        \"loading\": \"Cargando atajos...\",\n        \"none\": \"No hay atajos configurados\",\n        \"notFound\": \"Atajo no encontrado\",\n        \"pressKeys\": \"Presiona teclas...\",\n        \"bindings\": {\n          \"transcribe\": {\n            \"name\": \"Atajo de Transcripción\",\n            \"description\": \"El atajo de teclado para grabar y transcribir tu voz.\"\n          },\n          \"cancel\": {\n            \"name\": \"Atajo de Cancelar\",\n            \"description\": \"El atajo de teclado para cancelar la grabación actual.\"\n          },\n          \"transcribe_with_post_process\": {\n            \"name\": \"Tecla de Post Procesamiento\",\n            \"description\": \"Opcional: Una tecla de acceso rápido dedicada que siempre aplica post procesamiento con IA a tu transcripción.\"\n          }\n        },\n        \"errors\": {\n          \"restore\": \"Error al restaurar el atajo original\",\n          \"set\": \"Error al configurar el atajo: {{error}}\",\n          \"reset\": \"Error al restablecer el atajo al valor original\"\n        }\n      },\n      \"language\": {\n        \"title\": \"Idioma\",\n        \"description\": \"Selecciona el idioma para el reconocimiento de voz. Auto detectará automáticamente el idioma, mientras que seleccionar un idioma específico puede mejorar la precisión para ese idioma.\",\n        \"descriptionUnsupported\": \"El modelo Parakeet detecta automáticamente el idioma. No se necesita selección manual.\",\n        \"searchPlaceholder\": \"Buscar idiomas...\",\n        \"noResults\": \"No se encontraron idiomas\",\n        \"auto\": \"Auto\"\n      },\n      \"pushToTalk\": {\n        \"label\": \"Presionar para Hablar\",\n        \"description\": \"Mantén presionado para grabar, suelta para detener\"\n      }\n    },\n    \"sound\": {\n      \"title\": \"Sonido\",\n      \"microphone\": {\n        \"title\": \"Micrófono\",\n        \"description\": \"Selecciona tu dispositivo de micrófono preferido\",\n        \"placeholder\": \"Seleccionar micrófono...\",\n        \"loading\": \"Cargando...\"\n      },\n      \"audioFeedback\": {\n        \"label\": \"Retroalimentación de Audio\",\n        \"description\": \"Reproducir sonido cuando la grabación inicia y se detiene\"\n      },\n      \"outputDevice\": {\n        \"title\": \"Dispositivo de Salida\",\n        \"description\": \"Selecciona tu dispositivo de salida de audio preferido para los sonidos de retroalimentación\",\n        \"placeholder\": \"Seleccionar dispositivo de salida...\",\n        \"loading\": \"Cargando...\"\n      },\n      \"volume\": {\n        \"title\": \"Volumen\",\n        \"description\": \"Ajusta el volumen de los sonidos de retroalimentación de audio\"\n      }\n    },\n    \"advanced\": {\n      \"title\": \"Avanzado\",\n      \"groups\": {\n        \"app\": \"Aplicación\",\n        \"output\": \"Salida\",\n        \"transcription\": \"Transcripción\",\n        \"history\": \"Historial\",\n        \"experimental\": \"Experimental\"\n      },\n      \"experimentalToggle\": {\n        \"label\": \"Funciones Experimentales\",\n        \"description\": \"Habilitar funciones experimentales que aún están en desarrollo.\"\n      },\n      \"lazyStreamClose\": {\n        \"label\": \"Mantener micrófono abierto entre transcripciones\",\n        \"description\": \"Mantiene el flujo del micrófono abierto durante 30 segundos después de detener la grabación, reduciendo la latencia en transcripciones consecutivas. Puede degradar la calidad del audio Bluetooth mientras está activo.\"\n      },\n      \"acceleration\": {\n        \"whisper\": {\n          \"title\": \"Aceleración de Whisper\",\n          \"description\": \"Aceleración por hardware para modelos Whisper. Auto usa GPU si está disponible (Metal en macOS, Vulkan en Windows/Linux).\"\n        },\n        \"ort\": {\n          \"title\": \"Aceleración de ONNX\",\n          \"description\": \"Aceleración por hardware para modelos ONNX (Parakeet, Canary, Moonshine, etc.). DirectML en Windows es experimental. Los modelos pueden fallar al transcribir.\"\n        }\n      },\n      \"startHidden\": {\n        \"label\": \"Iniciar Oculto\",\n        \"description\": \"Lanzar en la bandeja del sistema sin abrir la ventana.\"\n      },\n      \"autostart\": {\n        \"label\": \"Iniciar al Arranque\",\n        \"description\": \"Iniciar Handy automáticamente cuando inicies sesión en tu computadora.\"\n      },\n      \"showTrayIcon\": {\n        \"label\": \"Mostrar Icono de Bandeja\",\n        \"description\": \"Mostrar el icono de Handy en la bandeja del sistema.\"\n      },\n      \"overlay\": {\n        \"title\": \"Posición de Superposición\",\n        \"description\": \"Mostrar superposición de retroalimentación visual durante la grabación y transcripción. En Linux se recomienda 'Ninguna'.\",\n        \"options\": {\n          \"none\": \"Ninguna\",\n          \"bottom\": \"Abajo\",\n          \"top\": \"Arriba\"\n        }\n      },\n      \"pasteMethod\": {\n        \"title\": \"Método de Pegado\",\n        \"description\": \"Elige cómo se inserta el texto. Directo: simula escritura mediante entrada del sistema. Ninguno: omite el pegado, solo actualiza historial/portapapeles.\",\n        \"options\": {\n          \"clipboard\": \"Portapapeles ({{modifier}}+V)\",\n          \"clipboardCtrlShiftV\": \"Portapapeles (Ctrl+Shift+V)\",\n          \"clipboardShiftInsert\": \"Portapapeles (Shift+Insert)\",\n          \"direct\": \"Directo\",\n          \"none\": \"Ninguno\",\n          \"externalScript\": \"Script externo\"\n        },\n        \"externalScriptPlaceholder\": \"/ruta/a/su/script.sh\"\n      },\n      \"typingTool\": {\n        \"title\": \"Herramienta de Escritura\",\n        \"description\": \"Elige qué herramienta de escritura de Linux usar para el método de pegado directo. Auto detectará y usará automáticamente la mejor herramienta disponible para tu sistema.\",\n        \"options\": {\n          \"auto\": \"Auto (Recomendado)\"\n        }\n      },\n      \"clipboardHandling\": {\n        \"title\": \"Manejo del Portapapeles\",\n        \"description\": \"No Modificar Portapapeles conserva el contenido actual de tu portapapeles después de la transcripción. Copiar al Portapapeles deja el resultado de la transcripción en tu portapapeles después de pegar.\",\n        \"options\": {\n          \"dontModify\": \"No Modificar Portapapeles\",\n          \"copyToClipboard\": \"Copiar al Portapapeles\"\n        }\n      },\n      \"autoSubmit\": {\n        \"title\": \"Envío automático\",\n        \"description\": \"Envía automáticamente la combinación de teclas seleccionada después de insertar el texto. Cmd+Enter se aplica en macOS, mientras que Windows/Linux usan Super+Enter.\",\n        \"options\": {\n          \"off\": \"Desactivado\",\n          \"enter\": \"Enter\",\n          \"cmdEnter\": \"Cmd+Enter\",\n          \"superEnter\": \"Super+Enter\",\n          \"ctrlEnter\": \"Ctrl+Enter\"\n        }\n      },\n      \"translateToEnglish\": {\n        \"label\": \"Traducir al Inglés\",\n        \"description\": \"Traducir automáticamente el habla de otros idiomas al inglés durante la transcripción.\",\n        \"descriptionUnsupported\": \"La traducción no es compatible con el modelo {{model}}.\"\n      },\n      \"modelUnload\": {\n        \"title\": \"Descargar Modelo\",\n        \"description\": \"Liberar automáticamente la memoria GPU/CPU cuando el modelo no se ha usado durante el tiempo especificado\",\n        \"options\": {\n          \"never\": \"Nunca\",\n          \"immediately\": \"Inmediatamente\",\n          \"min2\": \"Después de 2 minutos\",\n          \"min5\": \"Después de 5 minutos\",\n          \"min10\": \"Después de 10 minutos\",\n          \"min15\": \"Después de 15 minutos\",\n          \"hour1\": \"Después de 1 hora\",\n          \"sec15\": \"Después de 15 segundos (Depuración)\"\n        }\n      },\n      \"customWords\": {\n        \"title\": \"Palabras Personalizadas\",\n        \"description\": \"Agrega palabras que a menudo se escuchan mal o se escriben incorrectamente durante la transcripción. El sistema corregirá automáticamente palabras similares para que coincidan con tu lista.\",\n        \"placeholder\": \"Agregar una palabra\",\n        \"add\": \"Agregar\",\n        \"remove\": \"Eliminar {{word}}\",\n        \"duplicate\": \"\\\"{{word}}\\\" ya existe\"\n      }\n    },\n    \"postProcessing\": {\n      \"title\": \"Post Proceso\",\n      \"hotkey\": {\n        \"title\": \"Tecla de acceso rápido\"\n      },\n      \"api\": {\n        \"title\": \"API (Compatible con OpenAI)\",\n        \"provider\": {\n          \"title\": \"Proveedor\",\n          \"description\": \"Selecciona un proveedor compatible con OpenAI.\"\n        },\n        \"appleIntelligence\": {\n          \"title\": \"Apple Intelligence\",\n          \"description\": \"Se ejecuta completamente en el dispositivo. No se requiere clave API ni acceso a la red.\",\n          \"requirements\": \"Requiere un Mac con Apple Silicon ejecutando macOS Tahoe (26.0) o posterior. Apple Intelligence debe estar habilitado en Ajustes del Sistema.\",\n          \"unavailable\": \"Apple Intelligence no está disponible en este dispositivo. Requiere un Mac con Apple Silicon ejecutando macOS Tahoe (26.0) o posterior con Apple Intelligence habilitado en Ajustes del Sistema.\"\n        },\n        \"baseUrl\": {\n          \"title\": \"URL Base\",\n          \"description\": \"URL base de la API para el proveedor seleccionado. Solo se puede editar el proveedor personalizado.\",\n          \"placeholder\": \"https://api.openai.com/v1\"\n        },\n        \"apiKey\": {\n          \"title\": \"Clave API\",\n          \"description\": \"Clave API para el proveedor seleccionado.\",\n          \"placeholder\": \"sk-...\"\n        },\n        \"model\": {\n          \"title\": \"Modelo\",\n          \"descriptionApple\": \"Proporciona un límite de tokens numérico opcional o mantén el preajuste predeterminado en el dispositivo.\",\n          \"descriptionCustom\": \"Proporciona el identificador del modelo esperado por tu endpoint personalizado.\",\n          \"descriptionDefault\": \"Elige un modelo expuesto por el proveedor seleccionado.\",\n          \"placeholderApple\": \"Apple Intelligence\",\n          \"placeholderWithOptions\": \"Buscar o seleccionar un modelo\",\n          \"placeholderNoOptions\": \"Escribe un nombre de modelo\",\n          \"refreshModels\": \"Actualizar modelos\"\n        }\n      },\n      \"prompts\": {\n        \"title\": \"Prompt\",\n        \"selectedPrompt\": {\n          \"title\": \"Prompt Seleccionado\",\n          \"description\": \"Selecciona una plantilla para refinar las transcripciones o crea una nueva. Usa ${output} dentro del texto del prompt para hacer referencia a la transcripción capturada.\"\n        },\n        \"noPrompts\": \"No hay prompts disponibles\",\n        \"selectPrompt\": \"Seleccionar un prompt\",\n        \"createNew\": \"Crear Nuevo Prompt\",\n        \"promptLabel\": \"Etiqueta del Prompt\",\n        \"promptLabelPlaceholder\": \"Ingresa el nombre del prompt\",\n        \"promptInstructions\": \"Instrucciones del Prompt\",\n        \"promptInstructionsPlaceholder\": \"Escribe las instrucciones para ejecutar después de la transcripción. Ejemplo: Mejora la gramática y claridad del siguiente texto: ${output}\",\n        \"promptTip\": \"Consejo: Usa <code>${output}</code> para insertar el texto transcrito en tu prompt.\",\n        \"updatePrompt\": \"Actualizar Prompt\",\n        \"deletePrompt\": \"Eliminar Prompt\",\n        \"createPrompt\": \"Crear Prompt\",\n        \"cancel\": \"Cancelar\",\n        \"selectToEdit\": \"Selecciona un prompt arriba para ver y editar sus detalles.\",\n        \"createFirst\": \"Haz clic en 'Crear Nuevo Prompt' arriba para crear tu primer prompt de post procesamiento.\"\n      }\n    },\n    \"history\": {\n      \"title\": \"Historial\",\n      \"openFolder\": \"Abrir Carpeta de Grabaciones\",\n      \"loading\": \"Cargando historial...\",\n      \"empty\": \"Aún no hay transcripciones. ¡Comienza a grabar para crear tu historial!\",\n      \"copyToClipboard\": \"Copiar transcripción al portapapeles\",\n      \"save\": \"Guardar transcripción\",\n      \"unsave\": \"Eliminar de guardados\",\n      \"delete\": \"Eliminar entrada\",\n      \"deleteError\": \"Error al eliminar la entrada. Por favor, intenta de nuevo.\"\n    },\n    \"debug\": {\n      \"title\": \"Depuración\",\n      \"logDirectory\": {\n        \"title\": \"Directorio de Registros\",\n        \"description\": \"Ubicación donde se almacenan los archivos de registro\"\n      },\n      \"logLevel\": {\n        \"title\": \"Nivel de Registro\",\n        \"description\": \"Establece el nivel de detalle del registro\"\n      },\n      \"updateChecks\": {\n        \"label\": \"Buscar Actualizaciones\",\n        \"description\": \"Buscar automáticamente nuevas versiones de Handy\"\n      },\n      \"soundTheme\": {\n        \"label\": \"Tema de Sonido\",\n        \"description\": \"Elige un tema de sonido para la retroalimentación de inicio y parada de grabación\"\n      },\n      \"wordCorrectionThreshold\": {\n        \"title\": \"Umbral de Corrección de Palabras\",\n        \"description\": \"Sensibilidad para correcciones de palabras personalizadas\"\n      },\n      \"historyLimit\": {\n        \"title\": \"Límite de Historial\",\n        \"description\": \"Número máximo de entradas de historial a conservar\",\n        \"entries\": \"entradas\"\n      },\n      \"recordingRetention\": {\n        \"title\": \"Eliminación automática de grabaciones\",\n        \"description\": \"Eliminar automáticamente grabaciones antiguas para ahorrar espacio\",\n        \"never\": \"Nunca\",\n        \"preserveLimit\": \"Mantener las últimas {{count}}\",\n        \"days3\": \"Después de 3 días\",\n        \"weeks2\": \"Después de 2 semanas\",\n        \"months3\": \"Después de 3 meses\",\n        \"placeholder\": \"Seleccionar período de retención...\"\n      },\n      \"alwaysOnMicrophone\": {\n        \"label\": \"Micrófono Siempre Activo\",\n        \"description\": \"Mantener el micrófono activo para una respuesta más rápida\"\n      },\n      \"clamshellMicrophone\": {\n        \"title\": \"Micrófono en Modo Clamshell\",\n        \"description\": \"Micrófono a usar cuando la tapa del portátil está cerrada\"\n      },\n      \"postProcessingToggle\": {\n        \"label\": \"Post Procesamiento\",\n        \"description\": \"Habilitar refinamiento de texto impulsado por IA después de la transcripción\"\n      },\n      \"muteWhileRecording\": {\n        \"label\": \"Silenciar Durante la Grabación\",\n        \"description\": \"Silenciar el audio del sistema durante la grabación\"\n      },\n      \"appendTrailingSpace\": {\n        \"label\": \"Agregar Espacio Final\",\n        \"description\": \"Agregar un espacio después de la transcripción pegada\"\n      },\n      \"keyboardImplementation\": {\n        \"title\": \"Implementación del teclado\",\n        \"description\": \"Elige el sistema que gestiona los atajos de teclado.\",\n        \"bindingsReset\": \"Los atajos de teclado eran incompatibles y se restablecieron a los valores predeterminados\"\n      },\n      \"paths\": {\n        \"appData\": \"Datos de la Aplicación:\",\n        \"models\": \"Modelos:\",\n        \"settings\": \"Configuración:\"\n      },\n      \"pasteDelay\": {\n        \"title\": \"Retraso de pegado\",\n        \"description\": \"Retraso antes de enviar la pulsación de tecla de pegar (en milisegundos). Aumente si se está pegando texto incorrecto.\"\n      },\n      \"recordingBuffer\": {\n        \"title\": \"Búfer de grabación adicional\",\n        \"description\": \"Tiempo adicional (en milisegundos) para seguir grabando después de soltar la tecla, para capturar el audio restante. 0 = sin búfer adicional.\"\n      }\n    },\n    \"about\": {\n      \"title\": \"Acerca de\",\n      \"version\": {\n        \"title\": \"Versión\",\n        \"description\": \"Versión actual de Handy\"\n      },\n      \"appDataDirectory\": {\n        \"title\": \"Directorio de Datos de la Aplicación\",\n        \"description\": \"Ubicación donde Handy almacena sus datos\"\n      },\n      \"sourceCode\": {\n        \"title\": \"Código Fuente\",\n        \"description\": \"Ver código fuente y contribuir\",\n        \"button\": \"Ver en GitHub\"\n      },\n      \"supportDevelopment\": {\n        \"title\": \"Apoyar el Desarrollo\",\n        \"description\": \"Ayúdanos a continuar construyendo Handy\",\n        \"button\": \"Donar\"\n      },\n      \"acknowledgments\": {\n        \"title\": \"Reconocimientos\",\n        \"whisper\": {\n          \"title\": \"Whisper.cpp\",\n          \"description\": \"Inferencia de alto rendimiento del modelo de reconocimiento automático de voz Whisper de OpenAI\",\n          \"details\": \"Handy usa Whisper.cpp para procesamiento de voz a texto rápido y local. Gracias al increíble trabajo de Georgi Gerganov y colaboradores.\"\n        }\n      }\n    }\n  },\n  \"footer\": {\n    \"downloadingModel\": \"Descargando {{model}}...\",\n    \"checkingUpdates\": \"Buscando actualizaciones...\",\n    \"updateAvailable\": \"Actualización disponible: {{version}}\",\n    \"updateAvailableShort\": \"Actualización disponible\",\n    \"upToDate\": \"Actualizado\",\n    \"downloadUpdate\": \"Descargar Actualización\",\n    \"restart\": \"Reiniciar\",\n    \"updateCheckingDisabled\": \"Búsqueda de Actualizaciones Deshabilitada\",\n    \"downloading\": \"Descargando... {{progress}}%\",\n    \"installing\": \"Instalando...\",\n    \"preparing\": \"Preparando...\",\n    \"checkForUpdates\": \"Buscar actualizaciones\"\n  },\n  \"common\": {\n    \"loading\": \"Cargando...\",\n    \"save\": \"Guardar\",\n    \"cancel\": \"Cancelar\",\n    \"reset\": \"Restablecer\",\n    \"add\": \"Agregar\",\n    \"remove\": \"Eliminar\",\n    \"delete\": \"Eliminar\",\n    \"edit\": \"Editar\",\n    \"create\": \"Crear\",\n    \"update\": \"Actualizar\",\n    \"close\": \"Cerrar\",\n    \"open\": \"Abrir\",\n    \"default\": \"Predeterminado\",\n    \"enabled\": \"Habilitado\",\n    \"disabled\": \"Deshabilitado\",\n    \"on\": \"Activado\",\n    \"off\": \"Desactivado\",\n    \"yes\": \"Sí\",\n    \"no\": \"No\",\n    \"noOptionsFound\": \"No se encontraron opciones\"\n  },\n  \"accessibility\": {\n    \"permissionsRequired\": \"Se Requieren Permisos de Accesibilidad\",\n    \"permissionsDescription\": \"Handy necesita permisos de accesibilidad para escribir texto transcrito.\",\n    \"openSettings\": \"Abrir Ajustes del Sistema\",\n    \"dismiss\": \"Descartar\"\n  },\n  \"errors\": {\n    \"loadDirectory\": \"Error al cargar el directorio: {{error}}\",\n    \"micPermissionDeniedTitle\": \"Acceso al micrófono denegado\",\n    \"micPermissionDenied\": {\n      \"generic\": \"El sistema operativo denegó el acceso al micrófono. Por favor, conceda el permiso del micrófono en la configuración del sistema.\",\n      \"windows\": \"Active el acceso al micrófono en Configuración → Privacidad y seguridad → Micrófono (incluido el acceso de aplicaciones de escritorio).\",\n      \"macos\": \"Conceda el acceso al micrófono en Ajustes del Sistema → Privacidad y seguridad → Micrófono.\",\n      \"linux\": \"Conceda el acceso al micrófono en la configuración de sonido o privacidad de su sistema.\"\n    },\n    \"recordingFailed\": \"Error al iniciar la grabación: {{error}}\",\n    \"modelLoadFailed\": \"Error al cargar el modelo: {{model}}\",\n    \"modelLoadFailedUnknown\": \"modelo desconocido\"\n  },\n  \"appLanguage\": {\n    \"title\": \"Idioma de la aplicación\",\n    \"description\": \"Cambia el idioma de la interfaz de Handy\"\n  },\n  \"overlay\": {\n    \"transcribing\": \"Transcribiendo...\",\n    \"processing\": \"Procesando...\"\n  }\n}\n"
  },
  {
    "path": "src/i18n/locales/fr/translation.json",
    "content": "{\n  \"tray\": {\n    \"settings\": \"Paramètres...\",\n    \"checkUpdates\": \"Rechercher des mises à jour...\",\n    \"copyLastTranscript\": \"Copier la dernière transcription\",\n    \"unloadModel\": \"Décharger le modèle\",\n    \"model\": \"Modèle\",\n    \"quit\": \"Quitter\",\n    \"cancel\": \"Annuler\"\n  },\n  \"sidebar\": {\n    \"general\": \"Général\",\n    \"models\": \"Modèles\",\n    \"advanced\": \"Avancé\",\n    \"postProcessing\": \"Post-traitement\",\n    \"history\": \"Historique\",\n    \"debug\": \"Débogage\",\n    \"about\": \"À propos\"\n  },\n  \"onboarding\": {\n    \"subtitle\": \"Pour commencer, choisissez un modèle de transcription\",\n    \"recommended\": \"Recommandé\",\n    \"download\": \"Télécharger\",\n    \"downloading\": \"Téléchargement...\",\n    \"customModelDescription\": \"Non officiellement pris en charge\",\n    \"downloadFailed\": \"Échec du téléchargement. Veuillez réessayer.\",\n    \"modelCard\": {\n      \"accuracy\": \"précision\",\n      \"speed\": \"vitesse\"\n    },\n    \"models\": {\n      \"small\": {\n        \"name\": \"Whisper Small\",\n        \"description\": \"Rapide et assez précis.\"\n      },\n      \"medium\": {\n        \"name\": \"Whisper Medium\",\n        \"description\": \"Bonne précision, vitesse moyenne\"\n      },\n      \"turbo\": {\n        \"name\": \"Whisper Turbo\",\n        \"description\": \"Équilibre entre précision et vitesse.\"\n      },\n      \"large\": {\n        \"name\": \"Whisper Large\",\n        \"description\": \"Bonne précision, mais lent.\"\n      },\n      \"parakeet-tdt-0.6b-v2\": {\n        \"name\": \"Parakeet V2\",\n        \"description\": \"Anglais uniquement. Le meilleur modèle pour les anglophones.\"\n      },\n      \"parakeet-tdt-0.6b-v3\": {\n        \"name\": \"Parakeet V3\",\n        \"description\": \"Rapide et précis\"\n      },\n      \"moonshine-base\": {\n        \"name\": \"Moonshine Base\",\n        \"description\": \"Très rapide, anglais uniquement. Gère bien les accents.\"\n      },\n      \"moonshine-tiny-streaming-en\": {\n        \"name\": \"Moonshine V2 Tiny\",\n        \"description\": \"Ultra-rapide, anglais uniquement\"\n      },\n      \"moonshine-small-streaming-en\": {\n        \"name\": \"Moonshine V2 Small\",\n        \"description\": \"Rapide, anglais uniquement. Bon équilibre entre vitesse et précision.\"\n      },\n      \"moonshine-medium-streaming-en\": {\n        \"name\": \"Moonshine V2 Medium\",\n        \"description\": \"Anglais uniquement. Haute qualité.\"\n      },\n      \"breeze-asr\": {\n        \"name\": \"Breeze ASR\",\n        \"description\": \"Optimisé pour le mandarin taïwanais. Prise en charge de l'alternance codique.\"\n      },\n      \"sense-voice-int8\": {\n        \"name\": \"SenseVoice\",\n        \"description\": \"Très rapide. Chinois, anglais, japonais, coréen, cantonais.\"\n      },\n      \"gigaam-v3-e2e-ctc\": {\n        \"name\": \"GigaAM v3\",\n        \"description\": \"Reconnaissance vocale en russe. Rapide et précis.\"\n      },\n      \"canary-180m-flash\": {\n        \"name\": \"Canary 180M Flash\",\n        \"description\": \"Très rapide. Anglais, allemand, espagnol, français. Supporte la traduction.\"\n      },\n      \"canary-1b-v2\": {\n        \"name\": \"Canary 1B v2\",\n        \"description\": \"Multilingue précis. 25 langues européennes. Supporte la traduction.\"\n      }\n    },\n    \"errors\": {\n      \"loadModels\": \"Échec du chargement des modèles disponibles\",\n      \"downloadModel\": \"Échec du téléchargement du modèle : {{error}}\"\n    },\n    \"permissions\": {\n      \"title\": \"Autorisations requises\",\n      \"description\": \"Handy a besoin de quelques autorisations pour fonctionner correctement.\",\n      \"microphone\": {\n        \"title\": \"Accès au microphone\",\n        \"description\": \"Nécessaire pour entendre votre voix pour la transcription.\"\n      },\n      \"accessibility\": {\n        \"title\": \"Accès à l'accessibilité\",\n        \"description\": \"Nécessaire pour taper le texte transcrit dans vos applications.\"\n      },\n      \"grant\": \"Accorder l'autorisation\",\n      \"granted\": \"Accordé\",\n      \"waiting\": \"En attente...\",\n      \"allGranted\": \"Tout est prêt !\",\n      \"errors\": {\n        \"checkFailed\": \"Échec de la vérification des autorisations. Veuillez réessayer.\",\n        \"requestFailed\": \"Échec de la demande d'autorisation. Veuillez réessayer.\"\n      }\n    }\n  },\n  \"modelSelector\": {\n    \"custom\": \"Personnalisé\",\n    \"active\": \"Actif\",\n    \"switching\": \"Changement...\",\n    \"noModelsAvailable\": \"Aucun modèle disponible\",\n    \"extracting\": \"Extraction de {{modelName}}...\",\n    \"extractingMultiple\": \"Extraction de {{count}} modèles...\",\n    \"extractingGeneric\": \"Extraction...\",\n    \"downloading\": \"Téléchargement {{percentage}}%\",\n    \"downloadingMultiple\": \"Téléchargement de {{count}} modèles...\",\n    \"modelReady\": \"Modèle Prêt\",\n    \"loading\": \"Chargement de {{modelName}}...\",\n    \"loadingGeneric\": \"Chargement...\",\n    \"modelError\": \"Erreur du Modèle\",\n    \"modelUnloaded\": \"Modèle Déchargé\",\n    \"noModelDownloadRequired\": \"Aucun Modèle - Téléchargement Requis\",\n    \"deleteModel\": \"Supprimer {{modelName}}\",\n    \"downloadSpeed\": \"{{speed}} Mo/s\",\n    \"capabilities\": {\n      \"languageSelection\": \"Prend en charge plusieurs langues d'entrée\",\n      \"multiLanguage\": \"Multilingue\",\n      \"translation\": \"Peut traduire en anglais\",\n      \"translate\": \"Traduire en anglais\",\n      \"singleLanguage\": \"Prend en charge uniquement cette langue\",\n      \"languageOnly\": \"{{language}} uniquement\"\n    },\n    \"cancel\": \"Annuler\",\n    \"cancelDownload\": \"Annuler le téléchargement\"\n  },\n  \"settings\": {\n    \"modelSettings\": {\n      \"title\": \"Paramètres de {{model}}\",\n      \"noSettingsNeeded\": \"Ce modèle fonctionne automatiquement sans configuration nécessaire.\"\n    },\n    \"models\": {\n      \"title\": \"Modèles de Transcription\",\n      \"description\": \"Sélectionnez un modèle de transcription ou téléchargez des modèles supplémentaires. Différents modèles offrent différents niveaux de précision et de vitesse.\",\n      \"downloaded\": \"Téléchargés\",\n      \"available\": \"Disponibles au Téléchargement\",\n      \"deleteConfirm\": \"Êtes-vous sûr de vouloir supprimer {{modelName}} ? Vous devrez le télécharger à nouveau pour l'utiliser.\",\n      \"deleteActiveConfirm\": \"{{modelName}} est votre modèle actif. Le supprimer arrêtera les transcriptions jusqu'à ce que vous sélectionniez un nouveau modèle. Êtes-vous sûr ?\",\n      \"deleteTitle\": \"Supprimer le modèle\",\n      \"filters\": {\n        \"all\": \"Tous\",\n        \"multiLanguage\": \"Multilingue\",\n        \"translation\": \"Traduction\",\n        \"allLanguages\": \"Toutes les langues\"\n      },\n      \"noModelsMatch\": \"Aucun modèle ne correspond à ce filtre.\",\n      \"yourModels\": \"Modèles téléchargés\",\n      \"availableModels\": \"Disponibles au téléchargement\"\n    },\n    \"general\": {\n      \"title\": \"Général\",\n      \"shortcut\": {\n        \"title\": \"Raccourcis Handy\",\n        \"description\": \"Configurer les raccourcis clavier pour déclencher l'enregistrement de la reconnaissance vocale\",\n        \"loading\": \"Chargement des raccourcis...\",\n        \"none\": \"Aucun raccourci configuré\",\n        \"notFound\": \"Raccourci non trouvé\",\n        \"pressKeys\": \"Appuyez sur les touches...\",\n        \"bindings\": {\n          \"transcribe\": {\n            \"name\": \"Raccourci de Transcription\",\n            \"description\": \"Le raccourci clavier pour enregistrer et transcrire votre voix.\"\n          },\n          \"cancel\": {\n            \"name\": \"Raccourci d'Annulation\",\n            \"description\": \"Le raccourci clavier pour annuler l'enregistrement en cours.\"\n          },\n          \"transcribe_with_post_process\": {\n            \"name\": \"Raccourci de post-traitement\",\n            \"description\": \"Facultatif : Un raccourci dédié qui applique toujours le post-traitement IA à votre transcription.\"\n          }\n        },\n        \"errors\": {\n          \"restore\": \"Échec de la restauration du raccourci original\",\n          \"set\": \"Échec de la définition du raccourci : {{error}}\",\n          \"reset\": \"Échec de la réinitialisation du raccourci à sa valeur d'origine\"\n        }\n      },\n      \"language\": {\n        \"title\": \"Langue\",\n        \"description\": \"Sélectionnez la langue pour la reconnaissance vocale. Auto déterminera automatiquement la langue, tandis que sélectionner une langue spécifique peut améliorer la précision pour cette langue.\",\n        \"descriptionUnsupported\": \"Le modèle Parakeet détecte automatiquement la langue. Aucune sélection manuelle n'est nécessaire.\",\n        \"searchPlaceholder\": \"Rechercher des langues...\",\n        \"noResults\": \"Aucune langue trouvée\",\n        \"auto\": \"Auto\"\n      },\n      \"pushToTalk\": {\n        \"label\": \"Appuyer pour parler\",\n        \"description\": \"Maintenez pour enregistrer, relâchez pour arrêter\"\n      }\n    },\n    \"sound\": {\n      \"title\": \"Son\",\n      \"microphone\": {\n        \"title\": \"Microphone\",\n        \"description\": \"Sélectionnez votre périphérique d'entrée audio\",\n        \"placeholder\": \"Sélectionner un microphone...\",\n        \"loading\": \"Chargement...\"\n      },\n      \"audioFeedback\": {\n        \"label\": \"Signal sonore\",\n        \"description\": \"Jouer un son au début et à la fin de l'enregistrement\"\n      },\n      \"outputDevice\": {\n        \"title\": \"Périphérique de sortie\",\n        \"description\": \"Sélectionnez votre périphérique de sortie audio pour le signal sonore\",\n        \"placeholder\": \"Sélectionner un périphérique de sortie...\",\n        \"loading\": \"Chargement...\"\n      },\n      \"volume\": {\n        \"title\": \"Volume\",\n        \"description\": \"Ajuster le volume du signal sonore\"\n      }\n    },\n    \"advanced\": {\n      \"title\": \"Avancé\",\n      \"groups\": {\n        \"app\": \"Application\",\n        \"output\": \"Sortie\",\n        \"transcription\": \"Transcription\",\n        \"history\": \"Historique\",\n        \"experimental\": \"Expérimental\"\n      },\n      \"experimentalToggle\": {\n        \"label\": \"Fonctionnalités Expérimentales\",\n        \"description\": \"Activer les fonctionnalités expérimentales encore en développement.\"\n      },\n      \"lazyStreamClose\": {\n        \"label\": \"Garder le micro ouvert entre les transcriptions\",\n        \"description\": \"Maintient le flux du microphone ouvert pendant 30 secondes après l'arrêt de l'enregistrement, réduisant la latence pour les transcriptions consécutives. Peut dégrader la qualité audio Bluetooth.\"\n      },\n      \"acceleration\": {\n        \"whisper\": {\n          \"title\": \"Accélération Whisper\",\n          \"description\": \"Accélération matérielle pour les modèles Whisper. Auto utilise le GPU si disponible (Metal sur macOS, Vulkan sur Windows/Linux).\"\n        },\n        \"ort\": {\n          \"title\": \"Accélération ONNX\",\n          \"description\": \"Accélération matérielle pour les modèles ONNX (Parakeet, Canary, Moonshine, etc.). DirectML sur Windows est expérimental. Les modèles peuvent échouer à transcrire.\"\n        }\n      },\n      \"startHidden\": {\n        \"label\": \"Démarrer masqué\",\n        \"description\": \"Lancer dans la barre système sans ouvrir la fenêtre.\"\n      },\n      \"autostart\": {\n        \"label\": \"Lancer au démarrage\",\n        \"description\": \"Démarrer automatiquement Handy lorsque vous vous connectez à votre ordinateur.\"\n      },\n      \"showTrayIcon\": {\n        \"label\": \"Afficher l'icône de la barre\",\n        \"description\": \"Afficher l'icône de Handy dans la barre système.\"\n      },\n      \"overlay\": {\n        \"title\": \"Position de la fenêtre d'enregistrement\",\n        \"description\": \"Afficher un retour visuel pendant l'enregistrement et la transcription. Sur Linux, 'Aucune' est recommandé.\",\n        \"options\": {\n          \"none\": \"Aucune\",\n          \"bottom\": \"Bas\",\n          \"top\": \"Haut\"\n        }\n      },\n      \"pasteMethod\": {\n        \"title\": \"Méthode de collage\",\n        \"description\": \"Choisissez comment le texte est inséré. Direct : simule la frappe via l'entrée système. Aucun : ignore le collage, met uniquement à jour l'historique/presse-papiers.\",\n        \"options\": {\n          \"clipboard\": \"Presse-papiers ({{modifier}}+V)\",\n          \"clipboardCtrlShiftV\": \"Presse-papiers (Ctrl+Shift+V)\",\n          \"clipboardShiftInsert\": \"Presse-papiers (Shift+Insert)\",\n          \"direct\": \"Direct\",\n          \"none\": \"Aucun\",\n          \"externalScript\": \"Script externe\"\n        },\n        \"externalScriptPlaceholder\": \"/chemin/vers/votre/script.sh\"\n      },\n      \"typingTool\": {\n        \"title\": \"Outil de frappe\",\n        \"description\": \"Choisissez quel outil de frappe Linux utiliser pour la méthode de collage direct. Auto détectera et utilisera automatiquement le meilleur outil disponible pour votre système.\",\n        \"options\": {\n          \"auto\": \"Auto (Recommandé)\"\n        }\n      },\n      \"clipboardHandling\": {\n        \"title\": \"Gestion du presse-papiers\",\n        \"description\": \"Ne pas modifier le presse-papiers préserve le contenu actuel de votre presse-papiers après la transcription. Copier dans le presse-papiers laisse le résultat de la transcription dans votre presse-papiers après le collage.\",\n        \"options\": {\n          \"dontModify\": \"Ne pas modifier le presse-papiers\",\n          \"copyToClipboard\": \"Copier dans le presse-papiers\"\n        }\n      },\n      \"autoSubmit\": {\n        \"title\": \"Envoi automatique\",\n        \"description\": \"Envoie automatiquement la combinaison de touches sélectionnée après l'insertion du texte. Cmd+Enter s'applique sur macOS, tandis que Windows/Linux utilisent Super+Enter.\",\n        \"options\": {\n          \"off\": \"Désactivé\",\n          \"enter\": \"Entrée\",\n          \"cmdEnter\": \"Cmd+Enter\",\n          \"superEnter\": \"Super+Enter\",\n          \"ctrlEnter\": \"Ctrl+Enter\"\n        }\n      },\n      \"translateToEnglish\": {\n        \"label\": \"Traduire en anglais\",\n        \"description\": \"Traduire automatiquement la parole d'autres langues vers l'anglais pendant la transcription.\",\n        \"descriptionUnsupported\": \"La traduction n'est pas prise en charge par le modèle {{model}}.\"\n      },\n      \"modelUnload\": {\n        \"title\": \"Décharger le modèle\",\n        \"description\": \"Libérer automatiquement la mémoire GPU/CPU lorsque le modèle n'a pas été utilisé pendant le temps spécifié\",\n        \"options\": {\n          \"never\": \"Jamais\",\n          \"immediately\": \"Immédiatement\",\n          \"min2\": \"Après 2 minutes\",\n          \"min5\": \"Après 5 minutes\",\n          \"min10\": \"Après 10 minutes\",\n          \"min15\": \"Après 15 minutes\",\n          \"hour1\": \"Après 1 heure\",\n          \"sec15\": \"Après 15 secondes (Débogage)\"\n        }\n      },\n      \"customWords\": {\n        \"title\": \"Mots personnalisés\",\n        \"description\": \"Ajoutez des mots souvent mal entendus ou mal orthographiés lors de la transcription. Le système corrigera automatiquement les mots similaires pour correspondre à votre liste.\",\n        \"placeholder\": \"Ajouter un mot\",\n        \"add\": \"Ajouter\",\n        \"remove\": \"Supprimer {{word}}\",\n        \"duplicate\": \"\\\"{{word}}\\\" existe déjà\"\n      }\n    },\n    \"postProcessing\": {\n      \"title\": \"Post-traitement\",\n      \"hotkey\": {\n        \"title\": \"Raccourci clavier\"\n      },\n      \"api\": {\n        \"title\": \"API (Compatible OpenAI)\",\n        \"provider\": {\n          \"title\": \"Fournisseur\",\n          \"description\": \"Sélectionnez un fournisseur compatible OpenAI.\"\n        },\n        \"appleIntelligence\": {\n          \"title\": \"Apple Intelligence\",\n          \"description\": \"Fonctionne entièrement sur l'appareil. Aucune clé API ni accès réseau n'est requis.\",\n          \"requirements\": \"Nécessite un Mac Apple Silicon exécutant macOS Tahoe (26.0) ou une version ultérieure. Apple Intelligence doit être activé dans les Préférences Système.\",\n          \"unavailable\": \"Apple Intelligence n'est pas disponible sur cet appareil. Nécessite un Mac Apple Silicon exécutant macOS Tahoe (26.0) ou une version ultérieure avec Apple Intelligence activé dans les Préférences Système.\"\n        },\n        \"baseUrl\": {\n          \"title\": \"URL de base\",\n          \"description\": \"URL de base de l'API pour le fournisseur sélectionné. Seul le fournisseur personnalisé peut être modifié.\",\n          \"placeholder\": \"https://api.openai.com/v1\"\n        },\n        \"apiKey\": {\n          \"title\": \"Clé API\",\n          \"description\": \"Clé API pour le fournisseur sélectionné.\",\n          \"placeholder\": \"sk-...\"\n        },\n        \"model\": {\n          \"title\": \"Modèle\",\n          \"descriptionApple\": \"Fournissez une limite de tokens optionnelle ou conservez le préréglage par défaut sur l'appareil.\",\n          \"descriptionCustom\": \"Fournissez l'identifiant du modèle attendu par votre point de terminaison personnalisé.\",\n          \"descriptionDefault\": \"Choisissez un modèle exposé par le fournisseur sélectionné.\",\n          \"placeholderApple\": \"Apple Intelligence\",\n          \"placeholderWithOptions\": \"Rechercher ou sélectionner un modèle\",\n          \"placeholderNoOptions\": \"Tapez un nom de modèle\",\n          \"refreshModels\": \"Actualiser les modèles\"\n        }\n      },\n      \"prompts\": {\n        \"title\": \"Prompt\",\n        \"selectedPrompt\": {\n          \"title\": \"Prompt sélectionné\",\n          \"description\": \"Sélectionnez un modèle pour affiner les transcriptions ou créez-en un nouveau. Utilisez ${output} dans le texte du prompt pour référencer la transcription capturée.\"\n        },\n        \"noPrompts\": \"Aucun prompt disponible\",\n        \"selectPrompt\": \"Sélectionner un prompt\",\n        \"createNew\": \"Créer un nouveau prompt\",\n        \"promptLabel\": \"Libellé du prompt\",\n        \"promptLabelPlaceholder\": \"Entrez le nom du prompt\",\n        \"promptInstructions\": \"Instructions du prompt\",\n        \"promptInstructionsPlaceholder\": \"Écrivez les instructions à exécuter après la transcription. Exemple : Améliorer la grammaire et la clarté du texte suivant : ${output}\",\n        \"promptTip\": \"Astuce : Utilisez <code>${output}</code> pour insérer le texte transcrit dans votre prompt.\",\n        \"updatePrompt\": \"Mettre à jour le prompt\",\n        \"deletePrompt\": \"Supprimer le prompt\",\n        \"createPrompt\": \"Créer le prompt\",\n        \"cancel\": \"Annuler\",\n        \"selectToEdit\": \"Sélectionnez un prompt ci-dessus pour voir et modifier ses détails.\",\n        \"createFirst\": \"Cliquez sur 'Créer un nouveau prompt' ci-dessus pour créer votre premier prompt de post-traitement.\"\n      }\n    },\n    \"history\": {\n      \"title\": \"Historique\",\n      \"openFolder\": \"Ouvrir le dossier des enregistrements\",\n      \"loading\": \"Chargement de l'historique...\",\n      \"empty\": \"Pas encore de transcriptions. Commencez à enregistrer pour créer votre historique !\",\n      \"copyToClipboard\": \"Copier la transcription dans le presse-papiers\",\n      \"save\": \"Enregistrer la transcription\",\n      \"unsave\": \"Retirer des favoris\",\n      \"delete\": \"Supprimer l'entrée\",\n      \"deleteError\": \"Échec de la suppression de l'entrée. Veuillez réessayer.\"\n    },\n    \"debug\": {\n      \"title\": \"Débogage\",\n      \"logDirectory\": {\n        \"title\": \"Répertoire des journaux\",\n        \"description\": \"Emplacement où les fichiers journaux sont stockés\"\n      },\n      \"logLevel\": {\n        \"title\": \"Niveau de journalisation\",\n        \"description\": \"Définir le niveau de détail de la journalisation\"\n      },\n      \"updateChecks\": {\n        \"label\": \"Vérifier les mises à jour\",\n        \"description\": \"Vérifier automatiquement les nouvelles versions de Handy\"\n      },\n      \"soundTheme\": {\n        \"label\": \"Thème sonore\",\n        \"description\": \"Choisir un thème sonore pour les retours de début et de fin d'enregistrement\"\n      },\n      \"wordCorrectionThreshold\": {\n        \"title\": \"Seuil de correction des mots\",\n        \"description\": \"Sensibilité pour les corrections de mots personnalisés\"\n      },\n      \"historyLimit\": {\n        \"title\": \"Limite d'historique\",\n        \"description\": \"Nombre maximum d'entrées d'historique à conserver\",\n        \"entries\": \"entrées\"\n      },\n      \"recordingRetention\": {\n        \"title\": \"Suppression automatique des enregistrements\",\n        \"description\": \"Supprimer automatiquement les anciens enregistrements pour économiser de l'espace\",\n        \"never\": \"Jamais\",\n        \"preserveLimit\": \"Conserver les {{count}} plus récents\",\n        \"days3\": \"Après 3 jours\",\n        \"weeks2\": \"Après 2 semaines\",\n        \"months3\": \"Après 3 mois\",\n        \"placeholder\": \"Sélectionner la période de conservation...\"\n      },\n      \"alwaysOnMicrophone\": {\n        \"label\": \"Microphone toujours actif\",\n        \"description\": \"Garder le microphone actif pour une réponse plus rapide\"\n      },\n      \"clamshellMicrophone\": {\n        \"title\": \"Microphone en mode fermé\",\n        \"description\": \"Microphone à utiliser lorsque le couvercle du portable est fermé\"\n      },\n      \"postProcessingToggle\": {\n        \"label\": \"Post-traitement\",\n        \"description\": \"Activer l'affinage du texte par IA après la transcription\"\n      },\n      \"muteWhileRecording\": {\n        \"label\": \"Muet pendant l'enregistrement\",\n        \"description\": \"Couper le son du système pendant l'enregistrement\"\n      },\n      \"appendTrailingSpace\": {\n        \"label\": \"Ajouter une espace final\",\n        \"description\": \"Ajouter une espace après la transcription collée\"\n      },\n      \"keyboardImplementation\": {\n        \"title\": \"Gestion du clavier\",\n        \"description\": \"Choisissez le système des raccourcis clavier.\",\n        \"bindingsReset\": \"Raccourcis clavier étaient incompatibles et ont été réinitialisés aux valeurs par défaut\"\n      },\n      \"paths\": {\n        \"appData\": \"Données de l'application :\",\n        \"models\": \"Modèles :\",\n        \"settings\": \"Paramètres :\"\n      },\n      \"pasteDelay\": {\n        \"title\": \"Délai de collage\",\n        \"description\": \"Délai avant l'envoi de la touche de collage (en millisecondes). Augmentez si le mauvais texte est collé.\"\n      },\n      \"recordingBuffer\": {\n        \"title\": \"Tampon d'enregistrement supplémentaire\",\n        \"description\": \"Temps supplémentaire (en millisecondes) pour continuer l'enregistrement après avoir relâché la touche, pour capturer l'audio restant. 0 = pas de tampon supplémentaire.\"\n      }\n    },\n    \"about\": {\n      \"title\": \"À propos\",\n      \"version\": {\n        \"title\": \"Version\",\n        \"description\": \"Version actuelle de Handy\"\n      },\n      \"appDataDirectory\": {\n        \"title\": \"Emplacement des données de l'application\",\n        \"description\": \"Emplacement où Handy stocke ses données\"\n      },\n      \"sourceCode\": {\n        \"title\": \"Code source\",\n        \"description\": \"Voir le code source et contribuer\",\n        \"button\": \"Voir sur GitHub\"\n      },\n      \"supportDevelopment\": {\n        \"title\": \"Soutenir le développement\",\n        \"description\": \"Aidez-nous à continuer à construire Handy\",\n        \"button\": \"Faire un don\"\n      },\n      \"acknowledgments\": {\n        \"title\": \"Remerciements\",\n        \"whisper\": {\n          \"title\": \"Whisper.cpp\",\n          \"description\": \"Inférence haute performance du modèle de reconnaissance vocale automatique Whisper d'OpenAI\",\n          \"details\": \"Handy utilise Whisper.cpp pour un traitement rapide et local de la parole en texte. Merci au travail incroyable de Georgi Gerganov et des contributeurs.\"\n        }\n      }\n    }\n  },\n  \"footer\": {\n    \"downloadingModel\": \"Téléchargement de {{model}}...\",\n    \"checkingUpdates\": \"Recherche de mises à jour...\",\n    \"updateAvailable\": \"Mise à jour disponible : {{version}}\",\n    \"updateAvailableShort\": \"Mise à jour disponible\",\n    \"upToDate\": \"À jour\",\n    \"downloadUpdate\": \"Télécharger la mise à jour\",\n    \"restart\": \"Redémarrer\",\n    \"updateCheckingDisabled\": \"Vérification des mises à jour désactivée\",\n    \"downloading\": \"Téléchargement... {{progress}}%\",\n    \"installing\": \"Installation...\",\n    \"preparing\": \"Préparation...\",\n    \"checkForUpdates\": \"Rechercher des mises à jour\"\n  },\n  \"common\": {\n    \"loading\": \"Chargement...\",\n    \"save\": \"Enregistrer\",\n    \"cancel\": \"Annuler\",\n    \"reset\": \"Réinitialiser\",\n    \"add\": \"Ajouter\",\n    \"remove\": \"Supprimer\",\n    \"delete\": \"Supprimer\",\n    \"edit\": \"Modifier\",\n    \"create\": \"Créer\",\n    \"update\": \"Mettre à jour\",\n    \"close\": \"Fermer\",\n    \"open\": \"Ouvrir\",\n    \"default\": \"Par défaut\",\n    \"enabled\": \"Activé\",\n    \"disabled\": \"Désactivé\",\n    \"on\": \"Activé\",\n    \"off\": \"Désactivé\",\n    \"yes\": \"Oui\",\n    \"no\": \"Non\",\n    \"noOptionsFound\": \"Aucune option trouvée\"\n  },\n  \"accessibility\": {\n    \"permissionsRequired\": \"Autorisations d'accessibilité requises\",\n    \"permissionsDescription\": \"Handy a besoin des autorisations d'accessibilité pour taper le texte transcrit.\",\n    \"openSettings\": \"Ouvrir les Préférences Système\",\n    \"dismiss\": \"Ignorer\"\n  },\n  \"errors\": {\n    \"loadDirectory\": \"Erreur lors du chargement du répertoire : {{error}}\",\n    \"micPermissionDeniedTitle\": \"Accès au microphone refusé\",\n    \"micPermissionDenied\": {\n      \"generic\": \"L'accès au microphone a été refusé par le système d'exploitation. Veuillez accorder l'autorisation du microphone dans les paramètres système.\",\n      \"windows\": \"Activez l'accès au microphone dans Paramètres → Confidentialité et sécurité → Microphone (y compris l'accès des applications de bureau).\",\n      \"macos\": \"Accordez l'accès au microphone dans Réglages du système → Confidentialité et sécurité → Microphone.\",\n      \"linux\": \"Accordez l'accès au microphone dans les paramètres de son ou de confidentialité de votre système.\"\n    },\n    \"recordingFailed\": \"Échec du démarrage de l'enregistrement : {{error}}\",\n    \"modelLoadFailed\": \"Échec du chargement du modèle : {{model}}\",\n    \"modelLoadFailedUnknown\": \"modèle inconnu\"\n  },\n  \"appLanguage\": {\n    \"title\": \"Langue de l'application\",\n    \"description\": \"Changer la langue de l'interface de Handy\"\n  },\n  \"overlay\": {\n    \"transcribing\": \"Transcription...\",\n    \"processing\": \"Traitement...\"\n  }\n}\n"
  },
  {
    "path": "src/i18n/locales/it/translation.json",
    "content": "{\n  \"tray\": {\n    \"settings\": \"Impostazioni...\",\n    \"checkUpdates\": \"Verifica aggiornamenti...\",\n    \"copyLastTranscript\": \"Copia l'ultima trascrizione\",\n    \"unloadModel\": \"Rilascia modello\",\n    \"model\": \"Modello\",\n    \"quit\": \"Esci\",\n    \"cancel\": \"Annulla\"\n  },\n  \"sidebar\": {\n    \"general\": \"Generale\",\n    \"models\": \"Modelli\",\n    \"advanced\": \"Avanzate\",\n    \"postProcessing\": \"Post-Elaborazione\",\n    \"history\": \"Cronologia\",\n    \"debug\": \"Debug\",\n    \"about\": \"Informazioni\"\n  },\n  \"onboarding\": {\n    \"subtitle\": \"Per cominciare, scegli un modello di riconoscimento vocale\",\n    \"recommended\": \"Raccomandato\",\n    \"download\": \"Scarica\",\n    \"downloading\": \"Download in corso...\",\n    \"customModelDescription\": \"Non ufficialmente supportato\",\n    \"downloadFailed\": \"Download fallito. Riprova.\",\n    \"modelCard\": {\n      \"accuracy\": \"accuratezza\",\n      \"speed\": \"velocità\"\n    },\n    \"models\": {\n      \"small\": {\n        \"name\": \"Whisper Small\",\n        \"description\": \"Veloce e abbastanza accurato.\"\n      },\n      \"medium\": {\n        \"name\": \"Whisper Medium\",\n        \"description\": \"Buona accuratezza, velocità nella media\"\n      },\n      \"turbo\": {\n        \"name\": \"Whisper Turbo\",\n        \"description\": \"Bilanciato fra accuratezza e velocità.\"\n      },\n      \"large\": {\n        \"name\": \"Whisper Large\",\n        \"description\": \"Buona accuratezza, ma lento.\"\n      },\n      \"parakeet-tdt-0.6b-v2\": {\n        \"name\": \"Parakeet V2\",\n        \"description\": \"Solo inglese. Il miglior modello per chi parla inglese.\"\n      },\n      \"parakeet-tdt-0.6b-v3\": {\n        \"name\": \"Parakeet V3\",\n        \"description\": \"Veloce e accurato\"\n      },\n      \"moonshine-base\": {\n        \"name\": \"Moonshine Base\",\n        \"description\": \"Molto veloce, solo inglese. Gestisce bene gli accenti.\"\n      },\n      \"moonshine-tiny-streaming-en\": {\n        \"name\": \"Moonshine V2 Tiny\",\n        \"description\": \"Ultra-veloce, solo inglese\"\n      },\n      \"moonshine-small-streaming-en\": {\n        \"name\": \"Moonshine V2 Small\",\n        \"description\": \"Veloce, solo inglese. Buon equilibrio tra velocità e precisione.\"\n      },\n      \"moonshine-medium-streaming-en\": {\n        \"name\": \"Moonshine V2 Medium\",\n        \"description\": \"Solo inglese. Alta qualità.\"\n      },\n      \"breeze-asr\": {\n        \"name\": \"Breeze ASR\",\n        \"description\": \"Ottimizzato per il mandarino taiwanese. Supporto per il code-switching.\"\n      },\n      \"sense-voice-int8\": {\n        \"name\": \"SenseVoice\",\n        \"description\": \"Molto veloce. Cinese, inglese, giapponese, coreano, cantonese.\"\n      },\n      \"gigaam-v3-e2e-ctc\": {\n        \"name\": \"GigaAM v3\",\n        \"description\": \"Riconoscimento vocale in russo. Veloce e preciso.\"\n      },\n      \"canary-180m-flash\": {\n        \"name\": \"Canary 180M Flash\",\n        \"description\": \"Molto veloce. Inglese, tedesco, spagnolo, francese. Supporta la traduzione.\"\n      },\n      \"canary-1b-v2\": {\n        \"name\": \"Canary 1B v2\",\n        \"description\": \"Multilingue accurato. 25 lingue europee. Supporta la traduzione.\"\n      }\n    },\n    \"errors\": {\n      \"loadModels\": \"Errore di caricamento dei modelli disponibili\",\n      \"downloadModel\": \"Errore nel download del modello: {{error}}\"\n    },\n    \"permissions\": {\n      \"title\": \"Permessi Richiesti\",\n      \"description\": \"Handy ha bisogno di alcuni permessi per funzionare correttamente.\",\n      \"microphone\": {\n        \"title\": \"Accesso al Microfono\",\n        \"description\": \"Necessario per sentire la tua voce per la trascrizione.\"\n      },\n      \"accessibility\": {\n        \"title\": \"Accesso all'Accessibilità\",\n        \"description\": \"Necessario per digitare il testo trascritto nelle tue applicazioni.\"\n      },\n      \"grant\": \"Concedi Permesso\",\n      \"granted\": \"Concesso\",\n      \"waiting\": \"In attesa...\",\n      \"allGranted\": \"Tutto pronto!\",\n      \"errors\": {\n        \"checkFailed\": \"Impossibile verificare i permessi. Riprova.\",\n        \"requestFailed\": \"Impossibile richiedere il permesso. Riprova.\"\n      }\n    }\n  },\n  \"modelSelector\": {\n    \"custom\": \"Personalizzato\",\n    \"active\": \"Attivo\",\n    \"switching\": \"Cambio...\",\n    \"noModelsAvailable\": \"Nessun modello disponibile\",\n    \"extracting\": \"Estrazione di {{modelName}}...\",\n    \"extractingMultiple\": \"Estrazione di {{count}} modelli...\",\n    \"extractingGeneric\": \"Estrazione...\",\n    \"downloading\": \"Download {{percentage}}%\",\n    \"downloadingMultiple\": \"Download di {{count}} modelli...\",\n    \"modelReady\": \"Modello Pronto\",\n    \"loading\": \"Caricamento di {{modelName}}...\",\n    \"loadingGeneric\": \"Caricamento...\",\n    \"modelError\": \"Errore del Modello\",\n    \"modelUnloaded\": \"Modello Disattivato\",\n    \"noModelDownloadRequired\": \"Nessun Modello - Download Richiesto\",\n    \"deleteModel\": \"Elimina {{modelName}}\",\n    \"downloadSpeed\": \"{{speed}} MB/s\",\n    \"capabilities\": {\n      \"languageSelection\": \"Supporta più lingue di input\",\n      \"multiLanguage\": \"Multilingua\",\n      \"translation\": \"Può tradurre in inglese\",\n      \"translate\": \"Traduci in inglese\",\n      \"singleLanguage\": \"Supporta solo questa lingua\",\n      \"languageOnly\": \"Solo {{language}}\"\n    },\n    \"cancel\": \"Annulla\",\n    \"cancelDownload\": \"Annulla download\"\n  },\n  \"settings\": {\n    \"modelSettings\": {\n      \"title\": \"Impostazioni di {{model}}\",\n      \"noSettingsNeeded\": \"Questo modello funziona automaticamente senza bisogno di configurazione.\"\n    },\n    \"models\": {\n      \"title\": \"Modelli di Trascrizione\",\n      \"description\": \"Seleziona un modello di trascrizione o scarica modelli aggiuntivi. Diversi modelli offrono diversi livelli di accuratezza e velocità.\",\n      \"downloaded\": \"Scaricati\",\n      \"available\": \"Disponibili per il Download\",\n      \"deleteConfirm\": \"Sei sicuro di voler eliminare {{modelName}}? Dovrai scaricarlo di nuovo per usarlo.\",\n      \"deleteActiveConfirm\": \"{{modelName}} è il tuo modello attivo. Eliminarlo interromperà le trascrizioni fino a quando non selezionerai un nuovo modello. Sei sicuro?\",\n      \"deleteTitle\": \"Elimina modello\",\n      \"filters\": {\n        \"all\": \"Tutti\",\n        \"multiLanguage\": \"Multilingua\",\n        \"translation\": \"Traduzione\",\n        \"allLanguages\": \"Tutte le lingue\"\n      },\n      \"noModelsMatch\": \"Nessun modello corrisponde a questo filtro.\",\n      \"yourModels\": \"Modelli scaricati\",\n      \"availableModels\": \"Disponibili per il Download\"\n    },\n    \"general\": {\n      \"title\": \"Generale\",\n      \"shortcut\": {\n        \"title\": \"Scorciatoie di Handy\",\n        \"description\": \"Configura le scorciatoie per attivare la trascrizione vocale\",\n        \"loading\": \"Caricamento delle scorciatoie...\",\n        \"none\": \"Nessuna scorciatoia configurata\",\n        \"notFound\": \"Scorciatoia non trovata\",\n        \"pressKeys\": \"Premi i tasti...\",\n        \"bindings\": {\n          \"transcribe\": {\n            \"name\": \"Scorciatoia Trascrizione\",\n            \"description\": \"La scorciatoia da tastiera per registrare e trascrivere la tua voce.\"\n          },\n          \"cancel\": {\n            \"name\": \"Scorciatoia Annulla\",\n            \"description\": \"La scorciatoia da tastiera per annullare la registrazione in corso.\"\n          },\n          \"transcribe_with_post_process\": {\n            \"name\": \"Tasto di post-elaborazione\",\n            \"description\": \"Facoltativo: Un tasto di scelta rapida dedicato che applica sempre la post-elaborazione IA alla trascrizione.\"\n          }\n        },\n        \"errors\": {\n          \"restore\": \"Errore nel ripristino della scorciatoia originale\",\n          \"set\": \"Errore nella configurazione della scorciatoia: {{error}}\",\n          \"reset\": \"Errore nella reinizializzazione della scorciatoia al valore originale\"\n        }\n      },\n      \"language\": {\n        \"title\": \"Lingua\",\n        \"description\": \"Scegli la lingua per il riconoscimento vocale. Auto la determinerà automaticamente, mentre scegliere una lingua specifica può migliorare l'accuratezza per quella lingua.\",\n        \"descriptionUnsupported\": \"Il modello Parakeet sceglie automaticamente la lingua. Non serve scegliere manualmente.\",\n        \"searchPlaceholder\": \"Cerca lingue...\",\n        \"noResults\": \"Nessuna lingua trovata\",\n        \"auto\": \"Auto\"\n      },\n      \"pushToTalk\": {\n        \"label\": \"Premi per Parlare\",\n        \"description\": \"Tieni premuto per parlare, rilascia per interrompere\"\n      }\n    },\n    \"sound\": {\n      \"title\": \"Suono\",\n      \"microphone\": {\n        \"title\": \"Microfono\",\n        \"description\": \"Scegli il microfono preferito\",\n        \"placeholder\": \"Scegli microfono...\",\n        \"loading\": \"Caricamento...\"\n      },\n      \"audioFeedback\": {\n        \"label\": \"Feedback Audio\",\n        \"description\": \"Riproduci un suono quando la registrazione inizia e finisce\"\n      },\n      \"outputDevice\": {\n        \"title\": \"Dispositivo di Output\",\n        \"description\": \"Scegli il dispositivo di output per il feedback audio\",\n        \"placeholder\": \"Scegli dispositivo di output...\",\n        \"loading\": \"Caricamento...\"\n      },\n      \"volume\": {\n        \"title\": \"Volume\",\n        \"description\": \"Regola il volume del feedback audio\"\n      }\n    },\n    \"advanced\": {\n      \"title\": \"Avanzate\",\n      \"groups\": {\n        \"app\": \"App\",\n        \"output\": \"Output\",\n        \"transcription\": \"Trascrizione\",\n        \"history\": \"Cronologia\",\n        \"experimental\": \"Sperimentale\"\n      },\n      \"experimentalToggle\": {\n        \"label\": \"Funzionalità Sperimentali\",\n        \"description\": \"Abilita le funzionalità sperimentali ancora in fase di sviluppo.\"\n      },\n      \"lazyStreamClose\": {\n        \"label\": \"Mantieni il microfono aperto tra le trascrizioni\",\n        \"description\": \"Mantiene lo stream del microfono aperto per 30 secondi dopo l'arresto della registrazione, riducendo la latenza per trascrizioni consecutive. Potrebbe degradare la qualità audio Bluetooth.\"\n      },\n      \"acceleration\": {\n        \"whisper\": {\n          \"title\": \"Accelerazione Whisper\",\n          \"description\": \"Accelerazione hardware per i modelli Whisper. Auto utilizza la GPU se disponibile (Metal su macOS, Vulkan su Windows/Linux).\"\n        },\n        \"ort\": {\n          \"title\": \"Accelerazione ONNX\",\n          \"description\": \"Accelerazione hardware per i modelli ONNX (Parakeet, Canary, Moonshine, ecc.). DirectML su Windows è sperimentale. I modelli potrebbero non riuscire a trascrivere.\"\n        }\n      },\n      \"startHidden\": {\n        \"label\": \"Avvia in Background\",\n        \"description\": \"Avvia l'applicazione in background senza aprire la finestra.\"\n      },\n      \"autostart\": {\n        \"label\": \"Avvia all'Accensione\",\n        \"description\": \"Avvia Handy automaticamente quando accedi al computer.\"\n      },\n      \"showTrayIcon\": {\n        \"label\": \"Mostra icona nella barra di sistema\",\n        \"description\": \"Mostra l'icona di Handy nella barra di sistema.\"\n      },\n      \"overlay\": {\n        \"title\": \"Posizione della Sovrimpressione\",\n        \"description\": \"Mostra un feedback visivo in sovrimpressione durante la registrazione e la trascrizione. Su Linux si raccomanda 'Nessuna'.\",\n        \"options\": {\n          \"none\": \"Nessuna\",\n          \"bottom\": \"In basso\",\n          \"top\": \"In alto\"\n        }\n      },\n      \"pasteMethod\": {\n        \"title\": \"Metodo di Incolla\",\n        \"description\": \"Scegli come viene inserito il testo. Diretto: simula l'input da tastiera. Nessuno: non incolla, aggiorna solo la cronologia/appunti.\",\n        \"options\": {\n          \"clipboard\": \"Appunti ({{modifier}}+V)\",\n          \"clipboardCtrlShiftV\": \"Appunti (Ctrl+Shift+V)\",\n          \"clipboardShiftInsert\": \"Appunti (Shift+Insert)\",\n          \"direct\": \"Diretto\",\n          \"none\": \"Nessuno\",\n          \"externalScript\": \"Script esterno\"\n        },\n        \"externalScriptPlaceholder\": \"/percorso/del/tuo/script.sh\"\n      },\n      \"typingTool\": {\n        \"title\": \"Strumento di digitazione\",\n        \"description\": \"Scegli quale strumento di digitazione Linux usare per il metodo di incolla diretto. Auto rileverà e userà automaticamente lo strumento migliore disponibile per il tuo sistema.\",\n        \"options\": {\n          \"auto\": \"Auto (Consigliato)\"\n        }\n      },\n      \"clipboardHandling\": {\n        \"title\": \"Gestione Appunti\",\n        \"description\": \"Non Modificare gli Appunti mantiene il contenuto dei tuoi appunti dopo la trascrizione. Copia negli Appunti lascia il risultato della trascrizione negli appunti dopo aver incollato.\",\n        \"options\": {\n          \"dontModify\": \"Non Modificare gli Appunti\",\n          \"copyToClipboard\": \"Copia negli Appunti\"\n        }\n      },\n      \"autoSubmit\": {\n        \"title\": \"Invio automatico\",\n        \"description\": \"Invia automaticamente la combinazione di tasti selezionata dopo l'inserimento del testo. Cmd+Enter si applica su macOS, mentre Windows/Linux usano Super+Enter.\",\n        \"options\": {\n          \"off\": \"Disattivato\",\n          \"enter\": \"Enter\",\n          \"cmdEnter\": \"Cmd+Enter\",\n          \"superEnter\": \"Super+Enter\",\n          \"ctrlEnter\": \"Ctrl+Enter\"\n        }\n      },\n      \"translateToEnglish\": {\n        \"label\": \"Traduci in inglese\",\n        \"description\": \"Traduci automaticamente in inglese la voce in altre lingue durante la trascrizione.\",\n        \"descriptionUnsupported\": \"La traduzione non è supportata dal modello {{model}}.\"\n      },\n      \"modelUnload\": {\n        \"title\": \"Disattiva Model\",\n        \"description\": \"Libera automaticamente la memoria della GPU/CPU quando il modello non viene utilizzato per un certo periodo\",\n        \"options\": {\n          \"never\": \"Mai\",\n          \"immediately\": \"Immediatamente\",\n          \"min2\": \"Dopo 2 minuti\",\n          \"min5\": \"Dopo 5 minuti\",\n          \"min10\": \"Dopo 10 minuti\",\n          \"min15\": \"Dopo 15 minuti\",\n          \"hour1\": \"Dopo 1 ora\",\n          \"sec15\": \"Dopo 15 secondi (Debug)\"\n        }\n      },\n      \"customWords\": {\n        \"title\": \"Parole personalizzate\",\n        \"description\": \"Aggiungi parole che vengono spesso fraintese o scritte in modo errato durante la trascrizione. Il sistema correggerà automaticamente le parole dal suono simile in modo che corrispondano al tuo elenco.\",\n        \"placeholder\": \"Aggiungi una parola\",\n        \"add\": \"Aggiungi\",\n        \"remove\": \"Rimuovi {{word}}\",\n        \"duplicate\": \"\\\"{{word}}\\\" esiste già\"\n      }\n    },\n    \"postProcessing\": {\n      \"title\": \"Post-Elaborazione\",\n      \"hotkey\": {\n        \"title\": \"Tasto di scelta rapida\"\n      },\n      \"api\": {\n        \"title\": \"API (Compatibile con OpenAI)\",\n        \"provider\": {\n          \"title\": \"Provider\",\n          \"description\": \"Seleziona un provider compatibile con OpenAI.\"\n        },\n        \"appleIntelligence\": {\n          \"title\": \"Apple Intelligence\",\n          \"description\": \"Si esegue completamente in locale. Non è necessaria una chiave API né l'accesso alla rete.\",\n          \"requirements\": \"Necessita di un Mac con Apple Silicon e macOS Tahoe (26.0) o successivi. Apple Intelligence deve essere abilitata nelle Impostazioni di Sistema.\",\n          \"unavailable\": \"Apple Intelligence non è disponibile su questo dispositivo. Necessita di un Mac con Apple Silicon e macOS Tahoe (26.0) o successivi con Apple Intelligence abilitata nelle Impostazioni di Sistema.\"\n        },\n        \"baseUrl\": {\n          \"title\": \"URL Base\",\n          \"description\": \"URL di base per l'API del provider selezionato. Solo per i provider personalizzati.\",\n          \"placeholder\": \"https://api.openai.com/v1\"\n        },\n        \"apiKey\": {\n          \"title\": \"Chiave API\",\n          \"description\": \"Chiave API per il provider selezionato.\",\n          \"placeholder\": \"sk-...\"\n        },\n        \"model\": {\n          \"title\": \"Modello\",\n          \"descriptionApple\": \"Fornisci un limite numerico facoltativo per i token o mantieni l'impostazione predefinita sul dispositivo.\",\n          \"descriptionCustom\": \"Fornisci l'identificatore del modello previsto dal tuo endpoint personalizzato.\",\n          \"descriptionDefault\": \"Scegli un modello reso disponibile dal provider selezionato.\",\n          \"placeholderApple\": \"Apple Intelligence\",\n          \"placeholderWithOptions\": \"Cerca o scegli un modello\",\n          \"placeholderNoOptions\": \"Digita il nome di un modello\",\n          \"refreshModels\": \"Aggiorna modelli\"\n        }\n      },\n      \"prompts\": {\n        \"title\": \"Prompt\",\n        \"selectedPrompt\": {\n          \"title\": \"Prompt Selezionato\",\n          \"description\": \"Seleziona un template per migliorare le trascrizioni o creane uno nuovo. Usa ${output} nel prompt per fare riferimento alla trascrizione.\"\n        },\n        \"noPrompts\": \"Nessun prompt disponibile\",\n        \"selectPrompt\": \"Scegli un prompt\",\n        \"createNew\": \"Crea un nuovo prompt\",\n        \"promptLabel\": \"Etichetta del Prompt\",\n        \"promptLabelPlaceholder\": \"Inserisci il nome del prompt\",\n        \"promptInstructions\": \"Istruzioni del Prompt\",\n        \"promptInstructionsPlaceholder\": \"Scrivi le istruzioni da eseguire dopo la trascrizione. Esempio: Migliora la grammatica e la chiarezza per il seguente testo: ${output}\",\n        \"promptTip\": \"Suggerimento: Usa <code>${output}</code> per inserire il testo trascritto nel tuo prompt.\",\n        \"updatePrompt\": \"Aggiorna Prompt\",\n        \"deletePrompt\": \"Elimina Prompt\",\n        \"createPrompt\": \"Crea Prompt\",\n        \"cancel\": \"Annulla\",\n        \"selectToEdit\": \"Scegli un prompt qui sopra per visualizzare o modificare i dettagli.\",\n        \"createFirst\": \"Clicca 'Crea un nuovo prompt' qui sopra per creare il tuo primo prompt di post-elaborazione.\"\n      }\n    },\n    \"history\": {\n      \"title\": \"Cronologia\",\n      \"openFolder\": \"Apri la cartella delle registrazioni\",\n      \"loading\": \"Caricamento cronologia...\",\n      \"empty\": \"Non ci sono ancora trascrizioni. Comincia a registrare per costruire la tua cronologia!\",\n      \"copyToClipboard\": \"Copia la trascrizione negli appunti\",\n      \"save\": \"Salva la trascrizione\",\n      \"unsave\": \"Rimuovi dai salvataggi\",\n      \"delete\": \"Elimina elemento\",\n      \"deleteError\": \"Errore nell'eliminazione dell'elemento. Riprova.\"\n    },\n    \"debug\": {\n      \"title\": \"Debug\",\n      \"logDirectory\": {\n        \"title\": \"Cartella dei Log\",\n        \"description\": \"Cartella dove vengono salvati i file di log\"\n      },\n      \"logLevel\": {\n        \"title\": \"Livello di Log\",\n        \"description\": \"Scegli il livello di dettaglio dei log\"\n      },\n      \"updateChecks\": {\n        \"label\": \"Controlla aggiornamenti\",\n        \"description\": \"Controlla automaticamente la disponibilità di nuove versioni di Handy\"\n      },\n      \"soundTheme\": {\n        \"label\": \"Tema Sonoro\",\n        \"description\": \"Scegli un tema sonoro per il feedback di inizio e fine registrazione\"\n      },\n      \"wordCorrectionThreshold\": {\n        \"title\": \"Soglia di Correzione Parole\",\n        \"description\": \"Sensibilità per la correzione delle parole personalizzate\"\n      },\n      \"historyLimit\": {\n        \"title\": \"Limite della Cronologia\",\n        \"description\": \"Massimo numero di elementi da conservare nella cronologia\",\n        \"entries\": \"elementi\"\n      },\n      \"recordingRetention\": {\n        \"title\": \"Eliminazione Automatica Registrazioni\",\n        \"description\": \"Elimina automaticamente le registrazioni vecchie per liberare spazio\",\n        \"never\": \"Mai\",\n        \"preserveLimit\": \"Conserva le ultime {{count}}\",\n        \"days3\": \"Dopo 3 giorni\",\n        \"weeks2\": \"Dopo 2 settimane\",\n        \"months3\": \"Dopo 3 mesi\",\n        \"placeholder\": \"Seleziona periodo di conservazione...\"\n      },\n      \"alwaysOnMicrophone\": {\n        \"label\": \"Microfono Sempre Attivo\",\n        \"description\": \"Tieni il microfono attivo per una risposta più rapida\"\n      },\n      \"clamshellMicrophone\": {\n        \"title\": \"Microfono a portatile chiuso\",\n        \"description\": \"Microfono da usare quando il portatile è chiuso\"\n      },\n      \"postProcessingToggle\": {\n        \"label\": \"Post-Elaborazione\",\n        \"description\": \"Abilita il miglioramento della trascrizione con IA\"\n      },\n      \"muteWhileRecording\": {\n        \"label\": \"Silenzia durante la registrazione\",\n        \"description\": \"Silenzia l'audio di sistema durante la registrazione\"\n      },\n      \"appendTrailingSpace\": {\n        \"label\": \"Aggiungi Spazio Finale\",\n        \"description\": \"Aggiungi uno spazio dopo la trascrizione incollata\"\n      },\n      \"keyboardImplementation\": {\n        \"title\": \"Implementazione tastiera\",\n        \"description\": \"Scegli il backend per le scorciatoie da tastiera.\",\n        \"bindingsReset\": \"Le scorciatoie da tastiera erano incompatibili e sono state ripristinate ai valori predefiniti\"\n      },\n      \"paths\": {\n        \"appData\": \"Dati App:\",\n        \"models\": \"Modelli:\",\n        \"settings\": \"Impostazioni:\"\n      },\n      \"pasteDelay\": {\n        \"title\": \"Ritardo incolla\",\n        \"description\": \"Ritardo prima dell'invio del tasto incolla (in millisecondi). Aumentare se viene incollato il testo sbagliato.\"\n      },\n      \"recordingBuffer\": {\n        \"title\": \"Buffer di registrazione extra\",\n        \"description\": \"Tempo extra (in millisecondi) per continuare a registrare dopo aver rilasciato il tasto, per catturare l'audio finale. 0 = nessun buffer extra.\"\n      }\n    },\n    \"about\": {\n      \"title\": \"Informazioni\",\n      \"version\": {\n        \"title\": \"Versione\",\n        \"description\": \"Versione attuale di Handy\"\n      },\n      \"appDataDirectory\": {\n        \"title\": \"Cartella Dati App\",\n        \"description\": \"Cartella in cui Handy salva i dati\"\n      },\n      \"sourceCode\": {\n        \"title\": \"Codice Sorgente\",\n        \"description\": \"Vedi e contribuisci al codice sorgente\",\n        \"button\": \"Vedi su GitHub\"\n      },\n      \"supportDevelopment\": {\n        \"title\": \"Supporta lo Sviluppo\",\n        \"description\": \"Aiutaci a sviluppare Handy\",\n        \"button\": \"Dona\"\n      },\n      \"acknowledgments\": {\n        \"title\": \"Ringraziamenti\",\n        \"whisper\": {\n          \"title\": \"Whisper.cpp\",\n          \"description\": \"Inferenza ad alte prestazioni del modello di riconoscimento vocale automatico Whisper di OpenAI\",\n          \"details\": \"Handy usa Whisper.cpp per il riconoscimento vocale veloce in locale. Grazie a Georgi Gerganov e collaboratori per il fantastico lavoro.\"\n        }\n      }\n    }\n  },\n  \"footer\": {\n    \"downloadingModel\": \"Download di {{model}}...\",\n    \"checkingUpdates\": \"Controllo aggiornamenti...\",\n    \"updateAvailable\": \"Aggiornamento disponibile: {{version}}\",\n    \"updateAvailableShort\": \"Aggiornamento disponibile\",\n    \"upToDate\": \"Aggiornato\",\n    \"downloadUpdate\": \"Scarica Aggiornamento\",\n    \"restart\": \"Riavvia\",\n    \"updateCheckingDisabled\": \"Controllo Aggiornamenti Disabilitato\",\n    \"downloading\": \"Download... {{progress}}%\",\n    \"installing\": \"Installazione...\",\n    \"preparing\": \"Preparazione...\",\n    \"checkForUpdates\": \"Controlla aggiornamenti\"\n  },\n  \"common\": {\n    \"loading\": \"Caricamento...\",\n    \"save\": \"Salva\",\n    \"cancel\": \"Annulla\",\n    \"reset\": \"Reimposta\",\n    \"add\": \"Aggiungi\",\n    \"remove\": \"Rimuovi\",\n    \"delete\": \"Elimina\",\n    \"edit\": \"Modifica\",\n    \"create\": \"Crea\",\n    \"update\": \"Aggiorna\",\n    \"close\": \"Chiudi\",\n    \"open\": \"Apri\",\n    \"default\": \"Predefinito\",\n    \"enabled\": \"Abilitato\",\n    \"disabled\": \"Disabilitato\",\n    \"on\": \"On\",\n    \"off\": \"Off\",\n    \"yes\": \"Sì\",\n    \"no\": \"No\",\n    \"noOptionsFound\": \"Nessuna opzione trovata\"\n  },\n  \"accessibility\": {\n    \"permissionsRequired\": \"Richiesti Permessi di Accessibilità\",\n    \"permissionsDescription\": \"Handy ha bisogno dei permessi di accessibilità per digitare il testo trascritto.\",\n    \"openSettings\": \"Apri Impostazioni di Sistema\",\n    \"dismiss\": \"Ignora\"\n  },\n  \"errors\": {\n    \"loadDirectory\": \"Errore di caricamento cartella: {{error}}\",\n    \"micPermissionDeniedTitle\": \"Accesso al microfono negato\",\n    \"micPermissionDenied\": {\n      \"generic\": \"L'accesso al microfono è stato negato dal sistema operativo. Concedi l'autorizzazione al microfono nelle impostazioni di sistema.\",\n      \"windows\": \"Abilita l'accesso al microfono in Impostazioni → Privacy e sicurezza → Microfono (incluso l'accesso delle app desktop).\",\n      \"macos\": \"Concedi l'accesso al microfono in Impostazioni di Sistema → Privacy e sicurezza → Microfono.\",\n      \"linux\": \"Concedi l'accesso al microfono nelle impostazioni audio o sulla privacy del tuo sistema.\"\n    },\n    \"recordingFailed\": \"Impossibile avviare la registrazione: {{error}}\",\n    \"modelLoadFailed\": \"Impossibile caricare il modello: {{model}}\",\n    \"modelLoadFailedUnknown\": \"modello sconosciuto\"\n  },\n  \"appLanguage\": {\n    \"title\": \"Lingua Applicazione\",\n    \"description\": \"Cambia la lingua dell'interfaccia di Handy\"\n  },\n  \"overlay\": {\n    \"transcribing\": \"Trascrizione...\",\n    \"processing\": \"Elaborazione...\"\n  }\n}\n"
  },
  {
    "path": "src/i18n/locales/ja/translation.json",
    "content": "{\n  \"tray\": {\n    \"settings\": \"設定...\",\n    \"checkUpdates\": \"アップデートを確認...\",\n    \"copyLastTranscript\": \"最新の文字起こしをコピー\",\n    \"unloadModel\": \"モデルをアンロード\",\n    \"model\": \"モデル\",\n    \"quit\": \"終了\",\n    \"cancel\": \"キャンセル\"\n  },\n  \"sidebar\": {\n    \"general\": \"一般\",\n    \"models\": \"モデル\",\n    \"advanced\": \"詳細設定\",\n    \"postProcessing\": \"後処理\",\n    \"history\": \"履歴\",\n    \"debug\": \"デバッグ\",\n    \"about\": \"概要\"\n  },\n  \"onboarding\": {\n    \"subtitle\": \"開始するには、文字起こしモデルを選択してください\",\n    \"recommended\": \"おすすめ\",\n    \"download\": \"ダウンロード\",\n    \"downloading\": \"ダウンロード中...\",\n    \"customModelDescription\": \"公式サポート対象外\",\n    \"downloadFailed\": \"ダウンロードに失敗しました。もう一度お試しください。\",\n    \"modelCard\": {\n      \"accuracy\": \"精度\",\n      \"speed\": \"速度\"\n    },\n    \"models\": {\n      \"small\": {\n        \"name\": \"Whisper Small\",\n        \"description\": \"高速でそこそこ正確。\"\n      },\n      \"medium\": {\n        \"name\": \"Whisper Medium\",\n        \"description\": \"良好な精度、中程度の速度\"\n      },\n      \"turbo\": {\n        \"name\": \"Whisper Turbo\",\n        \"description\": \"精度と速度のバランスが良い。\"\n      },\n      \"large\": {\n        \"name\": \"Whisper Large\",\n        \"description\": \"良好な精度、ただし低速。\"\n      },\n      \"parakeet-tdt-0.6b-v2\": {\n        \"name\": \"Parakeet V2\",\n        \"description\": \"英語のみ。英語話者に最適なモデル。\"\n      },\n      \"parakeet-tdt-0.6b-v3\": {\n        \"name\": \"Parakeet V3\",\n        \"description\": \"高速で正確\"\n      },\n      \"moonshine-base\": {\n        \"name\": \"Moonshine Base\",\n        \"description\": \"非常に高速、英語のみ。アクセントにも対応。\"\n      },\n      \"moonshine-tiny-streaming-en\": {\n        \"name\": \"Moonshine V2 Tiny\",\n        \"description\": \"超高速、英語のみ\"\n      },\n      \"moonshine-small-streaming-en\": {\n        \"name\": \"Moonshine V2 Small\",\n        \"description\": \"高速、英語のみ。速度と精度のバランスが良い。\"\n      },\n      \"moonshine-medium-streaming-en\": {\n        \"name\": \"Moonshine V2 Medium\",\n        \"description\": \"英語のみ。高品質。\"\n      },\n      \"breeze-asr\": {\n        \"name\": \"Breeze ASR\",\n        \"description\": \"台湾華語に最適化。コードスイッチングに対応。\"\n      },\n      \"sense-voice-int8\": {\n        \"name\": \"SenseVoice\",\n        \"description\": \"非常に高速。中国語、英語、日本語、韓国語、広東語。\"\n      },\n      \"gigaam-v3-e2e-ctc\": {\n        \"name\": \"GigaAM v3\",\n        \"description\": \"ロシア語音声認識。高速かつ高精度。\"\n      },\n      \"canary-180m-flash\": {\n        \"name\": \"Canary 180M Flash\",\n        \"description\": \"非常に高速。英語、ドイツ語、スペイン語、フランス語。翻訳対応。\"\n      },\n      \"canary-1b-v2\": {\n        \"name\": \"Canary 1B v2\",\n        \"description\": \"高精度な多言語対応。25のヨーロッパ言語。翻訳対応。\"\n      }\n    },\n    \"errors\": {\n      \"loadModels\": \"利用可能なモデルの読み込みに失敗しました\",\n      \"downloadModel\": \"モデルのダウンロードに失敗しました: {{error}}\"\n    },\n    \"permissions\": {\n      \"title\": \"権限が必要です\",\n      \"description\": \"Handyが正常に動作するにはいくつかの権限が必要です。\",\n      \"microphone\": {\n        \"title\": \"マイクへのアクセス\",\n        \"description\": \"文字起こしのためにあなたの声を聞くために必要です。\"\n      },\n      \"accessibility\": {\n        \"title\": \"アクセシビリティへのアクセス\",\n        \"description\": \"アプリケーションに文字起こしテキストを入力するために必要です。\"\n      },\n      \"grant\": \"権限を許可\",\n      \"granted\": \"許可済み\",\n      \"waiting\": \"待機中...\",\n      \"allGranted\": \"準備完了！\",\n      \"errors\": {\n        \"checkFailed\": \"権限の確認に失敗しました。もう一度お試しください。\",\n        \"requestFailed\": \"権限のリクエストに失敗しました。もう一度お試しください。\"\n      }\n    }\n  },\n  \"modelSelector\": {\n    \"custom\": \"カスタム\",\n    \"active\": \"アクティブ\",\n    \"switching\": \"切替中...\",\n    \"noModelsAvailable\": \"利用可能なモデルがありません\",\n    \"extracting\": \"{{modelName}}を展開中...\",\n    \"extractingMultiple\": \"{{count}}個のモデルを展開中...\",\n    \"extractingGeneric\": \"展開中...\",\n    \"downloading\": \"ダウンロード中 {{percentage}}%\",\n    \"downloadingMultiple\": \"{{count}}個のモデルをダウンロード中...\",\n    \"modelReady\": \"モデル準備完了\",\n    \"loading\": \"{{modelName}}を読み込み中...\",\n    \"loadingGeneric\": \"読み込み中...\",\n    \"modelError\": \"モデルエラー\",\n    \"modelUnloaded\": \"モデルがアンロードされました\",\n    \"noModelDownloadRequired\": \"モデルなし - ダウンロードが必要\",\n    \"deleteModel\": \"{{modelName}}を削除\",\n    \"downloadSpeed\": \"{{speed}} MB/s\",\n    \"capabilities\": {\n      \"languageSelection\": \"複数の入力言語をサポート\",\n      \"multiLanguage\": \"多言語\",\n      \"translation\": \"英語への翻訳が可能\",\n      \"translate\": \"英語に翻訳\",\n      \"singleLanguage\": \"この言語のみ対応\",\n      \"languageOnly\": \"{{language}}のみ\"\n    },\n    \"cancel\": \"キャンセル\",\n    \"cancelDownload\": \"ダウンロードをキャンセル\"\n  },\n  \"settings\": {\n    \"modelSettings\": {\n      \"title\": \"{{model}} 設定\",\n      \"noSettingsNeeded\": \"このモデルは設定不要で自動的に動作します。\"\n    },\n    \"models\": {\n      \"title\": \"転写モデル\",\n      \"description\": \"転写モデルを選択するか、追加のモデルをダウンロードします。異なるモデルは精度と速度のレベルが異なります。\",\n      \"downloaded\": \"ダウンロード済み\",\n      \"available\": \"ダウンロード可能\",\n      \"deleteConfirm\": \"{{modelName}}を削除してもよろしいですか？再度使用するにはダウンロードが必要です。\",\n      \"deleteActiveConfirm\": \"{{modelName}}は現在使用中のモデルです。削除すると、新しいモデルを選択するまで文字起こしが停止します。本当に削除しますか？\",\n      \"deleteTitle\": \"モデルを削除\",\n      \"filters\": {\n        \"all\": \"すべて\",\n        \"multiLanguage\": \"多言語\",\n        \"translation\": \"翻訳\",\n        \"allLanguages\": \"すべての言語\"\n      },\n      \"noModelsMatch\": \"このフィルターに一致するモデルがありません。\",\n      \"yourModels\": \"ダウンロード済みモデル\",\n      \"availableModels\": \"ダウンロード可能\"\n    },\n    \"general\": {\n      \"title\": \"一般\",\n      \"shortcut\": {\n        \"title\": \"Handyショートカット\",\n        \"description\": \"音声録音を開始するキーボードショートカットを設定\",\n        \"loading\": \"ショートカットを読み込み中...\",\n        \"none\": \"ショートカットが設定されていません\",\n        \"notFound\": \"ショートカットが見つかりません\",\n        \"pressKeys\": \"キーを押してください...\",\n        \"bindings\": {\n          \"transcribe\": {\n            \"name\": \"文字起こしショートカット\",\n            \"description\": \"音声を録音して文字起こしするためのキーボードショートカット。\"\n          },\n          \"cancel\": {\n            \"name\": \"キャンセルショートカット\",\n            \"description\": \"現在の録音をキャンセルするためのキーボードショートカット。\"\n          },\n          \"transcribe_with_post_process\": {\n            \"name\": \"後処理ホットキー\",\n            \"description\": \"オプション：文字起こしに常にAI後処理を適用する専用ホットキー。\"\n          }\n        },\n        \"errors\": {\n          \"restore\": \"元のショートカットを復元できませんでした\",\n          \"set\": \"ショートカットを設定できませんでした: {{error}}\",\n          \"reset\": \"ショートカットを元の値にリセットできませんでした\"\n        }\n      },\n      \"language\": {\n        \"title\": \"言語\",\n        \"description\": \"音声認識の言語を選択してください。自動を選択すると言語を自動的に判定します。特定の言語を選択すると、その言語の精度が向上する場合があります。\",\n        \"descriptionUnsupported\": \"Parakeetモデルは言語を自動的に検出します。手動選択は不要です。\",\n        \"searchPlaceholder\": \"言語を検索...\",\n        \"noResults\": \"言語が見つかりません\",\n        \"auto\": \"自動\"\n      },\n      \"pushToTalk\": {\n        \"label\": \"プッシュトゥトーク\",\n        \"description\": \"押し続けて録音、離して停止\"\n      }\n    },\n    \"sound\": {\n      \"title\": \"サウンド\",\n      \"microphone\": {\n        \"title\": \"マイク\",\n        \"description\": \"使用するマイクデバイスを選択\",\n        \"placeholder\": \"マイクを選択...\",\n        \"loading\": \"読み込み中...\"\n      },\n      \"audioFeedback\": {\n        \"label\": \"音声フィードバック\",\n        \"description\": \"録音の開始と停止時にサウンドを再生\"\n      },\n      \"outputDevice\": {\n        \"title\": \"出力デバイス\",\n        \"description\": \"フィードバックサウンド用の音声出力デバイスを選択\",\n        \"placeholder\": \"出力デバイスを選択...\",\n        \"loading\": \"読み込み中...\"\n      },\n      \"volume\": {\n        \"title\": \"音量\",\n        \"description\": \"音声フィードバックの音量を調整\"\n      }\n    },\n    \"advanced\": {\n      \"title\": \"詳細設定\",\n      \"groups\": {\n        \"app\": \"アプリ\",\n        \"output\": \"出力\",\n        \"transcription\": \"文字起こし\",\n        \"history\": \"履歴\",\n        \"experimental\": \"実験的\"\n      },\n      \"experimentalToggle\": {\n        \"label\": \"実験的機能\",\n        \"description\": \"まだ開発中の実験的機能を有効にします。\"\n      },\n      \"lazyStreamClose\": {\n        \"label\": \"文字起こし間でマイクを開いたままにする\",\n        \"description\": \"録音停止後30秒間マイクストリームを開いたままにし、連続した文字起こしの遅延を軽減します。Bluetooth音声品質に影響する場合があります。\"\n      },\n      \"acceleration\": {\n        \"whisper\": {\n          \"title\": \"Whisper アクセラレーション\",\n          \"description\": \"Whisper モデルのハードウェアアクセラレーション。自動モードでは利用可能な場合 GPU を使用します（macOS では Metal、Windows/Linux では Vulkan）。\"\n        },\n        \"ort\": {\n          \"title\": \"ONNX アクセラレーション\",\n          \"description\": \"ONNX モデルのハードウェアアクセラレーション（Parakeet、Canary、Moonshine など）。Windows の DirectML は実験的です。モデルの文字起こしに失敗する場合があります。\"\n        }\n      },\n      \"startHidden\": {\n        \"label\": \"非表示で起動\",\n        \"description\": \"ウィンドウを開かずにシステムトレイに起動。\"\n      },\n      \"autostart\": {\n        \"label\": \"起動時に実行\",\n        \"description\": \"コンピューターにログインしたときにHandyを自動的に起動。\"\n      },\n      \"showTrayIcon\": {\n        \"label\": \"トレイアイコンを表示\",\n        \"description\": \"システムトレイにHandyのアイコンを表示します。\"\n      },\n      \"overlay\": {\n        \"title\": \"オーバーレイ位置\",\n        \"description\": \"録音と文字起こし中に視覚的なフィードバックオーバーレイを表示。Linuxでは「なし」を推奨。\",\n        \"options\": {\n          \"none\": \"なし\",\n          \"bottom\": \"下\",\n          \"top\": \"上\"\n        }\n      },\n      \"pasteMethod\": {\n        \"title\": \"貼り付け方法\",\n        \"description\": \"テキストの挿入方法を選択。直接：システム入力でタイピングをシミュレート。なし：貼り付けをスキップし、履歴/クリップボードのみ更新。\",\n        \"options\": {\n          \"clipboard\": \"クリップボード ({{modifier}}+V)\",\n          \"clipboardCtrlShiftV\": \"クリップボード (Ctrl+Shift+V)\",\n          \"clipboardShiftInsert\": \"クリップボード (Shift+Insert)\",\n          \"direct\": \"直接\",\n          \"none\": \"なし\",\n          \"externalScript\": \"外部スクリプト\"\n        },\n        \"externalScriptPlaceholder\": \"/path/to/your/script.sh\"\n      },\n      \"typingTool\": {\n        \"title\": \"タイピングツール\",\n        \"description\": \"直接貼り付け方式で使用する Linux のタイピングツールを選択します。Auto は自動的に最適なツールを検出して使用します。\",\n        \"options\": {\n          \"auto\": \"Auto（推奨）\"\n        }\n      },\n      \"clipboardHandling\": {\n        \"title\": \"クリップボードの処理\",\n        \"description\": \"クリップボードを変更しないを選択すると、文字起こし後も現在のクリップボード内容が保持されます。クリップボードにコピーを選択すると、貼り付け後も文字起こし結果がクリップボードに残ります。\",\n        \"options\": {\n          \"dontModify\": \"クリップボードを変更しない\",\n          \"copyToClipboard\": \"クリップボードにコピー\"\n        }\n      },\n      \"autoSubmit\": {\n        \"title\": \"自動送信\",\n        \"description\": \"テキスト挿入後に選択したキーの組み合わせを自動的に送信します。macOSではCmd+Enter、Windows/LinuxではSuper+Enterが適用されます。\",\n        \"options\": {\n          \"off\": \"オフ\",\n          \"enter\": \"Enter\",\n          \"cmdEnter\": \"Cmd+Enter\",\n          \"superEnter\": \"Super+Enter\",\n          \"ctrlEnter\": \"Ctrl+Enter\"\n        }\n      },\n      \"translateToEnglish\": {\n        \"label\": \"英語に翻訳\",\n        \"description\": \"文字起こし中に他の言語から英語に自動的に翻訳。\",\n        \"descriptionUnsupported\": \"翻訳は{{model}}モデルではサポートされていません。\"\n      },\n      \"modelUnload\": {\n        \"title\": \"モデルのアンロード\",\n        \"description\": \"指定時間モデルが使用されていない場合、GPU/CPUメモリを自動的に解放\",\n        \"options\": {\n          \"never\": \"しない\",\n          \"immediately\": \"即座に\",\n          \"min2\": \"2分後\",\n          \"min5\": \"5分後\",\n          \"min10\": \"10分後\",\n          \"min15\": \"15分後\",\n          \"hour1\": \"1時間後\",\n          \"sec15\": \"15秒後（デバッグ）\"\n        }\n      },\n      \"customWords\": {\n        \"title\": \"カスタム単語\",\n        \"description\": \"よく誤認識または誤入力される単語を追加します。システムは自動的に類似した発音の単語をリストに合わせて修正します。\",\n        \"placeholder\": \"単語を追加\",\n        \"add\": \"追加\",\n        \"remove\": \"{{word}}を削除\",\n        \"duplicate\": \"「{{word}}」は既に存在します\"\n      }\n    },\n    \"postProcessing\": {\n      \"title\": \"後処理\",\n      \"hotkey\": {\n        \"title\": \"ホットキー\"\n      },\n      \"api\": {\n        \"title\": \"API（OpenAI互換）\",\n        \"provider\": {\n          \"title\": \"プロバイダー\",\n          \"description\": \"OpenAI互換のプロバイダーを選択。\"\n        },\n        \"appleIntelligence\": {\n          \"title\": \"Apple Intelligence\",\n          \"description\": \"完全にデバイス上で動作。APIキーやネットワークアクセスは不要。\",\n          \"requirements\": \"macOS Tahoe（26.0）以降を実行するApple Silicon Macが必要です。システム設定でApple Intelligenceを有効にする必要があります。\",\n          \"unavailable\": \"このデバイスではApple Intelligenceを利用できません。macOS Tahoe（26.0）以降を実行し、システム設定でApple Intelligenceが有効になっているApple Silicon Macが必要です。\"\n        },\n        \"baseUrl\": {\n          \"title\": \"ベースURL\",\n          \"description\": \"選択したプロバイダーのAPIベースURL。カスタムプロバイダーのみ編集可能。\",\n          \"placeholder\": \"https://api.openai.com/v1\"\n        },\n        \"apiKey\": {\n          \"title\": \"APIキー\",\n          \"description\": \"選択したプロバイダーのAPIキー。\",\n          \"placeholder\": \"sk-...\"\n        },\n        \"model\": {\n          \"title\": \"モデル\",\n          \"descriptionApple\": \"オプションの数値トークン制限を指定するか、デフォルトのオンデバイスプリセットを使用。\",\n          \"descriptionCustom\": \"カスタムエンドポイントが期待するモデル識別子を指定。\",\n          \"descriptionDefault\": \"選択したプロバイダーが提供するモデルを選択。\",\n          \"placeholderApple\": \"Apple Intelligence\",\n          \"placeholderWithOptions\": \"モデルを検索または選択\",\n          \"placeholderNoOptions\": \"モデル名を入力\",\n          \"refreshModels\": \"モデルを更新\"\n        }\n      },\n      \"prompts\": {\n        \"title\": \"プロンプト\",\n        \"selectedPrompt\": {\n          \"title\": \"選択したプロンプト\",\n          \"description\": \"文字起こしを改善するテンプレートを選択するか、新しく作成します。プロンプトテキスト内で${output}を使用して、キャプチャした文字起こしを参照します。\"\n        },\n        \"noPrompts\": \"プロンプトがありません\",\n        \"selectPrompt\": \"プロンプトを選択\",\n        \"createNew\": \"新しいプロンプトを作成\",\n        \"promptLabel\": \"プロンプト名\",\n        \"promptLabelPlaceholder\": \"プロンプト名を入力\",\n        \"promptInstructions\": \"プロンプトの指示\",\n        \"promptInstructionsPlaceholder\": \"文字起こし後に実行する指示を記述します。例：以下のテキストの文法と明瞭さを改善してください: ${output}\",\n        \"promptTip\": \"ヒント：<code>${output}</code>を使用して、文字起こしテキストをプロンプトに挿入します。\",\n        \"updatePrompt\": \"プロンプトを更新\",\n        \"deletePrompt\": \"プロンプトを削除\",\n        \"createPrompt\": \"プロンプトを作成\",\n        \"cancel\": \"キャンセル\",\n        \"selectToEdit\": \"上からプロンプトを選択して、詳細を表示・編集します。\",\n        \"createFirst\": \"上の「新しいプロンプトを作成」をクリックして、最初の後処理プロンプトを作成してください。\"\n      }\n    },\n    \"history\": {\n      \"title\": \"履歴\",\n      \"openFolder\": \"録音フォルダを開く\",\n      \"loading\": \"履歴を読み込み中...\",\n      \"empty\": \"まだ文字起こしがありません。録音を開始して履歴を作成しましょう！\",\n      \"copyToClipboard\": \"文字起こしをクリップボードにコピー\",\n      \"save\": \"文字起こしを保存\",\n      \"unsave\": \"保存から削除\",\n      \"delete\": \"エントリーを削除\",\n      \"deleteError\": \"エントリーの削除に失敗しました。もう一度お試しください。\"\n    },\n    \"debug\": {\n      \"title\": \"デバッグ\",\n      \"logDirectory\": {\n        \"title\": \"ログディレクトリ\",\n        \"description\": \"ログファイルの保存場所\"\n      },\n      \"logLevel\": {\n        \"title\": \"ログレベル\",\n        \"description\": \"ログの詳細度を設定\"\n      },\n      \"updateChecks\": {\n        \"label\": \"アップデートを確認\",\n        \"description\": \"Handyの新しいバージョンを自動的にチェック\"\n      },\n      \"soundTheme\": {\n        \"label\": \"サウンドテーマ\",\n        \"description\": \"録音開始・停止フィードバックのサウンドテーマを選択\"\n      },\n      \"wordCorrectionThreshold\": {\n        \"title\": \"単語修正しきい値\",\n        \"description\": \"カスタム単語修正の感度\"\n      },\n      \"historyLimit\": {\n        \"title\": \"履歴上限\",\n        \"description\": \"保持する履歴エントリーの最大数\",\n        \"entries\": \"件\"\n      },\n      \"recordingRetention\": {\n        \"title\": \"録音の自動削除\",\n        \"description\": \"古い録音を自動的に削除して容量を節約\",\n        \"never\": \"しない\",\n        \"preserveLimit\": \"最新{{count}}件を保持\",\n        \"days3\": \"3日後\",\n        \"weeks2\": \"2週間後\",\n        \"months3\": \"3ヶ月後\",\n        \"placeholder\": \"保持期間を選択...\"\n      },\n      \"alwaysOnMicrophone\": {\n        \"label\": \"マイク常時オン\",\n        \"description\": \"より速い応答のためにマイクをアクティブに保つ\"\n      },\n      \"clamshellMicrophone\": {\n        \"title\": \"クラムシェルマイク\",\n        \"description\": \"ノートパソコンの蓋を閉じたときに使用するマイク\"\n      },\n      \"postProcessingToggle\": {\n        \"label\": \"後処理\",\n        \"description\": \"文字起こし後のAIによるテキスト改善を有効化\"\n      },\n      \"muteWhileRecording\": {\n        \"label\": \"録音中にミュート\",\n        \"description\": \"録音中にシステムオーディオをミュート\"\n      },\n      \"appendTrailingSpace\": {\n        \"label\": \"末尾にスペースを追加\",\n        \"description\": \"貼り付けた文字起こしの後にスペースを追加\"\n      },\n      \"keyboardImplementation\": {\n        \"title\": \"キーボード実装\",\n        \"description\": \"キーボードショートカットのバックエンドを選択してください。\",\n        \"bindingsReset\": \"キーボードショートカットに互換性がなかったため、デフォルトにリセットされました\"\n      },\n      \"paths\": {\n        \"appData\": \"アプリデータ:\",\n        \"models\": \"モデル:\",\n        \"settings\": \"設定:\"\n      },\n      \"pasteDelay\": {\n        \"title\": \"貼り付け遅延\",\n        \"description\": \"貼り付けキー送信前の遅延（ミリ秒）。間違ったテキストが貼り付けられる場合は増やしてください。\"\n      },\n      \"recordingBuffer\": {\n        \"title\": \"追加録音バッファ\",\n        \"description\": \"キーを離した後に録音を続ける追加時間（ミリ秒）。末尾の音声を捕捉するため。0 = 追加バッファなし。\"\n      }\n    },\n    \"about\": {\n      \"title\": \"概要\",\n      \"version\": {\n        \"title\": \"バージョン\",\n        \"description\": \"Handyの現在のバージョン\"\n      },\n      \"appDataDirectory\": {\n        \"title\": \"アプリデータディレクトリ\",\n        \"description\": \"Handyがデータを保存する場所\"\n      },\n      \"sourceCode\": {\n        \"title\": \"ソースコード\",\n        \"description\": \"ソースコードを見て貢献する\",\n        \"button\": \"GitHubで見る\"\n      },\n      \"supportDevelopment\": {\n        \"title\": \"開発を支援\",\n        \"description\": \"Handyの開発を支援してください\",\n        \"button\": \"寄付する\"\n      },\n      \"acknowledgments\": {\n        \"title\": \"謝辞\",\n        \"whisper\": {\n          \"title\": \"Whisper.cpp\",\n          \"description\": \"OpenAIのWhisper自動音声認識モデルの高性能推論\",\n          \"details\": \"Handyは高速でローカルな音声からテキストへの変換にWhisper.cppを使用しています。Georgi Gerganov氏と貢献者の皆様の素晴らしい仕事に感謝します。\"\n        }\n      }\n    }\n  },\n  \"footer\": {\n    \"downloadingModel\": \"{{model}}をダウンロード中...\",\n    \"checkingUpdates\": \"アップデートを確認中...\",\n    \"updateAvailable\": \"アップデートあり: {{version}}\",\n    \"updateAvailableShort\": \"アップデートあり\",\n    \"upToDate\": \"最新です\",\n    \"downloadUpdate\": \"アップデートをダウンロード\",\n    \"restart\": \"再起動\",\n    \"updateCheckingDisabled\": \"アップデート確認無効\",\n    \"downloading\": \"ダウンロード中... {{progress}}%\",\n    \"installing\": \"インストール中...\",\n    \"preparing\": \"準備中...\",\n    \"checkForUpdates\": \"アップデートを確認\"\n  },\n  \"common\": {\n    \"loading\": \"読み込み中...\",\n    \"save\": \"保存\",\n    \"cancel\": \"キャンセル\",\n    \"reset\": \"リセット\",\n    \"add\": \"追加\",\n    \"remove\": \"削除\",\n    \"delete\": \"削除\",\n    \"edit\": \"編集\",\n    \"create\": \"作成\",\n    \"update\": \"更新\",\n    \"close\": \"閉じる\",\n    \"open\": \"開く\",\n    \"default\": \"デフォルト\",\n    \"enabled\": \"有効\",\n    \"disabled\": \"無効\",\n    \"on\": \"オン\",\n    \"off\": \"オフ\",\n    \"yes\": \"はい\",\n    \"no\": \"いいえ\",\n    \"noOptionsFound\": \"オプションが見つかりません\"\n  },\n  \"accessibility\": {\n    \"permissionsRequired\": \"アクセシビリティ権限が必要です\",\n    \"permissionsDescription\": \"Handyが文字起こしテキストを入力するには、アクセシビリティ権限が必要です。\",\n    \"openSettings\": \"システム設定を開く\",\n    \"dismiss\": \"閉じる\"\n  },\n  \"errors\": {\n    \"loadDirectory\": \"ディレクトリの読み込みエラー: {{error}}\",\n    \"micPermissionDeniedTitle\": \"マイクへのアクセスが拒否されました\",\n    \"micPermissionDenied\": {\n      \"generic\": \"オペレーティングシステムによりマイクへのアクセスが拒否されました。システム設定でマイクの許可を付与してください。\",\n      \"windows\": \"設定 → プライバシーとセキュリティ → マイク（デスクトップアプリのアクセスを含む）でマイクへのアクセスを有効にしてください。\",\n      \"macos\": \"システム設定 → プライバシーとセキュリティ → マイクでマイクへのアクセスを許可してください。\",\n      \"linux\": \"システムのサウンドまたはプライバシー設定でマイクへのアクセスを許可してください。\"\n    },\n    \"recordingFailed\": \"録音の開始に失敗しました: {{error}}\",\n    \"modelLoadFailed\": \"モデルの読み込みに失敗しました: {{model}}\",\n    \"modelLoadFailedUnknown\": \"不明なモデル\"\n  },\n  \"appLanguage\": {\n    \"title\": \"アプリケーション言語\",\n    \"description\": \"Handyインターフェースの言語を変更\"\n  },\n  \"overlay\": {\n    \"transcribing\": \"文字起こし中...\",\n    \"processing\": \"処理中...\"\n  }\n}\n"
  },
  {
    "path": "src/i18n/locales/ko/translation.json",
    "content": "{\n  \"tray\": {\n    \"settings\": \"설정...\",\n    \"checkUpdates\": \"업데이트 확인...\",\n    \"copyLastTranscript\": \"마지막 녹음 내용 복사\",\n    \"unloadModel\": \"모델 언로드\",\n    \"model\": \"모델\",\n    \"quit\": \"종료\",\n    \"cancel\": \"취소\"\n  },\n  \"sidebar\": {\n    \"general\": \"일반\",\n    \"models\": \"모델\",\n    \"advanced\": \"고급\",\n    \"postProcessing\": \"후처리\",\n    \"history\": \"히스토리\",\n    \"debug\": \"디버그\",\n    \"about\": \"정보\"\n  },\n  \"onboarding\": {\n    \"subtitle\": \"시작하려면 음성 인식 모델을 선택하세요\",\n    \"recommended\": \"추천\",\n    \"download\": \"다운로드\",\n    \"downloading\": \"다운로드 중...\",\n    \"customModelDescription\": \"공식 지원되지 않음\",\n    \"downloadFailed\": \"다운로드에 실패했습니다. 다시 시도해주세요.\",\n    \"modelCard\": {\n      \"accuracy\": \"정확도\",\n      \"speed\": \"속도\"\n    },\n    \"models\": {\n      \"small\": {\n        \"name\": \"Whisper Small\",\n        \"description\": \"빠르고 상당히 정확합니다.\"\n      },\n      \"medium\": {\n        \"name\": \"Whisper Medium\",\n        \"description\": \"좋은 정확도, 중간 속도\"\n      },\n      \"turbo\": {\n        \"name\": \"Whisper Turbo\",\n        \"description\": \"정확도와 속도의 균형.\"\n      },\n      \"large\": {\n        \"name\": \"Whisper Large\",\n        \"description\": \"좋은 정확도지만 느립니다.\"\n      },\n      \"parakeet-tdt-0.6b-v2\": {\n        \"name\": \"Parakeet V2\",\n        \"description\": \"영어 전용. 영어 사용자를 위한 최고의 모델.\"\n      },\n      \"parakeet-tdt-0.6b-v3\": {\n        \"name\": \"Parakeet V3\",\n        \"description\": \"빠르고 정확함\"\n      },\n      \"moonshine-base\": {\n        \"name\": \"Moonshine Base\",\n        \"description\": \"매우 빠름, 영어 전용. 억양을 잘 처리합니다.\"\n      },\n      \"moonshine-tiny-streaming-en\": {\n        \"name\": \"Moonshine V2 Tiny\",\n        \"description\": \"초고속, 영어 전용\"\n      },\n      \"moonshine-small-streaming-en\": {\n        \"name\": \"Moonshine V2 Small\",\n        \"description\": \"빠름, 영어 전용. 속도와 정확도의 균형이 좋음.\"\n      },\n      \"moonshine-medium-streaming-en\": {\n        \"name\": \"Moonshine V2 Medium\",\n        \"description\": \"영어 전용. 높은 품질.\"\n      },\n      \"breeze-asr\": {\n        \"name\": \"Breeze ASR\",\n        \"description\": \"대만 만다린에 최적화. 코드 스위칭 지원.\"\n      },\n      \"sense-voice-int8\": {\n        \"name\": \"SenseVoice\",\n        \"description\": \"매우 빠름. 중국어, 영어, 일본어, 한국어, 광둥어.\"\n      },\n      \"gigaam-v3-e2e-ctc\": {\n        \"name\": \"GigaAM v3\",\n        \"description\": \"러시아어 음성 인식. 빠르고 정확함.\"\n      },\n      \"canary-180m-flash\": {\n        \"name\": \"Canary 180M Flash\",\n        \"description\": \"매우 빠름. 영어, 독일어, 스페인어, 프랑스어. 번역 지원.\"\n      },\n      \"canary-1b-v2\": {\n        \"name\": \"Canary 1B v2\",\n        \"description\": \"정확한 다국어 지원. 25개 유럽 언어. 번역 지원.\"\n      }\n    },\n    \"errors\": {\n      \"loadModels\": \"사용 가능한 모델을 불러오는데 실패했습니다\",\n      \"downloadModel\": \"모델 다운로드 실패: {{error}}\"\n    },\n    \"permissions\": {\n      \"title\": \"권한 필요\",\n      \"description\": \"Handy가 제대로 작동하려면 몇 가지 권한이 필요합니다.\",\n      \"microphone\": {\n        \"title\": \"마이크 접근\",\n        \"description\": \"음성 인식을 위해 마이크 접근 권한이 필요합니다.\"\n      },\n      \"accessibility\": {\n        \"title\": \"접근성 권한\",\n        \"description\": \"녹음된 텍스트를 애플리케이션에 입력하기 위해 필요합니다.\"\n      },\n      \"grant\": \"권한 부여\",\n      \"granted\": \"승인됨\",\n      \"waiting\": \"대기 중...\",\n      \"allGranted\": \"모두 설정 완료!\",\n      \"errors\": {\n        \"checkFailed\": \"권한 확인에 실패했습니다. 다시 시도해주세요.\",\n        \"requestFailed\": \"권한 요청에 실패했습니다. 다시 시도해주세요.\"\n      }\n    }\n  },\n  \"modelSelector\": {\n    \"custom\": \"사용자 정의\",\n    \"active\": \"활성\",\n    \"switching\": \"전환 중...\",\n    \"noModelsAvailable\": \"사용 가능한 모델이 없습니다\",\n    \"extracting\": \"{{modelName}} 압축 해제 중...\",\n    \"extractingMultiple\": \"{{count}}개 모델 압축 해제 중...\",\n    \"extractingGeneric\": \"압축 해제 중...\",\n    \"downloading\": \"다운로드 중 {{percentage}}%\",\n    \"downloadingMultiple\": \"{{count}}개 모델 다운로드 중...\",\n    \"modelReady\": \"모델 준비 완료\",\n    \"loading\": \"{{modelName}} 로딩 중...\",\n    \"loadingGeneric\": \"로딩 중...\",\n    \"modelError\": \"모델 오류\",\n    \"modelUnloaded\": \"모델 언로드됨\",\n    \"noModelDownloadRequired\": \"모델 없음 - 다운로드 필요\",\n    \"deleteModel\": \"{{modelName}} 삭제\",\n    \"downloadSpeed\": \"{{speed}} MB/s\",\n    \"cancel\": \"취소\",\n    \"cancelDownload\": \"다운로드 취소\",\n    \"capabilities\": {\n      \"languageSelection\": \"여러 입력 언어를 지원합니다\",\n      \"singleLanguage\": \"이 언어만 지원합니다\",\n      \"multiLanguage\": \"다국어\",\n      \"languageOnly\": \"{{language}} 전용\",\n      \"translation\": \"영어로 번역 가능\",\n      \"translate\": \"영어로 번역\"\n    }\n  },\n  \"settings\": {\n    \"modelSettings\": {\n      \"title\": \"{{model}} 설정\",\n      \"noSettingsNeeded\": \"이 모델은 별도의 설정 없이 자동으로 작동합니다.\"\n    },\n    \"general\": {\n      \"title\": \"일반\",\n      \"shortcut\": {\n        \"title\": \"Handy 단축키\",\n        \"description\": \"음성-텍스트 녹음을 시작하는 키보드 단축키를 설정하세요\",\n        \"loading\": \"단축키 로딩 중...\",\n        \"none\": \"설정된 단축키 없음\",\n        \"notFound\": \"단축키를 찾을 수 없음\",\n        \"pressKeys\": \"키를 눌러주세요...\",\n        \"bindings\": {\n          \"transcribe\": {\n            \"name\": \"음성 텍스트 변환 단축키\",\n            \"description\": \"음성을 녹음하고 테스트로 변환하는 키보드 단축키입니다.\"\n          },\n          \"cancel\": {\n            \"name\": \"취소 단축키\",\n            \"description\": \"현재 녹음을 취소하는 키보드 단축키입니다.\"\n          },\n          \"transcribe_with_post_process\": {\n            \"name\": \"후처리 단축키\",\n            \"description\": \"선택 사항: 항상 AI 후처리를 적용하는 전용 단축키입니다.\"\n          }\n        },\n        \"errors\": {\n          \"restore\": \"원래 단축키 복원에 실패했습니다\",\n          \"set\": \"단축키 설정 실패: {{error}}\",\n          \"reset\": \"단축키를 원래 값으로 재설정하는데 실패했습니다\"\n        }\n      },\n      \"language\": {\n        \"title\": \"언어\",\n        \"description\": \"음성 인식 언어를 선택하세요. 자동은 언어를 자동으로 감지하며, 특정 언어를 선택하면 해당 언어의 정확도가 향상될 수 있습니다.\",\n        \"descriptionUnsupported\": \"Parakeet 모델은 자동으로 언어를 감지합니다. 수동 선택이 필요하지 않습니다.\",\n        \"searchPlaceholder\": \"언어 검색...\",\n        \"noResults\": \"언어를 찾을 수 없습니다\",\n        \"auto\": \"자동\"\n      },\n      \"pushToTalk\": {\n        \"label\": \"녹음 중 단축키 홀딩\",\n        \"description\": \"누르고 있으면 녹음, 놓으면 정지\"\n      }\n    },\n    \"sound\": {\n      \"title\": \"사운드\",\n      \"microphone\": {\n        \"title\": \"마이크\",\n        \"description\": \"선호하는 마이크 장치를 선택하세요\",\n        \"placeholder\": \"마이크 선택...\",\n        \"loading\": \"로딩 중...\"\n      },\n      \"audioFeedback\": {\n        \"label\": \"오디오 피드백\",\n        \"description\": \"녹음 시작 및 정지 시 소리 재생\"\n      },\n      \"outputDevice\": {\n        \"title\": \"출력 장치\",\n        \"description\": \"피드백 사운드를 위한 오디오 출력 장치를 선택하세요\",\n        \"placeholder\": \"출력 장치 선택...\",\n        \"loading\": \"로딩 중...\"\n      },\n      \"volume\": {\n        \"title\": \"볼륨\",\n        \"description\": \"오디오 피드백 사운드의 볼륨 조절\"\n      }\n    },\n    \"models\": {\n      \"title\": \"음성 인식 모델\",\n      \"description\": \"음성 인식 모델을 선택하거나 추가 모델을 다운로드하세요. 모델마다 정확도와 속도가 다릅니다.\",\n      \"yourModels\": \"다운로드된 모델\",\n      \"availableModels\": \"다운로드 가능\",\n      \"downloaded\": \"다운로드됨\",\n      \"available\": \"다운로드 가능\",\n      \"deleteConfirm\": \"{{modelName}}을(를) 삭제하시겠습니까? 다시 사용하려면 다운로드해야 합니다.\",\n      \"deleteActiveConfirm\": \"{{modelName}}은(는) 현재 활성 모델입니다. 삭제하면 새 모델을 선택할 때까지 전사가 중지됩니다. 정말 삭제하시겠습니까?\",\n      \"deleteTitle\": \"모델 삭제\",\n      \"filters\": {\n        \"all\": \"전체\",\n        \"multiLanguage\": \"다국어\",\n        \"translation\": \"번역\",\n        \"allLanguages\": \"모든 언어\"\n      },\n      \"noModelsMatch\": \"이 필터에 맞는 모델이 없습니다.\"\n    },\n    \"advanced\": {\n      \"title\": \"고급\",\n      \"groups\": {\n        \"app\": \"앱\",\n        \"output\": \"출력\",\n        \"transcription\": \"전사\",\n        \"history\": \"히스토리\",\n        \"experimental\": \"실험적\"\n      },\n      \"experimentalToggle\": {\n        \"label\": \"실험적 기능\",\n        \"description\": \"개발 중인 실험적 기능을 활성화합니다.\"\n      },\n      \"lazyStreamClose\": {\n        \"label\": \"전사 사이에 마이크 열어 두기\",\n        \"description\": \"녹음 중지 후 30초 동안 마이크 스트림을 열어 두어 연속 전사 시 지연을 줄입니다. 활성화 중 블루투스 오디오 품질이 저하될 수 있습니다.\"\n      },\n      \"acceleration\": {\n        \"whisper\": {\n          \"title\": \"Whisper 가속\",\n          \"description\": \"Whisper 모델의 하드웨어 가속. 자동 모드는 GPU를 사용합니다 (macOS에서는 Metal, Windows/Linux에서는 Vulkan).\"\n        },\n        \"ort\": {\n          \"title\": \"ONNX 가속\",\n          \"description\": \"ONNX 모델의 하드웨어 가속 (Parakeet, Canary, Moonshine 등). Windows의 DirectML은 실험적입니다. 모델이 전사에 실패할 수 있습니다.\"\n        }\n      },\n      \"startHidden\": {\n        \"label\": \"숨김으로 시작\",\n        \"description\": \"창을 열지 않고 시스템 트레이에서 실행합니다.\"\n      },\n      \"autostart\": {\n        \"label\": \"시작 시 실행\",\n        \"description\": \"컴퓨터 로그인 시 Handy를 자동으로 시작합니다.\"\n      },\n      \"showTrayIcon\": {\n        \"label\": \"트레이 아이콘 표시\",\n        \"description\": \"시스템 트레이에 Handy 아이콘을 표시합니다.\"\n      },\n      \"overlay\": {\n        \"title\": \"오버레이 위치\",\n        \"description\": \"녹음 및 전사 중 시각적 피드백 오버레이를 표시합니다. Linux에서는 '없음'을 권장합니다.\",\n        \"options\": {\n          \"none\": \"없음\",\n          \"bottom\": \"하단\",\n          \"top\": \"상단\"\n        }\n      },\n      \"pasteMethod\": {\n        \"title\": \"붙여넣기 방법\",\n        \"description\": \"텍스트 삽입 방법을 선택하세요. 직접: 시스템 입력을 통해 타이핑 시뮬레이션. 없음: 붙여넣기를 건너뛰고 히스토리/클립보드만 업데이트합니다.\",\n        \"options\": {\n          \"clipboard\": \"클립보드 ({{modifier}}+V)\",\n          \"clipboardCtrlShiftV\": \"클립보드 (Ctrl+Shift+V)\",\n          \"clipboardShiftInsert\": \"클립보드 (Shift+Insert)\",\n          \"direct\": \"직접\",\n          \"none\": \"없음\",\n          \"externalScript\": \"외부 스크립트\"\n        },\n        \"externalScriptPlaceholder\": \"/path/to/your/script.sh\"\n      },\n      \"typingTool\": {\n        \"title\": \"타이핑 도구\",\n        \"description\": \"직접 붙여넣기 방식에 사용할 Linux 타이핑 도구를 선택하세요. Auto는 시스템에서 사용 가능한 최적의 도구를 자동으로 감지해 사용합니다.\",\n        \"options\": {\n          \"auto\": \"Auto (권장)\"\n        }\n      },\n      \"clipboardHandling\": {\n        \"title\": \"클립보드 처리\",\n        \"description\": \"클립보드 수정 안 함은 전사 후 현재 클립보드 내용을 보존합니다. 클립보드에 복사는 붙여넣기 후 전사 결과를 클립보드에 남겨둡니다.\",\n        \"options\": {\n          \"dontModify\": \"클립보드 수정 안 함\",\n          \"copyToClipboard\": \"클립보드에 복사\"\n        }\n      },\n      \"autoSubmit\": {\n        \"title\": \"자동 제출\",\n        \"description\": \"텍스트 삽입 후 선택한 키 조합을 자동으로 전송합니다. macOS에서는 Cmd+Enter가, Windows/Linux에서는 Super+Enter가 적용됩니다.\",\n        \"options\": {\n          \"off\": \"끄기\",\n          \"enter\": \"Enter\",\n          \"cmdEnter\": \"Cmd+Enter\",\n          \"superEnter\": \"Super+Enter\",\n          \"ctrlEnter\": \"Ctrl+Enter\"\n        }\n      },\n      \"translateToEnglish\": {\n        \"label\": \"영어로 번역\",\n        \"description\": \"텍스트로 변환시 다른 언어의 음성을 자동으로 영어로 번역합니다.\",\n        \"descriptionUnsupported\": \"번역은 {{model}} 모델에서 지원되지 않습니다.\"\n      },\n      \"modelUnload\": {\n        \"title\": \"모델 언로드\",\n        \"description\": \"모델을 지정된 시간 동안 사용하지 않으면 자동으로 GPU/CPU 메모리를 해제합니다\",\n        \"options\": {\n          \"never\": \"안 함\",\n          \"immediately\": \"즉시\",\n          \"min2\": \"2분 후\",\n          \"min5\": \"5분 후\",\n          \"min10\": \"10분 후\",\n          \"min15\": \"15분 후\",\n          \"hour1\": \"1시간 후\",\n          \"sec15\": \"15초 후 (디버그)\"\n        }\n      },\n      \"customWords\": {\n        \"title\": \"사용자 정의 단어\",\n        \"description\": \"녹음 중 자주 잘못 들리거나 철자가 틀리는 단어를 추가하세요. 시스템이 유사한 소리의 단어를 목록과 일치하도록 자동으로 수정합니다.\",\n        \"placeholder\": \"단어 추가\",\n        \"add\": \"추가\",\n        \"remove\": \"{{word}} 제거\",\n        \"duplicate\": \"\\\"{{word}}\\\"이(가) 이미 존재합니다\"\n      }\n    },\n    \"postProcessing\": {\n      \"title\": \"후처리\",\n      \"hotkey\": {\n        \"title\": \"단축키\"\n      },\n      \"api\": {\n        \"title\": \"API (OpenAI 호환)\",\n        \"provider\": {\n          \"title\": \"제공자\",\n          \"description\": \"OpenAI 호환 제공자를 선택하세요.\"\n        },\n        \"appleIntelligence\": {\n          \"title\": \"Apple Intelligence\",\n          \"description\": \"완전히 기기에서 실행됩니다. API 키나 네트워크 접근이 필요하지 않습니다.\",\n          \"requirements\": \"macOS Tahoe (26.0) 이상을 실행하는 Apple Silicon Mac이 필요합니다. 시스템 설정에서 Apple Intelligence가 활성화되어 있어야 합니다.\",\n          \"unavailable\": \"이 기기에서는 Apple Intelligence를 사용할 수 없습니다. macOS Tahoe (26.0) 이상을 실행하고 시스템 설정에서 Apple Intelligence가 활성화된 Apple Silicon Mac이 필요합니다.\"\n        },\n        \"baseUrl\": {\n          \"title\": \"기본 URL\",\n          \"description\": \"선택한 제공자의 API 기본 URL입니다. 사용자 정의 제공자만 편집할 수 있습니다.\",\n          \"placeholder\": \"https://api.openai.com/v1\"\n        },\n        \"apiKey\": {\n          \"title\": \"API 키\",\n          \"description\": \"선택한 제공자의 API 키입니다.\",\n          \"placeholder\": \"sk-...\"\n        },\n        \"model\": {\n          \"title\": \"모델\",\n          \"descriptionApple\": \"선택적 숫자 토큰 제한을 제공하거나 기본 기기 내 프리셋을 유지하세요.\",\n          \"descriptionCustom\": \"사용자 정의 엔드포인트에서 예상하는 모델 식별자를 제공하세요.\",\n          \"descriptionDefault\": \"선택한 제공자가 제공하는 모델을 선택하세요.\",\n          \"placeholderApple\": \"Apple Intelligence\",\n          \"placeholderWithOptions\": \"모델 검색 또는 선택\",\n          \"placeholderNoOptions\": \"모델 이름 입력\",\n          \"refreshModels\": \"모델 새로고침\"\n        }\n      },\n      \"prompts\": {\n        \"title\": \"프롬프트\",\n        \"selectedPrompt\": {\n          \"title\": \"선택된 프롬프트\",\n          \"description\": \"텍스트 변환을 개선하기 위한 템플릿을 선택하거나 새로 만드세요. 프롬프트 텍스트 내에서 ${output}를 사용하여 캡처된 텍스트를 참조하세요.\"\n        },\n        \"noPrompts\": \"사용 가능한 프롬프트 없음\",\n        \"selectPrompt\": \"프롬프트 선택\",\n        \"createNew\": \"새 프롬프트 만들기\",\n        \"promptLabel\": \"프롬프트 레이블\",\n        \"promptLabelPlaceholder\": \"프롬프트 이름 입력\",\n        \"promptInstructions\": \"프롬프트 지시사항\",\n        \"promptInstructionsPlaceholder\": \"텍스트 변환 후 실행할 지시사항을 작성하세요. 예: 다음 텍스트의 문법과 명확성을 개선하세요: ${output}\",\n        \"promptTip\": \"팁: 프롬프트에서 변환된 텍스트를 삽입하려면 <code>${output}</code>을 사용하세요.\",\n        \"updatePrompt\": \"프롬프트 업데이트\",\n        \"deletePrompt\": \"프롬프트 삭제\",\n        \"createPrompt\": \"프롬프트 만들기\",\n        \"cancel\": \"취소\",\n        \"selectToEdit\": \"세부 정보를 보고 편집하려면 위에서 프롬프트를 선택하세요.\",\n        \"createFirst\": \"첫 번째 후처리 프롬프트를 만들려면 위의 '새 프롬프트 만들기'를 클릭하세요.\"\n      }\n    },\n    \"history\": {\n      \"title\": \"히스토리\",\n      \"openFolder\": \"녹음 폴더 열기\",\n      \"loading\": \"히스토리 로딩 중...\",\n      \"empty\": \"아직 변환된 내용이 없습니다. 녹음을 시작하여 히스토리를 만드세요!\",\n      \"copyToClipboard\": \"녹음 내용을 클립보드에 복사\",\n      \"save\": \"변환된 텍스트 저장\",\n      \"unsave\": \"저장에서 제거\",\n      \"delete\": \"항목 삭제\",\n      \"deleteError\": \"항목 삭제에 실패했습니다. 다시 시도해주세요.\"\n    },\n    \"debug\": {\n      \"title\": \"디버그\",\n      \"logDirectory\": {\n        \"title\": \"로그 디렉토리\",\n        \"description\": \"로그 파일이 저장되는 위치\"\n      },\n      \"logLevel\": {\n        \"title\": \"로그 레벨\",\n        \"description\": \"로깅의 상세도 설정\"\n      },\n      \"updateChecks\": {\n        \"label\": \"업데이트 확인\",\n        \"description\": \"Handy의 새 버전을 자동으로 확인합니다\"\n      },\n      \"soundTheme\": {\n        \"label\": \"사운드 테마\",\n        \"description\": \"녹음 시작 및 정지 피드백을 위한 사운드 테마를 선택하세요\"\n      },\n      \"wordCorrectionThreshold\": {\n        \"title\": \"단어 수정 임계값\",\n        \"description\": \"사용자 정의 단어 수정의 민감도\"\n      },\n      \"historyLimit\": {\n        \"title\": \"히스토리 제한\",\n        \"description\": \"보관할 최대 히스토리 항목 수\",\n        \"entries\": \"항목\"\n      },\n      \"recordingRetention\": {\n        \"title\": \"녹음 자동 삭제\",\n        \"description\": \"공간을 절약하기 위해 오래된 녹음을 자동으로 삭제합니다\",\n        \"never\": \"안 함\",\n        \"preserveLimit\": \"최근 {{count}}개 유지\",\n        \"days3\": \"3일 후\",\n        \"weeks2\": \"2주 후\",\n        \"months3\": \"3개월 후\",\n        \"placeholder\": \"보관 기간 선택...\"\n      },\n      \"alwaysOnMicrophone\": {\n        \"label\": \"항상 켜진 마이크\",\n        \"description\": \"더 빠른 응답을 위해 마이크를 활성 상태로 유지\"\n      },\n      \"clamshellMicrophone\": {\n        \"title\": \"클램셸 마이크\",\n        \"description\": \"노트북 덮개가 닫혀 있을 때 사용할 마이크\"\n      },\n      \"postProcessingToggle\": {\n        \"label\": \"후처리\",\n        \"description\": \"텍스트 변환 후 AI 기반 텍스트 개선 활성화\"\n      },\n      \"muteWhileRecording\": {\n        \"label\": \"녹음 중 음소거\",\n        \"description\": \"녹음 중 시스템 오디오 음소거\"\n      },\n      \"appendTrailingSpace\": {\n        \"label\": \"끝 공백 추가\",\n        \"description\": \"붙여넣은 텍스트 끝에 공백 추가\"\n      },\n      \"keyboardImplementation\": {\n        \"title\": \"키보드 구현\",\n        \"description\": \"키보드 단축키 백엔드를 선택하세요.\",\n        \"bindingsReset\": \"키보드 단축키가 호환되지 않아 기본값으로 재설정되었습니다\"\n      },\n      \"pasteDelay\": {\n        \"title\": \"붙여넣기 지연\",\n        \"description\": \"붙여넣기 키 입력을 보내기 전 지연 시간(밀리초). 잘못된 텍스트가 붙여넣어지면 늘리세요.\"\n      },\n      \"recordingBuffer\": {\n        \"title\": \"추가 녹음 버퍼\",\n        \"description\": \"키를 놓은 후 추가로 녹음을 계속하는 시간(밀리초). 후행 오디오를 캡처하기 위함. 0 = 추가 버퍼 없음.\"\n      },\n      \"paths\": {\n        \"appData\": \"앱 데이터:\",\n        \"models\": \"모델:\",\n        \"settings\": \"설정:\"\n      }\n    },\n    \"about\": {\n      \"title\": \"정보\",\n      \"version\": {\n        \"title\": \"버전\",\n        \"description\": \"Handy의 현재 버전\"\n      },\n      \"appDataDirectory\": {\n        \"title\": \"앱 데이터 디렉토리\",\n        \"description\": \"Handy가 데이터를 저장하는 위치\"\n      },\n      \"sourceCode\": {\n        \"title\": \"소스 코드\",\n        \"description\": \"소스 코드 보기 및 기여\",\n        \"button\": \"GitHub에서 보기\"\n      },\n      \"supportDevelopment\": {\n        \"title\": \"개발 지원\",\n        \"description\": \"Handy 개발을 계속할 수 있도록 도와주세요\",\n        \"button\": \"후원하기\"\n      },\n      \"acknowledgments\": {\n        \"title\": \"감사의 말\",\n        \"whisper\": {\n          \"title\": \"Whisper.cpp\",\n          \"description\": \"OpenAI의 Whisper 자동 음성 인식 모델의 고성능 추론\",\n          \"details\": \"Handy는 빠르고 로컬 음성-텍스트 처리를 위해 Whisper.cpp를 사용합니다. Georgi Gerganov와 기여자들의 놀라운 작업에 감사드립니다.\"\n        }\n      }\n    }\n  },\n  \"footer\": {\n    \"downloadingModel\": \"{{model}} 다운로드 중...\",\n    \"checkingUpdates\": \"업데이트 확인 중...\",\n    \"updateAvailable\": \"업데이트 사용 가능: {{version}}\",\n    \"updateAvailableShort\": \"업데이트 사용 가능\",\n    \"upToDate\": \"최신 상태\",\n    \"downloadUpdate\": \"업데이트 다운로드\",\n    \"restart\": \"재시작\",\n    \"updateCheckingDisabled\": \"업데이트 확인 비활성화됨\",\n    \"downloading\": \"다운로드 중... {{progress}}%\",\n    \"installing\": \"설치 중...\",\n    \"preparing\": \"준비 중...\",\n    \"checkForUpdates\": \"업데이트 확인\"\n  },\n  \"common\": {\n    \"loading\": \"로딩 중...\",\n    \"save\": \"저장\",\n    \"cancel\": \"취소\",\n    \"reset\": \"재설정\",\n    \"add\": \"추가\",\n    \"remove\": \"제거\",\n    \"delete\": \"삭제\",\n    \"edit\": \"편집\",\n    \"create\": \"만들기\",\n    \"update\": \"업데이트\",\n    \"close\": \"닫기\",\n    \"open\": \"열기\",\n    \"default\": \"기본값\",\n    \"enabled\": \"활성화됨\",\n    \"disabled\": \"비활성화됨\",\n    \"on\": \"켜짐\",\n    \"off\": \"꺼짐\",\n    \"yes\": \"예\",\n    \"no\": \"아니오\",\n    \"noOptionsFound\": \"옵션을 찾을 수 없습니다\"\n  },\n  \"accessibility\": {\n    \"permissionsRequired\": \"접근성 권한 필요\",\n    \"permissionsDescription\": \"Handy는 녹음된 텍스트를 입력하기 위해 접근성 권한이 필요합니다.\",\n    \"openSettings\": \"시스템 설정 열기\",\n    \"dismiss\": \"닫기\"\n  },\n  \"errors\": {\n    \"loadDirectory\": \"디렉토리 로딩 오류: {{error}}\",\n    \"micPermissionDeniedTitle\": \"마이크 접근이 거부되었습니다\",\n    \"micPermissionDenied\": {\n      \"generic\": \"운영 체제에서 마이크 접근을 거부했습니다. 시스템 설정에서 마이크 권한을 허용해 주세요.\",\n      \"windows\": \"설정 → 개인 정보 및 보안 → 마이크(데스크톱 앱 접근 포함)에서 마이크 접근을 활성화하세요.\",\n      \"macos\": \"시스템 설정 → 개인 정보 보호 및 보안 → 마이크에서 마이크 접근을 허용하세요.\",\n      \"linux\": \"시스템의 소리 또는 개인 정보 설정에서 마이크 접근을 허용하세요.\"\n    },\n    \"recordingFailed\": \"녹음을 시작하지 못했습니다: {{error}}\",\n    \"modelLoadFailed\": \"모델을 불러오지 못했습니다: {{model}}\",\n    \"modelLoadFailedUnknown\": \"알 수 없는 모델\"\n  },\n  \"appLanguage\": {\n    \"title\": \"애플리케이션 언어\",\n    \"description\": \"Handy 인터페이스의 언어를 변경하세요\"\n  },\n  \"overlay\": {\n    \"transcribing\": \"텍스트로 변환 중...\",\n    \"processing\": \"처리 중...\"\n  }\n}\n"
  },
  {
    "path": "src/i18n/locales/pl/translation.json",
    "content": "{\n  \"tray\": {\n    \"settings\": \"Ustawienia...\",\n    \"checkUpdates\": \"Sprawdź aktualizacje...\",\n    \"copyLastTranscript\": \"Kopiuj ostatnią transkrypcję\",\n    \"unloadModel\": \"Zwolnij model\",\n    \"model\": \"Model\",\n    \"quit\": \"Zamknij\",\n    \"cancel\": \"Anuluj\"\n  },\n  \"sidebar\": {\n    \"general\": \"Ogólne\",\n    \"models\": \"Modele\",\n    \"advanced\": \"Zaawansowane\",\n    \"postProcessing\": \"Postproces\",\n    \"history\": \"Historia\",\n    \"debug\": \"Debugowanie\",\n    \"about\": \"O programie\"\n  },\n  \"onboarding\": {\n    \"subtitle\": \"Aby rozpocząć, wybierz model transkrypcji\",\n    \"recommended\": \"Polecane\",\n    \"download\": \"Pobierz\",\n    \"downloading\": \"Pobieranie...\",\n    \"customModelDescription\": \"Nieoficjalnie wspierane\",\n    \"downloadFailed\": \"Pobieranie nie powiodło się. Spróbuj ponownie.\",\n    \"modelCard\": {\n      \"accuracy\": \"dokładność\",\n      \"speed\": \"szybkość\"\n    },\n    \"models\": {\n      \"small\": {\n        \"name\": \"Whisper Small\",\n        \"description\": \"Szybki i dość dokładny.\"\n      },\n      \"medium\": {\n        \"name\": \"Whisper Medium\",\n        \"description\": \"Dobra dokładność, średnia szybkość\"\n      },\n      \"turbo\": {\n        \"name\": \"Whisper Turbo\",\n        \"description\": \"Zrównoważona dokładność i szybkość.\"\n      },\n      \"large\": {\n        \"name\": \"Whisper Large\",\n        \"description\": \"Dobra dokładność, ale wolny.\"\n      },\n      \"parakeet-tdt-0.6b-v2\": {\n        \"name\": \"Parakeet V2\",\n        \"description\": \"Tylko angielski. Najlepszy model dla osób mówiących po angielsku.\"\n      },\n      \"parakeet-tdt-0.6b-v3\": {\n        \"name\": \"Parakeet V3\",\n        \"description\": \"Szybki i dokładny\"\n      },\n      \"moonshine-base\": {\n        \"name\": \"Moonshine Base\",\n        \"description\": \"Bardzo szybki, tylko angielski. Dobrze radzi sobie z akcentami.\"\n      },\n      \"moonshine-tiny-streaming-en\": {\n        \"name\": \"Moonshine V2 Tiny\",\n        \"description\": \"Ultraszybki, tylko angielski\"\n      },\n      \"moonshine-small-streaming-en\": {\n        \"name\": \"Moonshine V2 Small\",\n        \"description\": \"Szybki, tylko angielski. Dobra równowaga między szybkością a dokładnością.\"\n      },\n      \"moonshine-medium-streaming-en\": {\n        \"name\": \"Moonshine V2 Medium\",\n        \"description\": \"Tylko angielski. Wysoka jakość.\"\n      },\n      \"breeze-asr\": {\n        \"name\": \"Breeze ASR\",\n        \"description\": \"Zoptymalizowany dla tajwańskiego mandaryńskiego. Obsługa przełączania języków.\"\n      },\n      \"sense-voice-int8\": {\n        \"name\": \"SenseVoice\",\n        \"description\": \"Bardzo szybki. Chiński, angielski, japoński, koreański, kantoński.\"\n      },\n      \"gigaam-v3-e2e-ctc\": {\n        \"name\": \"GigaAM v3\",\n        \"description\": \"Rozpoznawanie mowy rosyjskiej. Szybkie i dokładne.\"\n      },\n      \"canary-180m-flash\": {\n        \"name\": \"Canary 180M Flash\",\n        \"description\": \"Bardzo szybki. Angielski, niemiecki, hiszpański, francuski. Obsługuje tłumaczenie.\"\n      },\n      \"canary-1b-v2\": {\n        \"name\": \"Canary 1B v2\",\n        \"description\": \"Dokładny wielojęzyczny. 25 języków europejskich. Obsługuje tłumaczenie.\"\n      }\n    },\n    \"errors\": {\n      \"loadModels\": \"Nie udało się wczytać dostępnych modeli\",\n      \"downloadModel\": \"Nie udało się pobrać modelu: {{error}}\"\n    },\n    \"permissions\": {\n      \"title\": \"Wymagane uprawnienia\",\n      \"description\": \"Handy potrzebuje kilku uprawnień, aby działać poprawnie.\",\n      \"microphone\": {\n        \"title\": \"Dostęp do mikrofonu\",\n        \"description\": \"Wymagany do słyszenia Twojego głosu w celu transkrypcji.\"\n      },\n      \"accessibility\": {\n        \"title\": \"Dostęp do dostępności\",\n        \"description\": \"Wymagany do wpisywania transkrybowanego tekstu w Twoich aplikacjach.\"\n      },\n      \"grant\": \"Przyznaj uprawnienie\",\n      \"granted\": \"Przyznano\",\n      \"waiting\": \"Oczekiwanie...\",\n      \"allGranted\": \"Wszystko gotowe!\",\n      \"errors\": {\n        \"checkFailed\": \"Nie udało się sprawdzić uprawnień. Spróbuj ponownie.\",\n        \"requestFailed\": \"Nie udało się poprosić o uprawnienie. Spróbuj ponownie.\"\n      }\n    }\n  },\n  \"modelSelector\": {\n    \"custom\": \"Własny\",\n    \"active\": \"Aktywny\",\n    \"switching\": \"Przełączanie...\",\n    \"noModelsAvailable\": \"Brak dostępnych modeli\",\n    \"extracting\": \"Rozpakowywanie {{modelName}}...\",\n    \"extractingMultiple\": \"Rozpakowywanie {{count}} modeli...\",\n    \"extractingGeneric\": \"Rozpakowywanie...\",\n    \"downloading\": \"Pobieranie {{percentage}}%\",\n    \"downloadingMultiple\": \"Pobieranie {{count}} modeli...\",\n    \"modelReady\": \"Model gotowy\",\n    \"loading\": \"Wczytywanie {{modelName}}...\",\n    \"loadingGeneric\": \"Wczytywanie...\",\n    \"modelError\": \"Błąd modelu\",\n    \"modelUnloaded\": \"Model wyładowany\",\n    \"noModelDownloadRequired\": \"Brak modelu – wymagane pobranie\",\n    \"deleteModel\": \"Usuń {{modelName}}\",\n    \"downloadSpeed\": \"{{speed}} MB/s\",\n    \"capabilities\": {\n      \"languageSelection\": \"Obsługuje wiele języków wejściowych\",\n      \"multiLanguage\": \"Wielojęzyczny\",\n      \"translation\": \"Może tłumaczyć na angielski\",\n      \"translate\": \"Tłumacz na angielski\",\n      \"singleLanguage\": \"Obsługuje tylko ten język\",\n      \"languageOnly\": \"Tylko {{language}}\"\n    },\n    \"cancel\": \"Anuluj\",\n    \"cancelDownload\": \"Anuluj pobieranie\"\n  },\n  \"settings\": {\n    \"modelSettings\": {\n      \"title\": \"Ustawienia {{model}}\",\n      \"noSettingsNeeded\": \"Ten model działa automatycznie bez konieczności konfiguracji.\"\n    },\n    \"models\": {\n      \"title\": \"Modele transkrypcji\",\n      \"description\": \"Wybierz model transkrypcji lub pobierz dodatkowe modele. Różne modele oferują różne poziomy dokładności i szybkości.\",\n      \"downloaded\": \"Pobrane\",\n      \"available\": \"Dostępne do pobrania\",\n      \"deleteConfirm\": \"Czy na pewno chcesz usunąć {{modelName}}? Będziesz musiał pobrać go ponownie, aby go użyć.\",\n      \"deleteActiveConfirm\": \"{{modelName}} jest Twoim aktywnym modelem. Usunięcie go zatrzyma transkrypcje, dopóki nie wybierzesz nowego modelu. Czy na pewno chcesz kontynuować?\",\n      \"deleteTitle\": \"Usuń model\",\n      \"filters\": {\n        \"all\": \"Wszystkie\",\n        \"multiLanguage\": \"Wielojęzyczne\",\n        \"translation\": \"Tłumaczenie\",\n        \"allLanguages\": \"Wszystkie języki\"\n      },\n      \"noModelsMatch\": \"Żadne modele nie pasują do tego filtra.\",\n      \"yourModels\": \"Pobrane modele\",\n      \"availableModels\": \"Dostępne do pobrania\"\n    },\n    \"general\": {\n      \"title\": \"Ogólne\",\n      \"shortcut\": {\n        \"title\": \"Skróty Handy\",\n        \"description\": \"Skonfiguruj skróty klawiaturowe do uruchamiania nagrywania mowy\",\n        \"loading\": \"Wczytywanie skrótów...\",\n        \"none\": \"Brak skonfigurowanych skrótów\",\n        \"notFound\": \"Nie znaleziono skrótu\",\n        \"pressKeys\": \"Naciśnij klawisze...\",\n        \"bindings\": {\n          \"transcribe\": {\n            \"name\": \"Skrót transkrypcji\",\n            \"description\": \"Skrót klawiaturowy do nagrywania i transkrypcji głosu.\"\n          },\n          \"cancel\": {\n            \"name\": \"Skrót anulowania\",\n            \"description\": \"Skrót klawiaturowy do anulowania bieżącego nagrywania.\"\n          },\n          \"transcribe_with_post_process\": {\n            \"name\": \"Skrót postprocessingu\",\n            \"description\": \"Opcjonalnie: Dedykowany skrót klawiszowy, który zawsze stosuje postprocessing AI do transkrypcji.\"\n          }\n        },\n        \"errors\": {\n          \"restore\": \"Nie udało się przywrócić oryginalnego skrótu\",\n          \"set\": \"Nie udało się ustawić skrótu: {{error}}\",\n          \"reset\": \"Nie udało się zresetować skrótu do wartości domyślnej\"\n        }\n      },\n      \"language\": {\n        \"title\": \"Język\",\n        \"description\": \"Wybierz język rozpoznawania mowy. Opcja Auto automatycznie określi język, a wybór konkretnego języka może poprawić dokładność.\",\n        \"descriptionUnsupported\": \"Model Parakeet automatycznie wykrywa język. Nie jest potrzebny ręczny wybór.\",\n        \"searchPlaceholder\": \"Szukaj języka...\",\n        \"noResults\": \"Nie znaleziono języków\",\n        \"auto\": \"Auto\"\n      },\n      \"pushToTalk\": {\n        \"label\": \"Push To Talk\",\n        \"description\": \"Przytrzymaj, aby nagrywać, puść, aby zatrzymać\"\n      }\n    },\n    \"sound\": {\n      \"title\": \"Dźwięk\",\n      \"microphone\": {\n        \"title\": \"Mikrofon\",\n        \"description\": \"Wybierz preferowane urządzenie mikrofonowe\",\n        \"placeholder\": \"Wybierz mikrofon...\",\n        \"loading\": \"Wczytywanie...\"\n      },\n      \"audioFeedback\": {\n        \"label\": \"Informacja dźwiękowa\",\n        \"description\": \"Odtwarzaj dźwięk przy rozpoczęciu i zakończeniu nagrywania\"\n      },\n      \"outputDevice\": {\n        \"title\": \"Urządzenie wyjściowe\",\n        \"description\": \"Wybierz preferowane urządzenie audio do odtwarzania dźwięków\",\n        \"placeholder\": \"Wybierz urządzenie wyjściowe...\",\n        \"loading\": \"Wczytywanie...\"\n      },\n      \"volume\": {\n        \"title\": \"Głośność\",\n        \"description\": \"Dostosuj głośność dźwięków informacyjnych\"\n      }\n    },\n    \"advanced\": {\n      \"title\": \"Zaawansowane\",\n      \"groups\": {\n        \"app\": \"Aplikacja\",\n        \"output\": \"Wyjście\",\n        \"transcription\": \"Transkrypcja\",\n        \"history\": \"Historia\",\n        \"experimental\": \"Eksperymentalne\"\n      },\n      \"experimentalToggle\": {\n        \"label\": \"Funkcje Eksperymentalne\",\n        \"description\": \"Włącz funkcje eksperymentalne, które są jeszcze w fazie rozwoju.\"\n      },\n      \"lazyStreamClose\": {\n        \"label\": \"Pozostaw mikrofon otwarty między transkrypcjami\",\n        \"description\": \"Utrzymuje strumień mikrofonu otwarty przez 30 sekund po zatrzymaniu nagrywania, zmniejszając opóźnienie przy kolejnych transkrypcjach. Może pogorszyć jakość dźwięku Bluetooth.\"\n      },\n      \"acceleration\": {\n        \"whisper\": {\n          \"title\": \"Akceleracja Whisper\",\n          \"description\": \"Akceleracja sprzętowa dla modeli Whisper. Tryb automatyczny używa GPU, jeśli jest dostępny (Metal na macOS, Vulkan na Windows/Linux).\"\n        },\n        \"ort\": {\n          \"title\": \"Akceleracja ONNX\",\n          \"description\": \"Akceleracja sprzętowa dla modeli ONNX (Parakeet, Canary, Moonshine itp.). DirectML na Windows jest eksperymentalne. Modele mogą nie transkrybować poprawnie.\"\n        }\n      },\n      \"startHidden\": {\n        \"label\": \"Uruchom ukryty\",\n        \"description\": \"Uruchom w zasobniku systemowym bez otwierania okna.\"\n      },\n      \"autostart\": {\n        \"label\": \"Uruchamiaj przy starcie\",\n        \"description\": \"Automatycznie uruchamiaj Handy po zalogowaniu.\"\n      },\n      \"showTrayIcon\": {\n        \"label\": \"Pokaż ikonę w zasobniku\",\n        \"description\": \"Wyświetlaj ikonę Handy w zasobniku systemowym.\"\n      },\n      \"overlay\": {\n        \"title\": \"Pozycja nakładki\",\n        \"description\": \"Wyświetlaj wizualną nakładkę podczas nagrywania i transkrypcji. Na Linuxie zalecane 'Brak'.\",\n        \"options\": {\n          \"none\": \"Brak\",\n          \"bottom\": \"Dół\",\n          \"top\": \"Góra\"\n        }\n      },\n      \"pasteMethod\": {\n        \"title\": \"Metoda wklejania\",\n        \"description\": \"Wybierz sposób wstawiania tekstu. Direct: symuluje wpisywanie. None: pomija wklejanie, tylko aktualizuje historię/clipboard.\",\n        \"options\": {\n          \"clipboard\": \"Schowek ({{modifier}}+V)\",\n          \"clipboardCtrlShiftV\": \"Schowek (Ctrl+Shift+V)\",\n          \"clipboardShiftInsert\": \"Schowek (Shift+Insert)\",\n          \"direct\": \"Bezpośrednio\",\n          \"none\": \"Brak\",\n          \"externalScript\": \"Skrypt zewnętrzny\"\n        },\n        \"externalScriptPlaceholder\": \"/sciezka/do/twojego/skryptu.sh\"\n      },\n      \"typingTool\": {\n        \"title\": \"Narzędzie do wpisywania\",\n        \"description\": \"Wybierz, którego narzędzia do wpisywania w Linuxie użyć dla metody bezpośredniego wklejania. Auto automatycznie wykryje i użyje najlepszego dostępnego narzędzia dla Twojego systemu.\",\n        \"options\": {\n          \"auto\": \"Auto (Zalecane)\"\n        }\n      },\n      \"clipboardHandling\": {\n        \"title\": \"Obsługa schowka\",\n        \"description\": \"Nie modyfikuj schowka zachowuje jego zawartość. Kopiuj do schowka pozostawia wynik transkrypcji w schowku.\",\n        \"options\": {\n          \"dontModify\": \"Nie modyfikuj schowka\",\n          \"copyToClipboard\": \"Kopiuj do schowka\"\n        }\n      },\n      \"autoSubmit\": {\n        \"title\": \"Automatyczne wysyłanie\",\n        \"description\": \"Automatycznie wysyła wybraną kombinację klawiszy po wstawieniu tekstu. Cmd+Enter dotyczy macOS, natomiast Windows/Linux używają Super+Enter.\",\n        \"options\": {\n          \"off\": \"Wyłączone\",\n          \"enter\": \"Enter\",\n          \"cmdEnter\": \"Cmd+Enter\",\n          \"superEnter\": \"Super+Enter\",\n          \"ctrlEnter\": \"Ctrl+Enter\"\n        }\n      },\n      \"translateToEnglish\": {\n        \"label\": \"Tłumacz na angielski\",\n        \"description\": \"Automatycznie tłumacz mowę z innych języków na angielski podczas transkrypcji.\",\n        \"descriptionUnsupported\": \"Tłumaczenie nie jest obsługiwane przez model {{model}}.\"\n      },\n      \"modelUnload\": {\n        \"title\": \"Wyładowanie modelu\",\n        \"description\": \"Automatycznie zwalnia pamięć GPU/CPU po określonym czasie nieużywania\",\n        \"options\": {\n          \"never\": \"Nigdy\",\n          \"immediately\": \"Natychmiast\",\n          \"min2\": \"Po 2 minutach\",\n          \"min5\": \"Po 5 minutach\",\n          \"min10\": \"Po 10 minutach\",\n          \"min15\": \"Po 15 minutach\",\n          \"hour1\": \"Po 1 godzinie\",\n          \"sec15\": \"Po 15 sekundach (Debug)\"\n        }\n      },\n      \"customWords\": {\n        \"title\": \"Własne słowa\",\n        \"description\": \"Dodaj słowa, które często są źle rozpoznawane lub zapisywane podczas transkrypcji. System automatycznie poprawi podobnie brzmiące słowa, aby pasowały do Twojej listy.\",\n        \"placeholder\": \"Dodaj słowo\",\n        \"add\": \"Dodaj\",\n        \"remove\": \"Usuń {{word}}\",\n        \"duplicate\": \"\\\"{{word}}\\\" już istnieje\"\n      }\n    },\n    \"postProcessing\": {\n      \"title\": \"Postprocess\",\n      \"hotkey\": {\n        \"title\": \"Skrót klawiszowy\"\n      },\n      \"api\": {\n        \"title\": \"API (zgodne z OpenAI)\",\n        \"provider\": {\n          \"title\": \"Dostawca\",\n          \"description\": \"Wybierz dostawcę zgodnego z OpenAI.\"\n        },\n        \"appleIntelligence\": {\n          \"title\": \"Apple Intelligence\",\n          \"description\": \"Działa całkowicie na urządzeniu. Nie wymaga klucza API ani dostępu do sieci.\",\n          \"requirements\": \"Wymaga komputera Mac z Apple Silicon i systemem macOS Tahoe (26.0) lub nowszym. Apple Intelligence musi być włączone w Ustawieniach systemowych.\",\n          \"unavailable\": \"Apple Intelligence nie jest dostępne na tym urządzeniu. Wymaga komputera Mac z Apple Silicon i systemem macOS Tahoe (26.0) lub nowszym z włączonym Apple Intelligence w Ustawieniach systemowych.\"\n        },\n        \"baseUrl\": {\n          \"title\": \"Adres bazowy\",\n          \"description\": \"Bazowy adres API dla wybranego dostawcy. Tylko dostawca niestandardowy może być edytowany.\",\n          \"placeholder\": \"https://api.openai.com/v1\"\n        },\n        \"apiKey\": {\n          \"title\": \"Klucz API\",\n          \"description\": \"Klucz API dla wybranego dostawcy.\",\n          \"placeholder\": \"sk-...\"\n        },\n        \"model\": {\n          \"title\": \"Model\",\n          \"descriptionApple\": \"Podaj opcjonalny limit tokenów lub pozostaw domyślne ustawienie urządzenia.\",\n          \"descriptionCustom\": \"Podaj identyfikator modelu wymagany przez Twój niestandardowy endpoint.\",\n          \"descriptionDefault\": \"Wybierz model udostępniony przez wybranego dostawcę.\",\n          \"placeholderApple\": \"Apple Intelligence\",\n          \"placeholderWithOptions\": \"Wyszukaj lub wybierz model\",\n          \"placeholderNoOptions\": \"Wpisz nazwę modelu\",\n          \"refreshModels\": \"Odśwież modele\"\n        }\n      },\n      \"prompts\": {\n        \"title\": \"Prompt\",\n        \"selectedPrompt\": {\n          \"title\": \"Wybrany prompt\",\n          \"description\": \"Wybierz szablon do ulepszania transkrypcji lub utwórz nowy. Użyj ${output} w treści, aby odwołać się do przechwyconej transkrypcji.\"\n        },\n        \"noPrompts\": \"Brak dostępnych promptów\",\n        \"selectPrompt\": \"Wybierz prompt\",\n        \"createNew\": \"Utwórz nowy prompt\",\n        \"promptLabel\": \"Etykieta promptu\",\n        \"promptLabelPlaceholder\": \"Wpisz nazwę promptu\",\n        \"promptInstructions\": \"Instrukcje promptu\",\n        \"promptInstructionsPlaceholder\": \"Wpisz instrukcje do wykonania po transkrypcji. Przykład: Popraw gramatykę i jasność dla następującego tekstu: ${output}\",\n        \"promptTip\": \"Wskazówka: użyj <code>${output}</code>, aby wstawić transkrybowany tekst do promptu.\",\n        \"updatePrompt\": \"Zaktualizuj prompt\",\n        \"deletePrompt\": \"Usuń prompt\",\n        \"createPrompt\": \"Utwórz prompt\",\n        \"cancel\": \"Anuluj\",\n        \"selectToEdit\": \"Wybierz prompt powyżej, aby zobaczyć i edytować jego szczegóły.\",\n        \"createFirst\": \"Kliknij 'Utwórz nowy prompt' powyżej, aby utworzyć pierwszy prompt postprocessingu.\"\n      }\n    },\n    \"history\": {\n      \"title\": \"Historia\",\n      \"openFolder\": \"Otwórz folder nagrań\",\n      \"loading\": \"Wczytywanie historii...\",\n      \"empty\": \"Brak transkrypcji. Rozpocznij nagrywanie, aby zbudować historię!\",\n      \"copyToClipboard\": \"Kopiuj transkrypcję do schowka\",\n      \"save\": \"Zapisz transkrypcję\",\n      \"unsave\": \"Usuń z zapisanych\",\n      \"delete\": \"Usuń wpis\",\n      \"deleteError\": \"Nie udało się usunąć wpisu. Spróbuj ponownie.\"\n    },\n    \"debug\": {\n      \"title\": \"Debugowanie\",\n      \"logDirectory\": {\n        \"title\": \"Katalog logów\",\n        \"description\": \"Miejsce przechowywania plików logów\"\n      },\n      \"logLevel\": {\n        \"title\": \"Poziom logów\",\n        \"description\": \"Ustaw poziom szczegółowości logowania\"\n      },\n      \"updateChecks\": {\n        \"label\": \"Sprawdzaj aktualizacje\",\n        \"description\": \"Automatycznie sprawdzaj nowe wersje Handy\"\n      },\n      \"soundTheme\": {\n        \"label\": \"Motyw dźwiękowy\",\n        \"description\": \"Wybierz motyw dźwiękowy dla informacji o rozpoczęciu i zakończeniu nagrywania\"\n      },\n      \"wordCorrectionThreshold\": {\n        \"title\": \"Próg korekty słów\",\n        \"description\": \"Czułość dla własnych korekt słów\"\n      },\n      \"historyLimit\": {\n        \"title\": \"Limit historii\",\n        \"description\": \"Maksymalna liczba wpisów w historii\",\n        \"entries\": \"wpisy\"\n      },\n      \"recordingRetention\": {\n        \"title\": \"Automatyczne usuwanie nagrań\",\n        \"description\": \"Automatycznie usuwaj stare nagrania, aby zaoszczędzić miejsce\",\n        \"never\": \"Nigdy\",\n        \"preserveLimit\": \"Zachowaj ostatnie {{count}}\",\n        \"days3\": \"Po 3 dniach\",\n        \"weeks2\": \"Po 2 tygodniach\",\n        \"months3\": \"Po 3 miesiącach\",\n        \"placeholder\": \"Wybierz okres retencji...\"\n      },\n      \"alwaysOnMicrophone\": {\n        \"label\": \"Mikrofon zawsze aktywny\",\n        \"description\": \"Utrzymuj mikrofon aktywny dla szybszej reakcji\"\n      },\n      \"clamshellMicrophone\": {\n        \"title\": \"Mikrofon przy zamkniętej pokrywie\",\n        \"description\": \"Mikrofon używany, gdy pokrywa laptopa jest zamknięta\"\n      },\n      \"postProcessingToggle\": {\n        \"label\": \"Postprocess\",\n        \"description\": \"Włącz AI do ulepszania tekstu po transkrypcji\"\n      },\n      \"muteWhileRecording\": {\n        \"label\": \"Wycisz podczas nagrywania\",\n        \"description\": \"Wycisz dźwięk systemu podczas nagrywania\"\n      },\n      \"appendTrailingSpace\": {\n        \"label\": \"Dodaj spację na końcu\",\n        \"description\": \"Dodaj spację po wklejonej transkrypcji\"\n      },\n      \"keyboardImplementation\": {\n        \"title\": \"Implementacja klawiatury\",\n        \"description\": \"Wybierz backend dla skrótów klawiaturowych.\",\n        \"bindingsReset\": \"Skróty klawiaturowe były niekompatybilne i zostały zresetowane do wartości domyślnych\"\n      },\n      \"paths\": {\n        \"appData\": \"Dane aplikacji:\",\n        \"models\": \"Modele:\",\n        \"settings\": \"Ustawienia:\"\n      },\n      \"pasteDelay\": {\n        \"title\": \"Opóźnienie wklejania\",\n        \"description\": \"Opóźnienie przed wysłaniem klawisza wklejania (w milisekundach). Zwiększ, jeśli wklejany jest nieprawidłowy tekst.\"\n      },\n      \"recordingBuffer\": {\n        \"title\": \"Dodatkowy bufor nagrywania\",\n        \"description\": \"Dodatkowy czas (w milisekundach) na kontynuowanie nagrywania po zwolnieniu klawisza, aby przechwycić końcowy dźwięk. 0 = brak dodatkowego bufora.\"\n      }\n    },\n    \"about\": {\n      \"title\": \"O programie\",\n      \"version\": {\n        \"title\": \"Wersja\",\n        \"description\": \"Aktualna wersja Handy\"\n      },\n      \"appDataDirectory\": {\n        \"title\": \"Katalog danych aplikacji\",\n        \"description\": \"Miejsce, gdzie Handy przechowuje swoje dane\"\n      },\n      \"sourceCode\": {\n        \"title\": \"Kod źródłowy\",\n        \"description\": \"Zobacz kod źródłowy i współtwórz\",\n        \"button\": \"Zobacz na GitHub\"\n      },\n      \"supportDevelopment\": {\n        \"title\": \"Wspieraj rozwój\",\n        \"description\": \"Pomóż nam dalej rozwijać Handy\",\n        \"button\": \"Wesprzyj\"\n      },\n      \"acknowledgments\": {\n        \"title\": \"Podziękowania\",\n        \"whisper\": {\n          \"title\": \"Whisper.cpp\",\n          \"description\": \"Wydajne wykonywanie modelu rozpoznawania mowy Whisper od OpenAI\",\n          \"details\": \"Handy używa Whisper.cpp do szybkiego, lokalnego przetwarzania mowy na tekst. Dzięki niesamowitej pracy Georgi Gerganova i współtwórców.\"\n        }\n      }\n    }\n  },\n  \"footer\": {\n    \"downloadingModel\": \"Pobieranie {{model}}...\",\n    \"checkingUpdates\": \"Sprawdzanie aktualizacji...\",\n    \"updateAvailable\": \"Dostępna aktualizacja: {{version}}\",\n    \"updateAvailableShort\": \"Dostępna aktualizacja\",\n    \"upToDate\": \"Aktualne\",\n    \"downloadUpdate\": \"Pobierz aktualizację\",\n    \"restart\": \"Uruchom ponownie\",\n    \"updateCheckingDisabled\": \"Sprawdzanie aktualizacji wyłączone\",\n    \"downloading\": \"Pobieranie... {{progress}}%\",\n    \"installing\": \"Instalowanie...\",\n    \"preparing\": \"Przygotowywanie...\",\n    \"checkForUpdates\": \"Sprawdź aktualizacje\"\n  },\n  \"common\": {\n    \"loading\": \"Wczytywanie...\",\n    \"save\": \"Zapisz\",\n    \"cancel\": \"Anuluj\",\n    \"reset\": \"Resetuj\",\n    \"add\": \"Dodaj\",\n    \"remove\": \"Usuń\",\n    \"delete\": \"Usuń\",\n    \"edit\": \"Edytuj\",\n    \"create\": \"Utwórz\",\n    \"update\": \"Aktualizuj\",\n    \"close\": \"Zamknij\",\n    \"open\": \"Otwórz\",\n    \"default\": \"Domyślne\",\n    \"enabled\": \"Włączone\",\n    \"disabled\": \"Wyłączone\",\n    \"on\": \"Włącz\",\n    \"off\": \"Wyłącz\",\n    \"yes\": \"Tak\",\n    \"no\": \"Nie\",\n    \"noOptionsFound\": \"Nie znaleziono opcji\"\n  },\n  \"accessibility\": {\n    \"permissionsRequired\": \"Wymagane uprawnienia dostępności\",\n    \"permissionsDescription\": \"Handy potrzebuje uprawnień dostępności, aby wpisywać transkrybowany tekst.\",\n    \"openSettings\": \"Otwórz ustawienia systemowe\",\n    \"dismiss\": \"Zamknij\"\n  },\n  \"errors\": {\n    \"loadDirectory\": \"Błąd wczytywania katalogu: {{error}}\",\n    \"micPermissionDeniedTitle\": \"Odmowa dostępu do mikrofonu\",\n    \"micPermissionDenied\": {\n      \"generic\": \"System operacyjny odmówił dostępu do mikrofonu. Przyznaj uprawnienia do mikrofonu w ustawieniach systemowych.\",\n      \"windows\": \"Włącz dostęp do mikrofonu w Ustawienia → Prywatność i zabezpieczenia → Mikrofon (w tym dostęp aplikacji klasycznych).\",\n      \"macos\": \"Przyznaj dostęp do mikrofonu w Ustawienia systemowe → Prywatność i ochrona → Mikrofon.\",\n      \"linux\": \"Przyznaj dostęp do mikrofonu w ustawieniach dźwięku lub prywatności systemu.\"\n    },\n    \"recordingFailed\": \"Nie udało się rozpocząć nagrywania: {{error}}\",\n    \"modelLoadFailed\": \"Nie udało się załadować modelu: {{model}}\",\n    \"modelLoadFailedUnknown\": \"nieznany model\"\n  },\n  \"appLanguage\": {\n    \"title\": \"Język aplikacji\",\n    \"description\": \"Zmień język interfejsu Handy\"\n  },\n  \"overlay\": {\n    \"transcribing\": \"Transkrypcja...\",\n    \"processing\": \"Przetwarzanie...\"\n  }\n}\n"
  },
  {
    "path": "src/i18n/locales/pt/translation.json",
    "content": "{\n  \"tray\": {\n    \"settings\": \"Configurações...\",\n    \"checkUpdates\": \"Verificar Atualizações...\",\n    \"copyLastTranscript\": \"Copiar última transcrição\",\n    \"unloadModel\": \"Descarregar modelo\",\n    \"model\": \"Modelo\",\n    \"quit\": \"Sair\",\n    \"cancel\": \"Cancelar\"\n  },\n  \"sidebar\": {\n    \"general\": \"Geral\",\n    \"models\": \"Modelos\",\n    \"advanced\": \"Avançado\",\n    \"postProcessing\": \"Pós-Processamento\",\n    \"history\": \"Histórico\",\n    \"debug\": \"Depuração\",\n    \"about\": \"Sobre\"\n  },\n  \"onboarding\": {\n    \"subtitle\": \"Para começar, escolha um modelo de transcrição\",\n    \"recommended\": \"Recomendado\",\n    \"download\": \"Baixar\",\n    \"downloading\": \"Baixando...\",\n    \"customModelDescription\": \"Não suportado oficialmente\",\n    \"downloadFailed\": \"Falha no download. Por favor, tente novamente.\",\n    \"modelCard\": {\n      \"accuracy\": \"precisão\",\n      \"speed\": \"velocidade\"\n    },\n    \"models\": {\n      \"small\": {\n        \"name\": \"Whisper Small\",\n        \"description\": \"Rápido e razoavelmente preciso.\"\n      },\n      \"medium\": {\n        \"name\": \"Whisper Medium\",\n        \"description\": \"Boa precisão, velocidade média\"\n      },\n      \"turbo\": {\n        \"name\": \"Whisper Turbo\",\n        \"description\": \"Precisão e velocidade balanceadas.\"\n      },\n      \"large\": {\n        \"name\": \"Whisper Large\",\n        \"description\": \"Boa precisão, mas lento.\"\n      },\n      \"parakeet-tdt-0.6b-v2\": {\n        \"name\": \"Parakeet V2\",\n        \"description\": \"Apenas inglês. O melhor modelo para falantes de inglês.\"\n      },\n      \"parakeet-tdt-0.6b-v3\": {\n        \"name\": \"Parakeet V3\",\n        \"description\": \"Rápido e preciso\"\n      },\n      \"moonshine-base\": {\n        \"name\": \"Moonshine Base\",\n        \"description\": \"Muito rápido, apenas inglês. Lida bem com sotaques.\"\n      },\n      \"moonshine-tiny-streaming-en\": {\n        \"name\": \"Moonshine V2 Tiny\",\n        \"description\": \"Ultrarrápido, apenas inglês\"\n      },\n      \"moonshine-small-streaming-en\": {\n        \"name\": \"Moonshine V2 Small\",\n        \"description\": \"Rápido, apenas inglês. Bom equilíbrio entre velocidade e precisão.\"\n      },\n      \"moonshine-medium-streaming-en\": {\n        \"name\": \"Moonshine V2 Medium\",\n        \"description\": \"Apenas inglês. Alta qualidade.\"\n      },\n      \"breeze-asr\": {\n        \"name\": \"Breeze ASR\",\n        \"description\": \"Otimizado para mandarim taiwanês. Suporte para alternância de código.\"\n      },\n      \"sense-voice-int8\": {\n        \"name\": \"SenseVoice\",\n        \"description\": \"Muito rápido. Chinês, inglês, japonês, coreano, cantonês.\"\n      },\n      \"gigaam-v3-e2e-ctc\": {\n        \"name\": \"GigaAM v3\",\n        \"description\": \"Reconhecimento de voz em russo. Rápido e preciso.\"\n      },\n      \"canary-180m-flash\": {\n        \"name\": \"Canary 180M Flash\",\n        \"description\": \"Muito rápido. Inglês, alemão, espanhol, francês. Suporta tradução.\"\n      },\n      \"canary-1b-v2\": {\n        \"name\": \"Canary 1B v2\",\n        \"description\": \"Multilíngue preciso. 25 idiomas europeus. Suporta tradução.\"\n      }\n    },\n    \"errors\": {\n      \"loadModels\": \"Falha ao carregar modelos disponíveis\",\n      \"downloadModel\": \"Falha ao baixar modelo: {{error}}\"\n    },\n    \"permissions\": {\n      \"title\": \"Permissões Necessárias\",\n      \"description\": \"O Handy precisa de algumas permissões para funcionar corretamente.\",\n      \"microphone\": {\n        \"title\": \"Acesso ao Microfone\",\n        \"description\": \"Necessário para ouvir sua voz para transcrição.\"\n      },\n      \"accessibility\": {\n        \"title\": \"Acesso à Acessibilidade\",\n        \"description\": \"Necessário para digitar o texto transcrito em seus aplicativos.\"\n      },\n      \"grant\": \"Conceder Permissão\",\n      \"granted\": \"Concedido\",\n      \"waiting\": \"Aguardando...\",\n      \"allGranted\": \"Tudo pronto!\",\n      \"errors\": {\n        \"checkFailed\": \"Erro ao verificar permissões. Por favor, tente novamente.\",\n        \"requestFailed\": \"Erro ao solicitar permissão. Por favor, tente novamente.\"\n      }\n    }\n  },\n  \"modelSelector\": {\n    \"custom\": \"Personalizado\",\n    \"active\": \"Ativo\",\n    \"noModelsAvailable\": \"Nenhum modelo disponível\",\n    \"extracting\": \"Extraindo {{modelName}}...\",\n    \"extractingMultiple\": \"Extraindo {{count}} modelos...\",\n    \"extractingGeneric\": \"Extraindo...\",\n    \"downloading\": \"Baixando {{percentage}}%\",\n    \"downloadingMultiple\": \"Baixando {{count}} modelos...\",\n    \"modelReady\": \"Modelo Pronto\",\n    \"loading\": \"Carregando {{modelName}}...\",\n    \"loadingGeneric\": \"Carregando...\",\n    \"modelError\": \"Erro no Modelo\",\n    \"modelUnloaded\": \"Modelo Descarregado\",\n    \"noModelDownloadRequired\": \"Sem Modelo - Download Necessário\",\n    \"deleteModel\": \"Excluir {{modelName}}\",\n    \"switching\": \"Alternando...\",\n    \"downloadSpeed\": \"{{speed}} MB/s\",\n    \"capabilities\": {\n      \"languageSelection\": \"Suporta vários idiomas de entrada\",\n      \"multiLanguage\": \"Multi-idioma\",\n      \"translation\": \"Pode traduzir para inglês\",\n      \"translate\": \"Traduzir para inglês\",\n      \"singleLanguage\": \"Suporta apenas este idioma\",\n      \"languageOnly\": \"Apenas {{language}}\"\n    },\n    \"cancel\": \"Cancelar\",\n    \"cancelDownload\": \"Cancelar download\"\n  },\n  \"settings\": {\n    \"modelSettings\": {\n      \"title\": \"Configurações de {{model}}\",\n      \"noSettingsNeeded\": \"Este modelo funciona automaticamente sem necessidade de configuração.\"\n    },\n    \"general\": {\n      \"title\": \"Geral\",\n      \"shortcut\": {\n        \"title\": \"Atalhos do Handy\",\n        \"description\": \"Configure atalhos de teclado para iniciar a gravação de voz para texto\",\n        \"loading\": \"Carregando atalhos...\",\n        \"none\": \"Nenhum atalho configurado\",\n        \"notFound\": \"Atalho não encontrado\",\n        \"pressKeys\": \"Pressione as teclas...\",\n        \"bindings\": {\n          \"transcribe\": {\n            \"name\": \"Atalho de Transcrição\",\n            \"description\": \"O atalho de teclado para gravar e transcrever sua voz.\"\n          },\n          \"cancel\": {\n            \"name\": \"Atalho de Cancelar\",\n            \"description\": \"O atalho de teclado para cancelar a gravação atual.\"\n          },\n          \"transcribe_with_post_process\": {\n            \"name\": \"Tecla de Pós-Processamento\",\n            \"description\": \"Opcional: Uma tecla de atalho dedicada que sempre aplica pós-processamento com IA à sua transcrição.\"\n          }\n        },\n        \"errors\": {\n          \"restore\": \"Falha ao restaurar atalho original\",\n          \"set\": \"Falha ao definir atalho: {{error}}\",\n          \"reset\": \"Falha ao redefinir atalho para o valor original\"\n        }\n      },\n      \"language\": {\n        \"title\": \"Idioma\",\n        \"description\": \"Selecione o idioma para reconhecimento de fala. Auto detectará automaticamente o idioma, enquanto selecionar um idioma específico pode melhorar a precisão para esse idioma.\",\n        \"descriptionUnsupported\": \"O modelo Parakeet detecta automaticamente o idioma. Não é necessária seleção manual.\",\n        \"searchPlaceholder\": \"Buscar idiomas...\",\n        \"noResults\": \"Nenhum idioma encontrado\",\n        \"auto\": \"Auto\"\n      },\n      \"pushToTalk\": {\n        \"label\": \"Pressionar para Falar\",\n        \"description\": \"Segure para gravar, solte para parar\"\n      }\n    },\n    \"models\": {\n      \"title\": \"Modelos de Transcrição\",\n      \"description\": \"Selecione um modelo de transcrição ou baixe modelos adicionais. Diferentes modelos oferecem diferentes níveis de precisão e velocidade.\",\n      \"downloaded\": \"Baixados\",\n      \"available\": \"Disponíveis para Download\",\n      \"deleteConfirm\": \"Tem certeza de que deseja excluir {{modelName}}? Você precisará baixá-lo novamente para usá-lo.\",\n      \"deleteActiveConfirm\": \"{{modelName}} é o seu modelo ativo. Excluí-lo interromperá as transcrições até que você selecione um novo modelo. Tem certeza?\",\n      \"deleteTitle\": \"Excluir Modelo\",\n      \"filters\": {\n        \"all\": \"Todos\",\n        \"multiLanguage\": \"Multi-idioma\",\n        \"translation\": \"Tradução\",\n        \"allLanguages\": \"Todos os Idiomas\"\n      },\n      \"noModelsMatch\": \"Nenhum modelo corresponde a este filtro.\",\n      \"yourModels\": \"Modelos baixados\",\n      \"availableModels\": \"Disponíveis para download\"\n    },\n    \"sound\": {\n      \"title\": \"Som\",\n      \"microphone\": {\n        \"title\": \"Microfone\",\n        \"description\": \"Selecione seu dispositivo de microfone preferido\",\n        \"placeholder\": \"Selecionar microfone...\",\n        \"loading\": \"Carregando...\"\n      },\n      \"audioFeedback\": {\n        \"label\": \"Feedback de Áudio\",\n        \"description\": \"Reproduzir som quando a gravação iniciar e parar\"\n      },\n      \"outputDevice\": {\n        \"title\": \"Dispositivo de Saída\",\n        \"description\": \"Selecione seu dispositivo de saída de áudio preferido para sons de feedback\",\n        \"placeholder\": \"Selecionar dispositivo de saída...\",\n        \"loading\": \"Carregando...\"\n      },\n      \"volume\": {\n        \"title\": \"Volume\",\n        \"description\": \"Ajustar o volume dos sons de feedback de áudio\"\n      }\n    },\n    \"advanced\": {\n      \"title\": \"Avançado\",\n      \"groups\": {\n        \"app\": \"Aplicativo\",\n        \"output\": \"Saída\",\n        \"transcription\": \"Transcrição\",\n        \"history\": \"Histórico\",\n        \"experimental\": \"Experimental\"\n      },\n      \"experimentalToggle\": {\n        \"label\": \"Recursos Experimentais\",\n        \"description\": \"Ativar recursos experimentais que ainda estão em desenvolvimento.\"\n      },\n      \"lazyStreamClose\": {\n        \"label\": \"Manter microfone aberto entre transcrições\",\n        \"description\": \"Mantém o fluxo do microfone aberto por 30 segundos após parar a gravação, reduzindo a latência em transcrições consecutivas. Pode degradar a qualidade do áudio Bluetooth enquanto ativo.\"\n      },\n      \"acceleration\": {\n        \"whisper\": {\n          \"title\": \"Aceleração Whisper\",\n          \"description\": \"Aceleração por hardware para modelos Whisper. Auto usa GPU se disponível (Metal no macOS, Vulkan no Windows/Linux).\"\n        },\n        \"ort\": {\n          \"title\": \"Aceleração ONNX\",\n          \"description\": \"Aceleração por hardware para modelos ONNX (Parakeet, Canary, Moonshine, etc.). DirectML no Windows é experimental. Os modelos podem falhar na transcrição.\"\n        }\n      },\n      \"startHidden\": {\n        \"label\": \"Iniciar Oculto\",\n        \"description\": \"Iniciar na bandeja do sistema sem abrir a janela.\"\n      },\n      \"autostart\": {\n        \"label\": \"Iniciar na Inicialização\",\n        \"description\": \"Iniciar automaticamente o Handy quando você fizer login no seu computador.\"\n      },\n      \"showTrayIcon\": {\n        \"label\": \"Mostrar ícone na bandeja\",\n        \"description\": \"Exibir o ícone do Handy na bandeja do sistema.\"\n      },\n      \"overlay\": {\n        \"title\": \"Posição da Sobreposição\",\n        \"description\": \"Exibir sobreposição de feedback visual durante gravação e transcrição. No Linux, 'Nenhum' é recomendado.\",\n        \"options\": {\n          \"none\": \"Nenhum\",\n          \"bottom\": \"Inferior\",\n          \"top\": \"Superior\"\n        }\n      },\n      \"pasteMethod\": {\n        \"title\": \"Método de Colar\",\n        \"description\": \"Escolha como o texto é inserido. Direto: simula digitação via entrada do sistema. Nenhum: ignora colar, apenas atualiza histórico/área de transferência.\",\n        \"options\": {\n          \"clipboard\": \"Área de Transferência ({{modifier}}+V)\",\n          \"clipboardCtrlShiftV\": \"Área de Transferência (Ctrl+Shift+V)\",\n          \"clipboardShiftInsert\": \"Área de Transferência (Shift+Insert)\",\n          \"direct\": \"Direto\",\n          \"none\": \"Nenhum\",\n          \"externalScript\": \"Script externo\"\n        },\n        \"externalScriptPlaceholder\": \"/caminho/para/seu/script.sh\"\n      },\n      \"typingTool\": {\n        \"title\": \"Ferramenta de digitação\",\n        \"description\": \"Escolha qual ferramenta de digitação do Linux usar para o método de colagem direta. Auto detectará e usará automaticamente a melhor ferramenta disponível para o seu sistema.\",\n        \"options\": {\n          \"auto\": \"Auto (Recomendado)\"\n        }\n      },\n      \"clipboardHandling\": {\n        \"title\": \"Manipulação da Área de Transferência\",\n        \"description\": \"Não Modificar Área de Transferência preserva o conteúdo atual da área de transferência após a transcrição. Copiar para Área de Transferência deixa o resultado da transcrição na área de transferência após colar.\",\n        \"options\": {\n          \"dontModify\": \"Não Modificar Área de Transferência\",\n          \"copyToClipboard\": \"Copiar para Área de Transferência\"\n        }\n      },\n      \"autoSubmit\": {\n        \"title\": \"Envio automático\",\n        \"description\": \"Envia automaticamente a combinação de teclas selecionada após a inserção do texto. Cmd+Enter aplica-se no macOS, enquanto Windows/Linux usam Super+Enter.\",\n        \"options\": {\n          \"off\": \"Desativado\",\n          \"enter\": \"Enter\",\n          \"cmdEnter\": \"Cmd+Enter\",\n          \"superEnter\": \"Super+Enter\",\n          \"ctrlEnter\": \"Ctrl+Enter\"\n        }\n      },\n      \"translateToEnglish\": {\n        \"label\": \"Traduzir para Inglês\",\n        \"description\": \"Traduzir automaticamente fala de outros idiomas para inglês durante a transcrição.\",\n        \"descriptionUnsupported\": \"Tradução não é suportada pelo modelo {{model}}.\"\n      },\n      \"modelUnload\": {\n        \"title\": \"Descarregar Modelo\",\n        \"description\": \"Liberar automaticamente memória GPU/CPU quando o modelo não for usado pelo tempo especificado\",\n        \"options\": {\n          \"never\": \"Nunca\",\n          \"immediately\": \"Imediatamente\",\n          \"min2\": \"Após 2 minutos\",\n          \"min5\": \"Após 5 minutos\",\n          \"min10\": \"Após 10 minutos\",\n          \"min15\": \"Após 15 minutos\",\n          \"hour1\": \"Após 1 hora\",\n          \"sec15\": \"Após 15 segundos (Depuração)\"\n        }\n      },\n      \"customWords\": {\n        \"title\": \"Palavras Personalizadas\",\n        \"description\": \"Adicione palavras que são frequentemente mal ouvidas ou mal escritas durante a transcrição. O sistema irá automaticamente corrigir palavras semelhantes para corresponder à sua lista.\",\n        \"placeholder\": \"Adicionar uma palavra\",\n        \"add\": \"Adicionar\",\n        \"remove\": \"Remover {{word}}\",\n        \"duplicate\": \"\\\"{{word}}\\\" já existe\"\n      }\n    },\n    \"postProcessing\": {\n      \"title\": \"Pós-Processamento\",\n      \"hotkey\": {\n        \"title\": \"Tecla de atalho\"\n      },\n      \"api\": {\n        \"title\": \"API (Compatível com OpenAI)\",\n        \"provider\": {\n          \"title\": \"Provedor\",\n          \"description\": \"Selecione um provedor compatível com OpenAI.\"\n        },\n        \"appleIntelligence\": {\n          \"title\": \"Apple Intelligence\",\n          \"description\": \"Executa totalmente no dispositivo. Nenhuma chave de API ou acesso à rede é necessário.\",\n          \"requirements\": \"Requer um Mac Apple Silicon executando macOS Tahoe (26.0) ou posterior. Apple Intelligence deve estar habilitado nas Configurações do Sistema.\",\n          \"unavailable\": \"Apple Intelligence não está disponível neste dispositivo. Requer um Mac Apple Silicon executando macOS Tahoe (26.0) ou posterior com Apple Intelligence habilitado nas Configurações do Sistema.\"\n        },\n        \"baseUrl\": {\n          \"title\": \"URL Base\",\n          \"description\": \"URL base da API para o provedor selecionado. Apenas o provedor personalizado pode ser editado.\",\n          \"placeholder\": \"https://api.openai.com/v1\"\n        },\n        \"apiKey\": {\n          \"title\": \"Chave da API\",\n          \"description\": \"Chave da API para o provedor selecionado.\",\n          \"placeholder\": \"sk-...\"\n        },\n        \"model\": {\n          \"title\": \"Modelo\",\n          \"descriptionApple\": \"Forneça um limite numérico opcional de tokens ou mantenha a predefinição padrão no dispositivo.\",\n          \"descriptionCustom\": \"Forneça o identificador do modelo esperado pelo seu endpoint personalizado.\",\n          \"descriptionDefault\": \"Escolha um modelo exposto pelo provedor selecionado.\",\n          \"placeholderApple\": \"Apple Intelligence\",\n          \"placeholderWithOptions\": \"Buscar ou selecionar um modelo\",\n          \"placeholderNoOptions\": \"Digite o nome de um modelo\",\n          \"refreshModels\": \"Atualizar modelos\"\n        }\n      },\n      \"prompts\": {\n        \"title\": \"Prompt\",\n        \"selectedPrompt\": {\n          \"title\": \"Prompt Selecionado\",\n          \"description\": \"Selecione um modelo para refinar transcrições ou crie um novo. Use ${output} dentro do texto do prompt para referenciar a transcrição capturada.\"\n        },\n        \"noPrompts\": \"Nenhum prompt disponível\",\n        \"selectPrompt\": \"Selecionar um prompt\",\n        \"createNew\": \"Criar Novo Prompt\",\n        \"promptLabel\": \"Rótulo do Prompt\",\n        \"promptLabelPlaceholder\": \"Digite o nome do prompt\",\n        \"promptInstructions\": \"Instruções do Prompt\",\n        \"promptInstructionsPlaceholder\": \"Escreva as instruções para executar após a transcrição. Exemplo: Melhore a gramática e clareza do seguinte texto: ${output}\",\n        \"promptTip\": \"Dica: Use <code>${output}</code> para inserir o texto transcrito no seu prompt.\",\n        \"updatePrompt\": \"Atualizar Prompt\",\n        \"deletePrompt\": \"Excluir Prompt\",\n        \"createPrompt\": \"Criar Prompt\",\n        \"cancel\": \"Cancelar\",\n        \"selectToEdit\": \"Selecione um prompt acima para visualizar e editar seus detalhes.\",\n        \"createFirst\": \"Clique em 'Criar Novo Prompt' acima para criar seu primeiro prompt de pós-processamento.\"\n      }\n    },\n    \"history\": {\n      \"title\": \"Histórico\",\n      \"openFolder\": \"Abrir Pasta de Gravações\",\n      \"loading\": \"Carregando histórico...\",\n      \"empty\": \"Nenhuma transcrição ainda. Comece a gravar para construir seu histórico!\",\n      \"copyToClipboard\": \"Copiar transcrição para área de transferência\",\n      \"save\": \"Salvar transcrição\",\n      \"unsave\": \"Remover dos salvos\",\n      \"delete\": \"Excluir entrada\",\n      \"deleteError\": \"Falha ao excluir entrada. Por favor, tente novamente.\"\n    },\n    \"debug\": {\n      \"title\": \"Depuração\",\n      \"logDirectory\": {\n        \"title\": \"Diretório de Logs\",\n        \"description\": \"Local onde os arquivos de log são armazenados\"\n      },\n      \"logLevel\": {\n        \"title\": \"Nível de Log\",\n        \"description\": \"Definir a verbosidade do registro\"\n      },\n      \"updateChecks\": {\n        \"label\": \"Verificar Atualizações\",\n        \"description\": \"Verificar automaticamente novas versões do Handy\"\n      },\n      \"soundTheme\": {\n        \"label\": \"Tema de Som\",\n        \"description\": \"Escolha um tema de som para feedback de início e parada de gravação\"\n      },\n      \"wordCorrectionThreshold\": {\n        \"title\": \"Limite de Correção de Palavras\",\n        \"description\": \"Sensibilidade para correções de palavras personalizadas\"\n      },\n      \"historyLimit\": {\n        \"title\": \"Limite de Histórico\",\n        \"description\": \"Número máximo de entradas de histórico a manter\",\n        \"entries\": \"entradas\"\n      },\n      \"recordingRetention\": {\n        \"title\": \"Excluir Gravações Automaticamente\",\n        \"description\": \"Excluir automaticamente gravações antigas para economizar espaço\",\n        \"never\": \"Nunca\",\n        \"preserveLimit\": \"Manter as últimas {{count}}\",\n        \"days3\": \"Após 3 dias\",\n        \"weeks2\": \"Após 2 semanas\",\n        \"months3\": \"Após 3 meses\",\n        \"placeholder\": \"Selecionar período de retenção...\"\n      },\n      \"alwaysOnMicrophone\": {\n        \"label\": \"Microfone Sempre Ativo\",\n        \"description\": \"Manter microfone ativo para resposta mais rápida\"\n      },\n      \"clamshellMicrophone\": {\n        \"title\": \"Microfone em Modo Fechado\",\n        \"description\": \"Microfone a usar quando a tampa do laptop está fechada\"\n      },\n      \"postProcessingToggle\": {\n        \"label\": \"Pós-Processamento\",\n        \"description\": \"Habilitar refinamento de texto com IA após a transcrição\"\n      },\n      \"muteWhileRecording\": {\n        \"label\": \"Silenciar Durante Gravação\",\n        \"description\": \"Silenciar áudio do sistema durante a gravação\"\n      },\n      \"appendTrailingSpace\": {\n        \"label\": \"Adicionar Espaço Final\",\n        \"description\": \"Adicionar um espaço após a transcrição colada\"\n      },\n      \"keyboardImplementation\": {\n        \"title\": \"Implementação do Teclado\",\n        \"description\": \"Escolha o sistema responsável pelos atalhos de teclado.\",\n        \"bindingsReset\": \"Os atalhos de teclado eram incompatíveis e foram redefinidos para os padrões\"\n      },\n      \"paths\": {\n        \"appData\": \"Dados do App:\",\n        \"models\": \"Modelos:\",\n        \"settings\": \"Configurações:\"\n      },\n      \"pasteDelay\": {\n        \"title\": \"Atraso de colagem\",\n        \"description\": \"Atraso antes de enviar a tecla de colar (em milissegundos). Aumente se o texto errado estiver sendo colado.\"\n      },\n      \"recordingBuffer\": {\n        \"title\": \"Buffer de gravação extra\",\n        \"description\": \"Tempo extra (em milissegundos) para continuar gravando após soltar a tecla, para capturar áudio restante. 0 = sem buffer extra.\"\n      }\n    },\n    \"about\": {\n      \"title\": \"Sobre\",\n      \"version\": {\n        \"title\": \"Versão\",\n        \"description\": \"Versão atual do Handy\"\n      },\n      \"appDataDirectory\": {\n        \"title\": \"Diretório de Dados do App\",\n        \"description\": \"Local onde o Handy armazena seus dados\"\n      },\n      \"sourceCode\": {\n        \"title\": \"Código Fonte\",\n        \"description\": \"Visualizar código fonte e contribuir\",\n        \"button\": \"Ver no GitHub\"\n      },\n      \"supportDevelopment\": {\n        \"title\": \"Apoiar o Desenvolvimento\",\n        \"description\": \"Ajude-nos a continuar construindo o Handy\",\n        \"button\": \"Doar\"\n      },\n      \"acknowledgments\": {\n        \"title\": \"Agradecimentos\",\n        \"whisper\": {\n          \"title\": \"Whisper.cpp\",\n          \"description\": \"Inferência de alto desempenho do modelo de reconhecimento automático de fala Whisper da OpenAI\",\n          \"details\": \"O Handy usa Whisper.cpp para processamento rápido e local de fala para texto. Agradecemos ao incrível trabalho de Georgi Gerganov e colaboradores.\"\n        }\n      }\n    }\n  },\n  \"footer\": {\n    \"downloadingModel\": \"Baixando {{model}}...\",\n    \"checkingUpdates\": \"Verificando atualizações...\",\n    \"updateAvailable\": \"Atualização disponível: {{version}}\",\n    \"updateAvailableShort\": \"Atualização disponível\",\n    \"upToDate\": \"Atualizado\",\n    \"downloadUpdate\": \"Baixar Atualização\",\n    \"restart\": \"Reiniciar\",\n    \"updateCheckingDisabled\": \"Verificação de Atualização Desabilitada\",\n    \"downloading\": \"Baixando... {{progress}}%\",\n    \"installing\": \"Instalando...\",\n    \"preparing\": \"Preparando...\",\n    \"checkForUpdates\": \"Verificar atualizações\"\n  },\n  \"common\": {\n    \"loading\": \"Carregando...\",\n    \"save\": \"Salvar\",\n    \"cancel\": \"Cancelar\",\n    \"reset\": \"Redefinir\",\n    \"add\": \"Adicionar\",\n    \"remove\": \"Remover\",\n    \"delete\": \"Excluir\",\n    \"edit\": \"Editar\",\n    \"create\": \"Criar\",\n    \"update\": \"Atualizar\",\n    \"close\": \"Fechar\",\n    \"open\": \"Abrir\",\n    \"default\": \"Padrão\",\n    \"enabled\": \"Habilitado\",\n    \"disabled\": \"Desabilitado\",\n    \"on\": \"Ligado\",\n    \"off\": \"Desligado\",\n    \"yes\": \"Sim\",\n    \"no\": \"Não\",\n    \"noOptionsFound\": \"Nenhuma opção encontrada\"\n  },\n  \"accessibility\": {\n    \"permissionsRequired\": \"Permissões de Acessibilidade Necessárias\",\n    \"permissionsDescription\": \"O Handy precisa de permissões de acessibilidade para digitar o texto transcrito.\",\n    \"openSettings\": \"Abrir Configurações do Sistema\",\n    \"dismiss\": \"Dispensar\"\n  },\n  \"errors\": {\n    \"loadDirectory\": \"Erro ao carregar diretório: {{error}}\",\n    \"micPermissionDeniedTitle\": \"Acesso ao microfone negado\",\n    \"micPermissionDenied\": {\n      \"generic\": \"O acesso ao microfone foi negado pelo sistema operacional. Conceda a permissão do microfone nas configurações do sistema.\",\n      \"windows\": \"Ative o acesso ao microfone em Configurações → Privacidade e segurança → Microfone (incluindo acesso de aplicativos da área de trabalho).\",\n      \"macos\": \"Conceda o acesso ao microfone em Ajustes do Sistema → Privacidade e Segurança → Microfone.\",\n      \"linux\": \"Conceda o acesso ao microfone nas configurações de som ou privacidade do seu sistema.\"\n    },\n    \"recordingFailed\": \"Falha ao iniciar a gravação: {{error}}\",\n    \"modelLoadFailed\": \"Falha ao carregar o modelo: {{model}}\",\n    \"modelLoadFailedUnknown\": \"modelo desconhecido\"\n  },\n  \"appLanguage\": {\n    \"title\": \"Idioma da Aplicação\",\n    \"description\": \"Alterar o idioma da interface do Handy\"\n  },\n  \"overlay\": {\n    \"transcribing\": \"Transcrevendo...\",\n    \"processing\": \"Processando...\"\n  }\n}\n"
  },
  {
    "path": "src/i18n/locales/ru/translation.json",
    "content": "{\n  \"tray\": {\n    \"settings\": \"Настройки...\",\n    \"checkUpdates\": \"Проверить обновления...\",\n    \"copyLastTranscript\": \"Скопировать последнюю транскрипцию\",\n    \"unloadModel\": \"Выгрузить модель\",\n    \"model\": \"Модель\",\n    \"quit\": \"Выход\",\n    \"cancel\": \"Отмена\"\n  },\n  \"sidebar\": {\n    \"general\": \"Общие\",\n    \"models\": \"Модели\",\n    \"advanced\": \"Продвинутые\",\n    \"postProcessing\": \"Постобработка\",\n    \"history\": \"История\",\n    \"debug\": \"Отладка\",\n    \"about\": \"О программе\"\n  },\n  \"onboarding\": {\n    \"subtitle\": \"Для начала выберите модель транскрипции\",\n    \"recommended\": \"Рекомендуется\",\n    \"download\": \"Скачать\",\n    \"downloading\": \"Загрузка...\",\n    \"customModelDescription\": \"Официально не поддерживается\",\n    \"downloadFailed\": \"Загрузка не удалась. Пожалуйста, попробуйте снова.\",\n    \"modelCard\": {\n      \"accuracy\": \"точность\",\n      \"speed\": \"скорость\"\n    },\n    \"models\": {\n      \"small\": {\n        \"name\": \"Whisper Small\",\n        \"description\": \"Быстрая и достаточно точная.\"\n      },\n      \"medium\": {\n        \"name\": \"Whisper Medium\",\n        \"description\": \"Хорошая точность, средняя скорость.\"\n      },\n      \"turbo\": {\n        \"name\": \"Whisper Turbo\",\n        \"description\": \"Сбалансированная точность и скорость.\"\n      },\n      \"large\": {\n        \"name\": \"Whisper Large\",\n        \"description\": \"Хорошая точность, но медленная.\"\n      },\n      \"parakeet-tdt-0.6b-v2\": {\n        \"name\": \"Parakeet V2\",\n        \"description\": \"Только английский. Лучшая модель для англоговорящих.\"\n      },\n      \"parakeet-tdt-0.6b-v3\": {\n        \"name\": \"Parakeet V3\",\n        \"description\": \"Быстрая и точная\"\n      },\n      \"moonshine-base\": {\n        \"name\": \"Moonshine Base\",\n        \"description\": \"Очень быстрая, только английский. Хорошо справляется с акцентами.\"\n      },\n      \"moonshine-tiny-streaming-en\": {\n        \"name\": \"Moonshine V2 Tiny\",\n        \"description\": \"Сверхбыстрая, только английский\"\n      },\n      \"moonshine-small-streaming-en\": {\n        \"name\": \"Moonshine V2 Small\",\n        \"description\": \"Быстрая, только английский. Хороший баланс скорости и точности.\"\n      },\n      \"moonshine-medium-streaming-en\": {\n        \"name\": \"Moonshine V2 Medium\",\n        \"description\": \"Только английский. Высокое качество.\"\n      },\n      \"breeze-asr\": {\n        \"name\": \"Breeze ASR\",\n        \"description\": \"Оптимизирована для тайваньского мандаринского. Поддержка переключения языков.\"\n      },\n      \"sense-voice-int8\": {\n        \"name\": \"SenseVoice\",\n        \"description\": \"Очень быстрая. Китайский, английский, японский, корейский, кантонский.\"\n      },\n      \"gigaam-v3-e2e-ctc\": {\n        \"name\": \"GigaAM v3\",\n        \"description\": \"Распознавание русской речи. Быстро и точно.\"\n      },\n      \"canary-180m-flash\": {\n        \"name\": \"Canary 180M Flash\",\n        \"description\": \"Очень быстрая. Английский, немецкий, испанский, французский. Поддержка перевода.\"\n      },\n      \"canary-1b-v2\": {\n        \"name\": \"Canary 1B v2\",\n        \"description\": \"Точная многоязычная. 25 европейских языков. Поддержка перевода.\"\n      }\n    },\n    \"errors\": {\n      \"loadModels\": \"Не удалось загрузить доступные модели.\",\n      \"downloadModel\": \"Не удалось загрузить модель: {{error}}.\"\n    },\n    \"permissions\": {\n      \"title\": \"Требуются разрешения\",\n      \"description\": \"Handy требуются некоторые разрешения для корректной работы.\",\n      \"microphone\": {\n        \"title\": \"Доступ к микрофону\",\n        \"description\": \"Необходим для прослушивания вашего голоса для транскрипции.\"\n      },\n      \"accessibility\": {\n        \"title\": \"Доступ к универсальному доступу\",\n        \"description\": \"Необходим для ввода расшифрованного текста в ваши приложения.\"\n      },\n      \"grant\": \"Предоставить разрешение\",\n      \"granted\": \"Предоставлено\",\n      \"waiting\": \"Ожидание...\",\n      \"allGranted\": \"Всё готово!\",\n      \"errors\": {\n        \"checkFailed\": \"Не удалось проверить разрешения. Пожалуйста, попробуйте снова.\",\n        \"requestFailed\": \"Не удалось запросить разрешение. Пожалуйста, попробуйте снова.\"\n      }\n    }\n  },\n  \"modelSelector\": {\n    \"custom\": \"Пользовательская\",\n    \"active\": \"Активный\",\n    \"switching\": \"Переключение...\",\n    \"noModelsAvailable\": \"Нет доступных моделей\",\n    \"extracting\": \"Извлечение {{modelName}}...\",\n    \"extractingMultiple\": \"Извлечение моделей {{count}}...\",\n    \"extractingGeneric\": \"Извлечение...\",\n    \"downloading\": \"Загрузка {{percentage}} %\",\n    \"downloadingMultiple\": \"Загрузка моделей {{count}}...\",\n    \"modelReady\": \"Модель готова\",\n    \"loading\": \"Загрузка {{modelName}}...\",\n    \"loadingGeneric\": \"Загрузка...\",\n    \"modelError\": \"Ошибка модели\",\n    \"modelUnloaded\": \"Модель выгружена\",\n    \"noModelDownloadRequired\": \"Нет модели – требуется загрузка\",\n    \"deleteModel\": \"Удалить {{modelName}}\",\n    \"downloadSpeed\": \"{{speed}} МБ/с\",\n    \"capabilities\": {\n      \"languageSelection\": \"Поддерживает несколько языков ввода\",\n      \"multiLanguage\": \"Многоязычная\",\n      \"translation\": \"Может переводить на английский\",\n      \"translate\": \"Перевод на английский\",\n      \"singleLanguage\": \"Поддерживает только этот язык\",\n      \"languageOnly\": \"Только {{language}}\"\n    },\n    \"cancel\": \"Отмена\",\n    \"cancelDownload\": \"Отменить загрузку\"\n  },\n  \"settings\": {\n    \"modelSettings\": {\n      \"title\": \"Настройки {{model}}\",\n      \"noSettingsNeeded\": \"Эта модель работает автоматически без необходимости настройки.\"\n    },\n    \"models\": {\n      \"title\": \"Модели транскрипции\",\n      \"description\": \"Выберите модель транскрипции или загрузите дополнительные модели. Разные модели предлагают разные уровни точности и скорости.\",\n      \"downloaded\": \"Загружено\",\n      \"available\": \"Доступные для загрузки\",\n      \"deleteConfirm\": \"Вы уверены, что хотите удалить {{modelName}}? Вам нужно будет загрузить её снова, чтобы использовать.\",\n      \"deleteActiveConfirm\": \"{{modelName}} — ваша активная модель. Удаление остановит транскрипцию, пока вы не выберете новую модель. Вы уверены?\",\n      \"deleteTitle\": \"Удалить модель\",\n      \"filters\": {\n        \"all\": \"Все\",\n        \"multiLanguage\": \"Многоязычные\",\n        \"translation\": \"Перевод\",\n        \"allLanguages\": \"Все языки\"\n      },\n      \"noModelsMatch\": \"Нет моделей, соответствующих этому фильтру.\",\n      \"yourModels\": \"Загруженные модели\",\n      \"availableModels\": \"Доступны для загрузки\"\n    },\n    \"general\": {\n      \"title\": \"Общие\",\n      \"shortcut\": {\n        \"title\": \"Ярлыки Handy\",\n        \"description\": \"Настройте сочетания клавиш для запуска записи речи в текст\",\n        \"loading\": \"Загрузка ярлыков...\",\n        \"none\": \"Ярлыки не настроены\",\n        \"notFound\": \"Ярлык не найден\",\n        \"pressKeys\": \"Нажимайте клавиши...\",\n        \"bindings\": {\n          \"transcribe\": {\n            \"name\": \"Горячая клавиша транскрипции\",\n            \"description\": \"Сочетание клавиш для записи и транскрибирования вашего голоса.\"\n          },\n          \"cancel\": {\n            \"name\": \"Горячая клавиша отмены\",\n            \"description\": \"Сочетание клавиш для отмены текущей записи.\"\n          },\n          \"transcribe_with_post_process\": {\n            \"name\": \"Горячая клавиша постобработки\",\n            \"description\": \"Специальное сочетание клавиш для ИИ-постобработки вашей транскрипции.\"\n          }\n        },\n        \"errors\": {\n          \"restore\": \"Не удалось восстановить исходный ярлык\",\n          \"set\": \"Не удалось установить ярлык: {{error}}\",\n          \"reset\": \"Не удалось сбросить ярлык до исходного значения\"\n        }\n      },\n      \"language\": {\n        \"title\": \"Язык\",\n        \"description\": \"Выберите язык для распознавания речи. «Авто» автоматически определит язык, а выбор конкретного языка может повысить точность определения этого языка.\",\n        \"descriptionUnsupported\": \"Модель Parakeet автоматически определяет язык. Ручной выбор не требуется.\",\n        \"searchPlaceholder\": \"Поиск языков...\",\n        \"noResults\": \"Языки не найдены\",\n        \"auto\": \"Авто\"\n      },\n      \"pushToTalk\": {\n        \"label\": \"Нажми и говори\",\n        \"description\": \"Удерживайте, чтобы записать, отпустите, чтобы остановить\"\n      }\n    },\n    \"sound\": {\n      \"title\": \"Звук\",\n      \"microphone\": {\n        \"title\": \"Микрофон\",\n        \"description\": \"Выберите предпочитаемое устройство ввода звука\",\n        \"placeholder\": \"Выбрать микрофон...\",\n        \"loading\": \"Загрузка...\"\n      },\n      \"audioFeedback\": {\n        \"label\": \"Обратная звуковая связь\",\n        \"description\": \"Воспроизведение звука при запуске и остановке записи\"\n      },\n      \"outputDevice\": {\n        \"title\": \"Устройство вывода\",\n        \"description\": \"Выберите предпочитаемое устройство вывода звука для звуков обратной связи\",\n        \"placeholder\": \"Выберите устройство вывода...\",\n        \"loading\": \"Загрузка...\"\n      },\n      \"volume\": {\n        \"title\": \"Громкость\",\n        \"description\": \"Отрегулируйте громкость звуков обратной связи\"\n      }\n    },\n    \"advanced\": {\n      \"title\": \"Продвинутые\",\n      \"groups\": {\n        \"app\": \"Приложение\",\n        \"output\": \"Вывод\",\n        \"transcription\": \"Транскрипция\",\n        \"history\": \"История\",\n        \"experimental\": \"Экспериментальное\"\n      },\n      \"experimentalToggle\": {\n        \"label\": \"Экспериментальные функции\",\n        \"description\": \"Включить экспериментальные функции, которые находятся в разработке.\"\n      },\n      \"lazyStreamClose\": {\n        \"label\": \"Оставлять микрофон включённым между транскрипциями\",\n        \"description\": \"Оставляет поток микрофона открытым в течение 30 секунд после остановки записи, уменьшая задержку при последовательных транскрипциях. Может ухудшить качество звука Bluetooth.\"\n      },\n      \"acceleration\": {\n        \"whisper\": {\n          \"title\": \"Ускорение Whisper\",\n          \"description\": \"Аппаратное ускорение для моделей Whisper. Автоматический режим использует GPU при наличии (Metal на macOS, Vulkan на Windows/Linux).\"\n        },\n        \"ort\": {\n          \"title\": \"Ускорение ONNX\",\n          \"description\": \"Аппаратное ускорение для моделей ONNX (Parakeet, Canary, Moonshine и др.). DirectML на Windows является экспериментальным. Модели могут не выполнять транскрипцию.\"\n        }\n      },\n      \"startHidden\": {\n        \"label\": \"Скрыть при запуске\",\n        \"description\": \"Запускать в системный трей, не открывая окно.\"\n      },\n      \"autostart\": {\n        \"label\": \"Запускать при старте\",\n        \"description\": \"Автоматически запускать Handy при входе в систему.\"\n      },\n      \"showTrayIcon\": {\n        \"label\": \"Показать значок в трее\",\n        \"description\": \"Отображать значок Handy в системном трее.\"\n      },\n      \"overlay\": {\n        \"title\": \"Позиция индикатора\",\n        \"description\": \"Отображение индикатора визуальной обратной связи во время записи и транскрипции. В Linux рекомендуется выбрать «Нет».\",\n        \"options\": {\n          \"none\": \"Нет\",\n          \"bottom\": \"Снизу\",\n          \"top\": \"Сверху\"\n        }\n      },\n      \"pasteMethod\": {\n        \"title\": \"Метод вставки\",\n        \"description\": \"Выбрать способ вставки текста. Прямой: имитирует набор текста через системный ввод. Нет: пропуск вставки, обновление только истории/буфера обмена.\",\n        \"options\": {\n          \"clipboard\": \"Буфер обмена ({{modifier}}+V)\",\n          \"clipboardCtrlShiftV\": \"Буфер обмена (Ctrl+Shift+V)\",\n          \"clipboardShiftInsert\": \"Буфер обмена (Shift+Insert)\",\n          \"direct\": \"Прямой\",\n          \"none\": \"Нет\",\n          \"externalScript\": \"Внешний скрипт\"\n        },\n        \"externalScriptPlaceholder\": \"/path/to/your/script.sh\"\n      },\n      \"typingTool\": {\n        \"title\": \"Инструмент ввода\",\n        \"description\": \"Выберите, какой инструмент ввода в Linux использовать для метода прямой вставки. Auto автоматически определит и использует лучший доступный инструмент для вашей системы.\",\n        \"options\": {\n          \"auto\": \"Auto (Рекомендуется)\"\n        }\n      },\n      \"clipboardHandling\": {\n        \"title\": \"Обработка буфера обмена\",\n        \"description\": \"Функция «Не изменять буфер обмена» сохраняет текущее содержимое буфера обмена после транскрипции. Копировать в буфер обмена оставляет результат транскрипции в буфере обмена после вставки.\",\n        \"options\": {\n          \"dontModify\": \"Не изменять буфер обмена\",\n          \"copyToClipboard\": \"Копировать в буфер обмена\"\n        }\n      },\n      \"autoSubmit\": {\n        \"title\": \"Автоматическая отправка\",\n        \"description\": \"Автоматически отправляет выбранную комбинацию клавиш после вставки текста. Cmd+Enter применяется на macOS, а Windows/Linux используют Super+Enter.\",\n        \"options\": {\n          \"off\": \"Выкл.\",\n          \"enter\": \"Enter\",\n          \"cmdEnter\": \"Cmd+Enter\",\n          \"superEnter\": \"Super+Enter\",\n          \"ctrlEnter\": \"Ctrl+Enter\"\n        }\n      },\n      \"translateToEnglish\": {\n        \"label\": \"Перевести на английский\",\n        \"description\": \"Автоматически переводить речь с других языков на английский во время транскрипции.\",\n        \"descriptionUnsupported\": \"Перевод не поддерживается моделью {{model}}.\"\n      },\n      \"modelUnload\": {\n        \"title\": \"Выгрузить модель\",\n        \"description\": \"Автоматически освобождать память, если модель не использовалась в течение указанного времени.\",\n        \"options\": {\n          \"never\": \"Никогда\",\n          \"immediately\": \"Немедленно\",\n          \"min2\": \"Через 2 минуты\",\n          \"min5\": \"Через 5 минут\",\n          \"min10\": \"Через 10 минут\",\n          \"min15\": \"Через 15 минут\",\n          \"hour1\": \"Через 1 час\",\n          \"sec15\": \"Через 15 секунд (отладка)\"\n        }\n      },\n      \"customWords\": {\n        \"title\": \"Пользовательский словарь\",\n        \"description\": \"Добавить слова, которые часто неправильно слышатся или пишутся с ошибками во время транскрипции. Система автоматически исправит похожие по звучанию слова, чтобы они соответствовали вашему списку.\",\n        \"placeholder\": \"Добавить слово\",\n        \"add\": \"Добавить\",\n        \"remove\": \"Удалить {{word}}\",\n        \"duplicate\": \"\\\"{{word}}\\\" уже существует\"\n      }\n    },\n    \"postProcessing\": {\n      \"title\": \"Постобработка\",\n      \"hotkey\": {\n        \"title\": \"Горячая клавиша\"\n      },\n      \"api\": {\n        \"title\": \"API (совместимый с OpenAI)\",\n        \"provider\": {\n          \"title\": \"Поставщик\",\n          \"description\": \"Выберите поставщика, совместимого с OpenAI.\"\n        },\n        \"appleIntelligence\": {\n          \"title\": \"Apple Интеллект\",\n          \"description\": \"Полностью работает на устройстве. Никакой ключ API или доступ к сети не требуется.\",\n          \"requirements\": \"Требуется Apple Silicon Mac под управлением macOS Tahoe (26.0) или более поздней версии. Apple Intelligence должен быть включен в настройках системы.\",\n          \"unavailable\": \"Apple Intelligence недоступен на этом устройстве. Требуется Apple Silicon Mac под управлением macOS Tahoe (26.0) или более поздней версии с включенным Apple Intelligence в настройках системы.\"\n        },\n        \"baseUrl\": {\n          \"title\": \"Базовый URL\",\n          \"description\": \"Базовый URL-адрес API для выбранного провайдера. Редактировать можно только настраиваемого поставщика.\",\n          \"placeholder\": \"https://api.openai.com/v1\"\n        },\n        \"apiKey\": {\n          \"title\": \"API-ключ\",\n          \"description\": \"API-ключ для выбранного провайдера.\",\n          \"placeholder\": \"sk-...\"\n        },\n        \"model\": {\n          \"title\": \"Модель\",\n          \"descriptionApple\": \"Укажите дополнительный числовой лимит токенов или сохраните настройки по умолчанию, установленные на устройстве.\",\n          \"descriptionCustom\": \"Укажите идентификатор модели, ожидаемый вашей пользовательской конечной точкой.\",\n          \"descriptionDefault\": \"Выберите модель, предоставляемую выбранным поставщиком.\",\n          \"placeholderApple\": \"Apple Интеллект\",\n          \"placeholderWithOptions\": \"Найдите или выберите модель\",\n          \"placeholderNoOptions\": \"Введите название модели\",\n          \"refreshModels\": \"Обновить модели\"\n        }\n      },\n      \"prompts\": {\n        \"title\": \"Запрос к модели\",\n        \"selectedPrompt\": {\n          \"title\": \"Выберите запрос\",\n          \"description\": \"Выберите запрос для постобработки транскрипции или создайте новый. Используйте ${output} внутри текста запроса для ссылки на записанную транскрипцию.\"\n        },\n        \"noPrompts\": \"Нет доступных запросов\",\n        \"selectPrompt\": \"Выберите запрос\",\n        \"createNew\": \"Создать новый запрос\",\n        \"promptLabel\": \"Название запроса\",\n        \"promptLabelPlaceholder\": \"Введите название запроса\",\n        \"promptInstructions\": \"Инструкции\",\n        \"promptInstructionsPlaceholder\": \"Напишите инструкции для постобработки транскрипции. Например: Улучшить грамматику и ясность следующего текста: ${output}\",\n        \"promptTip\": \"Совет: используйте <code>${output}</code>, чтобы вставить транскрипцию.\",\n        \"updatePrompt\": \"Обновить запрос\",\n        \"deletePrompt\": \"Удалить запрос\",\n        \"createPrompt\": \"Создать запрос\",\n        \"cancel\": \"Отмена\",\n        \"selectToEdit\": \"Выберите запрос выше, чтобы просмотреть и изменить его сведения.\",\n        \"createFirst\": \"Нажмите «Создать новый запрос» выше, чтобы создать первый запрос для постобработки.\"\n      }\n    },\n    \"history\": {\n      \"title\": \"История\",\n      \"openFolder\": \"Открыть папку с записями\",\n      \"loading\": \"Загрузка истории...\",\n      \"empty\": \"Транскрипций пока нет. Начните запись, чтобы создать свою историю!\",\n      \"copyToClipboard\": \"Скопировать транскрипцию в буфер обмена\",\n      \"save\": \"Сохранить транскрипцию\",\n      \"unsave\": \"Удалить из сохраненных\",\n      \"delete\": \"Удалить запись\",\n      \"deleteError\": \"Не удалось удалить запись. Пожалуйста, попробуйте еще раз.\"\n    },\n    \"debug\": {\n      \"title\": \"Отлаживать\",\n      \"logDirectory\": {\n        \"title\": \"Каталог журналов\",\n        \"description\": \"Место хранения файлов журналов\"\n      },\n      \"logLevel\": {\n        \"title\": \"Уровень журнала\",\n        \"description\": \"Установите уровень детализации журнала\"\n      },\n      \"updateChecks\": {\n        \"label\": \"Проверять наличие обновлений\",\n        \"description\": \"Автоматически проверять наличие новых версий Handy\"\n      },\n      \"soundTheme\": {\n        \"label\": \"Звуковая тема\",\n        \"description\": \"Выберите звуковую тему для начала и остановки записи обратной связи.\"\n      },\n      \"wordCorrectionThreshold\": {\n        \"title\": \"Порог исправления слов\",\n        \"description\": \"Чувствительность к пользовательским исправлениям слов\"\n      },\n      \"historyLimit\": {\n        \"title\": \"Размер истории\",\n        \"description\": \"Максимальное количество записей истории, которые можно сохранить\",\n        \"entries\": \"записи\"\n      },\n      \"recordingRetention\": {\n        \"title\": \"Автоматическое удаление записей\",\n        \"description\": \"Автоматически удалять старые записи для экономии места\",\n        \"never\": \"Никогда\",\n        \"preserveLimit\": \"Хранить последние {{count}}\",\n        \"days3\": \"Через 3 дня\",\n        \"weeks2\": \"Через 2 недели\",\n        \"months3\": \"Через 3 месяца\",\n        \"placeholder\": \"Выберите срок хранения...\"\n      },\n      \"alwaysOnMicrophone\": {\n        \"label\": \"Всегда включенный микрофон\",\n        \"description\": \"Держите микрофон активным для более быстрого ответа\"\n      },\n      \"clamshellMicrophone\": {\n        \"title\": \"Раскладной микрофон\",\n        \"description\": \"Микрофон для использования при закрытой крышке ноутбука\"\n      },\n      \"postProcessingToggle\": {\n        \"label\": \"Постобработка\",\n        \"description\": \"Включить уточнение текста с помощью искусственного интеллекта после транскрипции\"\n      },\n      \"muteWhileRecording\": {\n        \"label\": \"Отключить звук во время записи\",\n        \"description\": \"Отключение звука системы во время записи\"\n      },\n      \"appendTrailingSpace\": {\n        \"label\": \"Добавить пробел\",\n        \"description\": \"Добавить пробел после вставленной транскрипции\"\n      },\n      \"keyboardImplementation\": {\n        \"title\": \"Реализация клавиатуры\",\n        \"description\": \"Выберите бэкенд для клавиатурных сочетаний.\",\n        \"bindingsReset\": \"Клавиатурные сочетания были несовместимы и сброшены к значениям по умолчанию\"\n      },\n      \"paths\": {\n        \"appData\": \"Данные приложения:\",\n        \"models\": \"Модели:\",\n        \"settings\": \"Настройки:\"\n      },\n      \"pasteDelay\": {\n        \"title\": \"Задержка вставки\",\n        \"description\": \"Задержка перед отправкой нажатия клавиши вставки (в миллисекундах). Увеличьте, если вставляется неправильный текст.\"\n      },\n      \"recordingBuffer\": {\n        \"title\": \"Дополнительный буфер записи\",\n        \"description\": \"Дополнительное время (в миллисекундах) для продолжения записи после отпускания клавиши, чтобы захватить завершающий звук. 0 = без дополнительного буфера.\"\n      }\n    },\n    \"about\": {\n      \"title\": \"О программе\",\n      \"version\": {\n        \"title\": \"Версия\",\n        \"description\": \"Текущая версия Handy\"\n      },\n      \"appDataDirectory\": {\n        \"title\": \"Каталог данных приложения\",\n        \"description\": \"Место, где Handy хранит свои данные\"\n      },\n      \"sourceCode\": {\n        \"title\": \"Исходный код\",\n        \"description\": \"Просмотреть исходный код и внести свой вклад\",\n        \"button\": \"Посмотреть на GitHub\"\n      },\n      \"supportDevelopment\": {\n        \"title\": \"Поддержка развития\",\n        \"description\": \"Помогите нам продолжать развитие Handy\",\n        \"button\": \"Поддержать\"\n      },\n      \"acknowledgments\": {\n        \"title\": \"Благодарности\",\n        \"whisper\": {\n          \"title\": \"Whisper.cpp\",\n          \"description\": \"Высокопроизводительная модель автоматического распознавания речи OpenAI Whisper\",\n          \"details\": \"Handy использует Whisper.cpp для быстрого локального преобразования речи в текст. Спасибо великолепной работе Георгия Герганова и участников.\"\n        }\n      }\n    }\n  },\n  \"footer\": {\n    \"downloadingModel\": \"Загрузка {{model}}...\",\n    \"checkingUpdates\": \"Проверяем наличие обновлений...\",\n    \"updateAvailable\": \"Доступно обновление: {{version}}\",\n    \"updateAvailableShort\": \"Доступно обновление\",\n    \"upToDate\": \"Не требует обновлений\",\n    \"downloadUpdate\": \"Скачать обновление\",\n    \"restart\": \"Перезапуск\",\n    \"updateCheckingDisabled\": \"Проверка обновлений отключена\",\n    \"downloading\": \"Загрузка... {{progress}} %\",\n    \"installing\": \"Установка...\",\n    \"preparing\": \"Подготовка...\",\n    \"checkForUpdates\": \"Проверить наличие обновлений\"\n  },\n  \"common\": {\n    \"loading\": \"Загрузка...\",\n    \"save\": \"Сохранять\",\n    \"cancel\": \"Отмена\",\n    \"reset\": \"Перезагрузить\",\n    \"add\": \"Добавлять\",\n    \"remove\": \"Удалять\",\n    \"delete\": \"Удалить\",\n    \"edit\": \"Редактировать\",\n    \"create\": \"Создавать\",\n    \"update\": \"Обновлять\",\n    \"close\": \"Закрывать\",\n    \"open\": \"Открыть\",\n    \"default\": \"По умолчанию\",\n    \"enabled\": \"Включено\",\n    \"disabled\": \"Неполноценный\",\n    \"on\": \"На\",\n    \"off\": \"Выключенный\",\n    \"yes\": \"Да\",\n    \"no\": \"Нет\",\n    \"noOptionsFound\": \"Вариантов не найдено\"\n  },\n  \"accessibility\": {\n    \"permissionsRequired\": \"Требуются разрешения на доступ\",\n    \"permissionsDescription\": \"Handy необходимы разрешения на доступ для ввода расшифрованного текста.\",\n    \"openSettings\": \"Открыть настройки системы\",\n    \"dismiss\": \"Увольнять\"\n  },\n  \"errors\": {\n    \"loadDirectory\": \"Ошибка загрузки каталога: {{error}}.\",\n    \"micPermissionDeniedTitle\": \"Доступ к микрофону запрещён\",\n    \"micPermissionDenied\": {\n      \"generic\": \"Доступ к микрофону был запрещён операционной системой. Предоставьте разрешение на использование микрофона в настройках системы.\",\n      \"windows\": \"Включите доступ к микрофону в Параметры → Конфиденциальность и безопасность → Микрофон (включая доступ классических приложений).\",\n      \"macos\": \"Предоставьте доступ к микрофону в Системные настройки → Конфиденциальность и безопасность → Микрофон.\",\n      \"linux\": \"Предоставьте доступ к микрофону в настройках звука или конфиденциальности вашей системы.\"\n    },\n    \"recordingFailed\": \"Не удалось начать запись: {{error}}\",\n    \"modelLoadFailed\": \"Не удалось загрузить модель: {{model}}\",\n    \"modelLoadFailedUnknown\": \"неизвестная модель\"\n  },\n  \"appLanguage\": {\n    \"title\": \"Язык приложения\",\n    \"description\": \"Изменить язык интерфейса Handy\"\n  },\n  \"overlay\": {\n    \"transcribing\": \"Расшифровка...\",\n    \"processing\": \"Обработка...\"\n  }\n}\n"
  },
  {
    "path": "src/i18n/locales/tr/translation.json",
    "content": "{\n  \"tray\": {\n    \"settings\": \"Ayarlar...\",\n    \"checkUpdates\": \"Güncellemeleri Kontrol Et...\",\n    \"copyLastTranscript\": \"Son transkripti kopyala\",\n    \"unloadModel\": \"Modeli boşalt\",\n    \"model\": \"Model\",\n    \"quit\": \"Çıkış\",\n    \"cancel\": \"İptal\"\n  },\n  \"sidebar\": {\n    \"general\": \"Genel\",\n    \"models\": \"Modeller\",\n    \"advanced\": \"Gelişmiş\",\n    \"postProcessing\": \"Son İşlem\",\n    \"history\": \"Geçmiş\",\n    \"debug\": \"Hata Ayıklama\",\n    \"about\": \"Hakkında\"\n  },\n  \"onboarding\": {\n    \"subtitle\": \"Başlamak için bir transkripsiyon modeli seçin\",\n    \"recommended\": \"Önerilen\",\n    \"download\": \"İndir\",\n    \"downloading\": \"İndiriliyor...\",\n    \"customModelDescription\": \"Resmi olarak desteklenmiyor\",\n    \"downloadFailed\": \"İndirme başarısız oldu. Lütfen tekrar deneyin.\",\n    \"modelCard\": {\n      \"accuracy\": \"doğruluk\",\n      \"speed\": \"hız\"\n    },\n    \"models\": {\n      \"small\": {\n        \"name\": \"Whisper Small\",\n        \"description\": \"Hızlı ve oldukça doğru.\"\n      },\n      \"medium\": {\n        \"name\": \"Whisper Medium\",\n        \"description\": \"İyi doğruluk, orta hız.\"\n      },\n      \"turbo\": {\n        \"name\": \"Whisper Turbo\",\n        \"description\": \"Dengeli doğruluk ve hız.\"\n      },\n      \"large\": {\n        \"name\": \"Whisper Large\",\n        \"description\": \"İyi doğruluk, ancak yavaş.\"\n      },\n      \"parakeet-tdt-0.6b-v2\": {\n        \"name\": \"Parakeet V2\",\n        \"description\": \"Yalnızca İngilizce. İngilizce konuşanlar için en iyi model.\"\n      },\n      \"parakeet-tdt-0.6b-v3\": {\n        \"name\": \"Parakeet V3\",\n        \"description\": \"Hızlı ve doğru.\"\n      },\n      \"moonshine-base\": {\n        \"name\": \"Moonshine Base\",\n        \"description\": \"Çok hızlı, yalnızca İngilizce. Aksanları iyi işler.\"\n      },\n      \"moonshine-tiny-streaming-en\": {\n        \"name\": \"Moonshine V2 Tiny\",\n        \"description\": \"Ultra hızlı, yalnızca İngilizce\"\n      },\n      \"moonshine-small-streaming-en\": {\n        \"name\": \"Moonshine V2 Small\",\n        \"description\": \"Hızlı, yalnızca İngilizce. Hız ve doğruluk arasında iyi denge.\"\n      },\n      \"moonshine-medium-streaming-en\": {\n        \"name\": \"Moonshine V2 Medium\",\n        \"description\": \"Yalnızca İngilizce. Yüksek kalite.\"\n      },\n      \"breeze-asr\": {\n        \"name\": \"Breeze ASR\",\n        \"description\": \"Tayvan Mandarin Çincesi için optimize edilmiş. Dil değiştirme desteği.\"\n      },\n      \"sense-voice-int8\": {\n        \"name\": \"SenseVoice\",\n        \"description\": \"Çok hızlı. Çince, İngilizce, Japonca, Korece, Kantonca.\"\n      },\n      \"gigaam-v3-e2e-ctc\": {\n        \"name\": \"GigaAM v3\",\n        \"description\": \"Rusça konuşma tanıma. Hızlı ve doğru.\"\n      },\n      \"canary-180m-flash\": {\n        \"name\": \"Canary 180M Flash\",\n        \"description\": \"Çok hızlı. İngilizce, Almanca, İspanyolca, Fransızca. Çeviri desteği.\"\n      },\n      \"canary-1b-v2\": {\n        \"name\": \"Canary 1B v2\",\n        \"description\": \"Doğru çok dilli. 25 Avrupa dili. Çeviri desteği.\"\n      }\n    },\n    \"errors\": {\n      \"loadModels\": \"Mevcut modeller yüklenemedi\",\n      \"downloadModel\": \"Model indirilemedi: {{error}}\"\n    },\n    \"permissions\": {\n      \"title\": \"İzinler Gerekli\",\n      \"description\": \"Handy'nin düzgün çalışabilmesi için birkaç izne ihtiyacı var.\",\n      \"microphone\": {\n        \"title\": \"Mikrofon Erişimi\",\n        \"description\": \"Sesinizi transkripsiyon için alabilmek için gereklidir.\"\n      },\n      \"accessibility\": {\n        \"title\": \"Erişilebilirlik Erişimi\",\n        \"description\": \"Transkribe edilen metni uygulamalarınıza yazabilmek için gereklidir.\"\n      },\n      \"grant\": \"İzin Ver\",\n      \"granted\": \"Verildi\",\n      \"waiting\": \"Bekleniyor...\",\n      \"allGranted\": \"Her şey hazır!\",\n      \"errors\": {\n        \"checkFailed\": \"İzinler kontrol edilemedi. Lütfen tekrar deneyin.\",\n        \"requestFailed\": \"İzin isteği başarısız oldu. Lütfen tekrar deneyin.\"\n      }\n    }\n  },\n  \"modelSelector\": {\n    \"custom\": \"Özel\",\n    \"active\": \"Aktif\",\n    \"noModelsAvailable\": \"Kullanılabilir model yok\",\n    \"extracting\": \"{{modelName}} çıkarılıyor...\",\n    \"extractingMultiple\": \"{{count}} model çıkarılıyor...\",\n    \"extractingGeneric\": \"Çıkarılıyor...\",\n    \"downloading\": \"%{{percentage}} indiriliyor\",\n    \"downloadingMultiple\": \"{{count}} model indiriliyor...\",\n    \"modelReady\": \"Model Hazır\",\n    \"loading\": \"{{modelName}} yükleniyor...\",\n    \"loadingGeneric\": \"Yükleniyor...\",\n    \"modelError\": \"Model Hatası\",\n    \"modelUnloaded\": \"Model Boşaltıldı\",\n    \"noModelDownloadRequired\": \"Model Yok – İndirme Gerekli\",\n    \"deleteModel\": \"{{modelName}} Sil\",\n    \"switching\": \"Değiştiriliyor...\",\n    \"downloadSpeed\": \"{{speed}} MB/s\",\n    \"capabilities\": {\n      \"languageSelection\": \"Birden fazla giriş dilini destekler\",\n      \"multiLanguage\": \"Çok dilli\",\n      \"translation\": \"İngilizce'ye çevirebilir\",\n      \"translate\": \"İngilizce'ye çevir\",\n      \"singleLanguage\": \"Yalnızca bu dili destekler\",\n      \"languageOnly\": \"Yalnızca {{language}}\"\n    },\n    \"cancel\": \"İptal\",\n    \"cancelDownload\": \"İndirmeyi iptal et\"\n  },\n  \"settings\": {\n    \"modelSettings\": {\n      \"title\": \"{{model}} Ayarları\",\n      \"noSettingsNeeded\": \"Bu model yapılandırma gerektirmeden otomatik olarak çalışır.\"\n    },\n    \"models\": {\n      \"title\": \"Transkripsiyon Modelleri\",\n      \"description\": \"Bir transkripsiyon modeli seçin veya ek modeller indirin. Farklı modeller değişen doğruluk ve hız seviyeleri sunar.\",\n      \"downloaded\": \"İndirildi\",\n      \"available\": \"İndirilebilir\",\n      \"deleteConfirm\": \"{{modelName}} modelini silmek istediğinizden emin misiniz? Kullanmak için tekrar indirmeniz gerekecek.\",\n      \"deleteActiveConfirm\": \"{{modelName}} aktif modelinizdir. Silmek, yeni bir model seçene kadar transkripsiyonları durduracaktır. Emin misiniz?\",\n      \"deleteTitle\": \"Modeli Sil\",\n      \"filters\": {\n        \"all\": \"Tümü\",\n        \"multiLanguage\": \"Çok dilli\",\n        \"translation\": \"Çeviri\",\n        \"allLanguages\": \"Tüm Diller\"\n      },\n      \"noModelsMatch\": \"Bu filtreyle eşleşen model yok.\",\n      \"yourModels\": \"İndirilen modeller\",\n      \"availableModels\": \"İndirilebilir\"\n    },\n    \"general\": {\n      \"title\": \"Genel\",\n      \"shortcut\": {\n        \"title\": \"Handy Kısayolları\",\n        \"description\": \"Sesle yazma kaydını başlatmak için klavye kısayollarını yapılandırın\",\n        \"loading\": \"Kısayollar yükleniyor...\",\n        \"none\": \"Kısayol yapılandırılmadı\",\n        \"notFound\": \"Kısayol bulunamadı\",\n        \"pressKeys\": \"Tuşlara basın...\",\n        \"bindings\": {\n          \"transcribe\": {\n            \"name\": \"Transkripsiyon Kısayolu\",\n            \"description\": \"Sesinizi kaydetmek ve metne dönüştürmek için klavye kısayolu.\"\n          },\n          \"cancel\": {\n            \"name\": \"İptal Kısayolu\",\n            \"description\": \"Mevcut kaydı iptal etmek için klavye kısayolu.\"\n          },\n          \"transcribe_with_post_process\": {\n            \"name\": \"Son İşlem Kısayolu\",\n            \"description\": \"İsteğe bağlı: Transkripsiyonunuza her zaman AI son işleme uygulayan özel bir kısayol tuşu.\"\n          }\n        },\n        \"errors\": {\n          \"restore\": \"Orijinal kısayol geri yüklenemedi\",\n          \"set\": \"Kısayol ayarlanamadı: {{error}}\",\n          \"reset\": \"Kısayol orijinal değerine sıfırlanamadı\"\n        }\n      },\n      \"language\": {\n        \"title\": \"Dil\",\n        \"description\": \"Konuşma tanıma dilini seçin. 'Otomatik' seçeneği dili kendisi belirler, ancak belirli bir dil seçmek o dildeki doğruluğu artırabilir.\",\n        \"descriptionUnsupported\": \"Parakeet modeli dili otomatik algılar. Manuel seçim gerekmez.\",\n        \"searchPlaceholder\": \"Dil ara...\",\n        \"noResults\": \"Dil bulunamadı\",\n        \"auto\": \"Otomatik\"\n      },\n      \"pushToTalk\": {\n        \"label\": \"Bas Konuş\",\n        \"description\": \"Kaydetmek için basılı tutun, durdurmak için bırakın\"\n      }\n    },\n    \"sound\": {\n      \"title\": \"Ses\",\n      \"microphone\": {\n        \"title\": \"Mikrofon\",\n        \"description\": \"Tercih ettiğiniz mikrofon cihazını seçin\",\n        \"placeholder\": \"Mikrofon seçin...\",\n        \"loading\": \"Yükleniyor...\"\n      },\n      \"audioFeedback\": {\n        \"label\": \"Sesli Geri Bildirim\",\n        \"description\": \"Kayıt başladığında ve bittiğinde ses çalar\"\n      },\n      \"outputDevice\": {\n        \"title\": \"Çıkış Cihazı\",\n        \"description\": \"Geri bildirim sesleri için ses çıkış cihazını seçin\",\n        \"placeholder\": \"Çıkış cihazı seçin...\",\n        \"loading\": \"Yükleniyor...\"\n      },\n      \"volume\": {\n        \"title\": \"Ses Seviyesi\",\n        \"description\": \"Sesli geri bildirimlerin ses seviyesini ayarlayın\"\n      }\n    },\n    \"advanced\": {\n      \"title\": \"Gelişmiş\",\n      \"groups\": {\n        \"app\": \"Uygulama\",\n        \"output\": \"Çıktı\",\n        \"transcription\": \"Transkripsiyon\",\n        \"history\": \"Geçmiş\",\n        \"experimental\": \"Deneysel\"\n      },\n      \"experimentalToggle\": {\n        \"label\": \"Deneysel Özellikler\",\n        \"description\": \"Hala geliştirme aşamasında olan deneysel özellikleri etkinleştir.\"\n      },\n      \"lazyStreamClose\": {\n        \"label\": \"Transkripsiyonlar arasında mikrofonu açık tut\",\n        \"description\": \"Kayıt durdurulduktan sonra mikrofon akışını 30 saniye açık tutar, ardışık transkripsiyonlarda gecikmeyi azaltır. Etkinken Bluetooth ses kalitesini düşürebilir.\"\n      },\n      \"acceleration\": {\n        \"whisper\": {\n          \"title\": \"Whisper Hızlandırma\",\n          \"description\": \"Whisper modelleri için donanım hızlandırma. Otomatik mod, mevcutsa GPU kullanır (macOS'ta Metal, Windows/Linux'ta Vulkan).\"\n        },\n        \"ort\": {\n          \"title\": \"ONNX Hızlandırma\",\n          \"description\": \"ONNX modelleri için donanım hızlandırma (Parakeet, Canary, Moonshine vb.). Windows'ta DirectML deneyseldir. Modeller transkripsiyon yapamayabilir.\"\n        }\n      },\n      \"startHidden\": {\n        \"label\": \"Gizli Başlat\",\n        \"description\": \"Pencereyi açmadan sistem tepsisinde başlatır.\"\n      },\n      \"autostart\": {\n        \"label\": \"Başlangıçta Çalıştır\",\n        \"description\": \"Bilgisayara giriş yaptığınızda Handy otomatik olarak başlatılır.\"\n      },\n      \"showTrayIcon\": {\n        \"label\": \"Tepsi simgesini göster\",\n        \"description\": \"Handy simgesini sistem tepsisinde göster.\"\n      },\n      \"overlay\": {\n        \"title\": \"Overlay Konumu\",\n        \"description\": \"Kayıt ve transkripsiyon sırasında görsel geri bildirim kaplamasını gösterir. Linux'ta 'Yok' önerilir.\",\n        \"options\": {\n          \"none\": \"Yok\",\n          \"bottom\": \"Alt\",\n          \"top\": \"Üst\"\n        }\n      },\n      \"pasteMethod\": {\n        \"title\": \"Yapıştırma Yöntemi\",\n        \"description\": \"Metnin nasıl ekleneceğini seçin. Doğrudan: sistem girişiyle yazmayı simüle eder. Yok: yapıştırmayı atlar, sadece geçmişi/panoyu günceller.\",\n        \"options\": {\n          \"clipboard\": \"Pano ({{modifier}}+V)\",\n          \"clipboardCtrlShiftV\": \"Pano (Ctrl+Shift+V)\",\n          \"clipboardShiftInsert\": \"Pano (Shift+Insert)\",\n          \"direct\": \"Doğrudan\",\n          \"none\": \"Yok\",\n          \"externalScript\": \"Harici Betik\"\n        },\n        \"externalScriptPlaceholder\": \"/dosya/yolu/betik.sh\"\n      },\n      \"typingTool\": {\n        \"title\": \"Yazma Aracı\",\n        \"description\": \"Doğrudan yapıştırma yöntemi için hangi Linux yazma aracının kullanılacağını seçin. Auto, sisteminiz için mevcut en iyi aracı otomatik olarak algılar ve kullanır.\",\n        \"options\": {\n          \"auto\": \"Auto (Önerilen)\"\n        }\n      },\n      \"clipboardHandling\": {\n        \"title\": \"Pano Yönetimi\",\n        \"description\": \"Panoyu Değiştirme, transkripsiyon sonrası mevcut pano içeriğini korur. Panoya Kopyala ise yapıştırma işleminden sonra transkripsiyon sonucunu panoda bırakır.\",\n        \"options\": {\n          \"dontModify\": \"Panoyu Değiştirme\",\n          \"copyToClipboard\": \"Panoya Kopyala\"\n        }\n      },\n      \"autoSubmit\": {\n        \"title\": \"Otomatik Gönder\",\n        \"description\": \"Metin eklendikten sonra seçilen tuş kombinasyonunu otomatik olarak gönderir. macOS'ta Cmd+Enter, Windows/Linux'ta Super+Enter geçerlidir.\",\n        \"options\": {\n          \"off\": \"Kapalı\",\n          \"enter\": \"Enter\",\n          \"cmdEnter\": \"Cmd+Enter\",\n          \"superEnter\": \"Super+Enter\",\n          \"ctrlEnter\": \"Ctrl+Enter\"\n        }\n      },\n      \"translateToEnglish\": {\n        \"label\": \"İngilizceye Çevir\",\n        \"description\": \"Transkripsiyon sırasında diğer dillerden İngilizceye otomatik olarak çevirir.\",\n        \"descriptionUnsupported\": \"Çeviri {{model}} modeli tarafından desteklenmiyor.\"\n      },\n      \"modelUnload\": {\n        \"title\": \"Modeli Boşalt\",\n        \"description\": \"Belirtilen süre boyunca kullanılmadığında modelin GPU/CPU belleğini otomatik olarak serbest bırakır.\",\n        \"options\": {\n          \"never\": \"Asla\",\n          \"immediately\": \"Hemen\",\n          \"min2\": \"2 dakika sonra\",\n          \"min5\": \"5 dakika sonra\",\n          \"min10\": \"10 dakika sonra\",\n          \"min15\": \"15 dakika sonra\",\n          \"hour1\": \"1 saat sonra\",\n          \"sec15\": \"15 saniye sonra (Debug)\"\n        }\n      },\n      \"customWords\": {\n        \"title\": \"Özel Kelimeler\",\n        \"description\": \"Transkripsiyon sırasında sıkça yanlış duyulan veya yanlış yazılan kelimeleri ekleyin. Sistem, benzer sesli kelimeleri listenize göre otomatik olarak düzeltir.\",\n        \"placeholder\": \"Kelime ekle\",\n        \"add\": \"Ekle\",\n        \"remove\": \"{{word}} Kaldır\",\n        \"duplicate\": \"\\\"{{word}}\\\" zaten mevcut\"\n      }\n    },\n    \"postProcessing\": {\n      \"title\": \"Son İşlem\",\n      \"hotkey\": {\n        \"title\": \"Kısayol Tuşu\"\n      },\n      \"api\": {\n        \"title\": \"API (OpenAI Uyumlu)\",\n        \"provider\": {\n          \"title\": \"Sağlayıcı\",\n          \"description\": \"OpenAI uyumlu bir sağlayıcı seçin.\"\n        },\n        \"appleIntelligence\": {\n          \"title\": \"Apple Intelligence\",\n          \"description\": \"Tamamen cihaz üzerinde çalışır. API anahtarı veya ağ erişimi gerekmez.\",\n          \"requirements\": \"macOS Tahoe (26.0) veya üzeri çalıştıran bir Apple Silicon Mac gerektirir. Sistem Ayarları'nda Apple Intelligence etkin olmalıdır.\",\n          \"unavailable\": \"Apple Intelligence bu cihazda kullanılamıyor. macOS Tahoe (26.0) veya üzeri ve Sistem Ayarları'nda Apple Intelligence etkin olan bir Apple Silicon Mac gerektirir.\"\n        },\n        \"baseUrl\": {\n          \"title\": \"Temel URL\",\n          \"description\": \"Seçili sağlayıcı için API temel URL'si. Yalnızca özel sağlayıcı düzenlenebilir.\",\n          \"placeholder\": \"https://api.openai.com/v1\"\n        },\n        \"apiKey\": {\n          \"title\": \"API Anahtarı\",\n          \"description\": \"Seçili sağlayıcı için API anahtarı.\",\n          \"placeholder\": \"sk-...\"\n        },\n        \"model\": {\n          \"title\": \"Model\",\n          \"descriptionApple\": \"İsteğe bağlı bir sayısal token sınırı belirleyin veya varsayılan cihaz içi ön ayarı kullanın.\",\n          \"descriptionCustom\": \"Özel uç noktanızın beklediği model tanımlayıcısını girin.\",\n          \"descriptionDefault\": \"Seçili sağlayıcının sunduğu bir modeli seçin.\",\n          \"placeholderApple\": \"Apple Intelligence\",\n          \"placeholderWithOptions\": \"Model ara veya seç\",\n          \"placeholderNoOptions\": \"Model adı yazın\",\n          \"refreshModels\": \"Modelleri Yenile\"\n        }\n      },\n      \"prompts\": {\n        \"title\": \"Prompt\",\n        \"selectedPrompt\": {\n          \"title\": \"Seçili Prompt\",\n          \"description\": \"Transkripsiyonları iyileştirmek için bir şablon seçin veya yeni bir tane oluşturun. Yakalanan transkripte referans vermek için prompt metni içinde ${output} kullanın.\"\n        },\n        \"noPrompts\": \"Kullanılabilir prompt yok\",\n        \"selectPrompt\": \"Prompt Seç\",\n        \"createNew\": \"Yeni Prompt Oluştur\",\n        \"promptLabel\": \"Prompt Etiketi\",\n        \"promptLabelPlaceholder\": \"Prompt adı girin\",\n        \"promptInstructions\": \"Prompt Talimatları\",\n        \"promptInstructionsPlaceholder\": \"Transkripsiyondan sonra çalıştırılacak talimatları yazın. Örnek: Aşağıdaki metnin dilbilgisini ve açıklığını iyileştir: ${output}\",\n        \"promptTip\": \"İpucu: Transkribe edilen metni promptunuza eklemek için <code>${output}</code> kullanın.\",\n        \"updatePrompt\": \"Promptu Güncelle\",\n        \"deletePrompt\": \"Promptu Sil\",\n        \"createPrompt\": \"Prompt Oluştur\",\n        \"cancel\": \"İptal\",\n        \"selectToEdit\": \"Ayrıntılarını görüntülemek ve düzenlemek için yukarıdan bir prompt seçin.\",\n        \"createFirst\": \"İlk son işlem prompt'unuzu oluşturmak için yukarıdaki “Yeni Prompt Oluştur” seçeneğine tıklayın.\"\n      }\n    },\n    \"history\": {\n      \"title\": \"Geçmiş\",\n      \"openFolder\": \"Kayıtlar Klasörünü Aç\",\n      \"loading\": \"Geçmiş yükleniyor...\",\n      \"empty\": \"Henüz transkripsiyon yok. Geçmişinizi oluşturmak için kayda başlayın!\",\n      \"copyToClipboard\": \"Transkripsiyonu panoya kopyala\",\n      \"save\": \"Transkripsiyonu kaydet\",\n      \"unsave\": \"Kaydedilenlerden kaldır\",\n      \"delete\": \"Kaydı sil\",\n      \"deleteError\": \"Kayıt silinemedi. Lütfen tekrar deneyin.\"\n    },\n    \"debug\": {\n      \"title\": \"Hata Ayıklama\",\n      \"logDirectory\": {\n        \"title\": \"Log Dizini\",\n        \"description\": \"Log dosyalarının saklandığı konum\"\n      },\n      \"logLevel\": {\n        \"title\": \"Log Seviyesi\",\n        \"description\": \"Loglamanın ayrıntı düzeyini ayarla\"\n      },\n      \"updateChecks\": {\n        \"label\": \"Güncellemeleri Kontrol Et\",\n        \"description\": \"Handy için yeni sürümleri otomatik olarak kontrol eder\"\n      },\n      \"soundTheme\": {\n        \"label\": \"Ses Teması\",\n        \"description\": \"Kayıt başlangıç ve bitişi için sesli geri bildirim temasını seçin\"\n      },\n      \"wordCorrectionThreshold\": {\n        \"title\": \"Kelime Düzeltme Eşiği\",\n        \"description\": \"Özel kelime düzeltmeleri için hassasiyet\"\n      },\n      \"historyLimit\": {\n        \"title\": \"Geçmiş Limiti\",\n        \"description\": \"Saklanacak maksimum geçmiş kaydı sayısı\",\n        \"entries\": \"kayıt\"\n      },\n      \"recordingRetention\": {\n        \"title\": \"Kayıtları Otomatik Sil\",\n        \"description\": \"Alan tasarrufu için eski kayıtları otomatik olarak siler\",\n        \"never\": \"Asla\",\n        \"preserveLimit\": \"Son {{count}} kaydı tut\",\n        \"days3\": \"3 gün sonra\",\n        \"weeks2\": \"2 hafta sonra\",\n        \"months3\": \"3 ay sonra\",\n        \"placeholder\": \"Saklama süresi seçin...\"\n      },\n      \"alwaysOnMicrophone\": {\n        \"label\": \"Mikrofon Her Zaman Açık\",\n        \"description\": \"Daha hızlı yanıt için mikrofonu aktif tutar\"\n      },\n      \"clamshellMicrophone\": {\n        \"title\": \"Kapalı Kapak Mikrofonu\",\n        \"description\": \"Dizüstü bilgisayar kapağı kapalıyken kullanılacak mikrofon\"\n      },\n      \"postProcessingToggle\": {\n        \"label\": \"Son İşlem\",\n        \"description\": \"Transkripsiyon sonrası yapay zekâ destekli metin iyileştirmeyi etkinleştirir\"\n      },\n      \"muteWhileRecording\": {\n        \"label\": \"Kayıt Sırasında Sessize Al\",\n        \"description\": \"Kayıt sırasında sistem sesini kapatır\"\n      },\n      \"appendTrailingSpace\": {\n        \"label\": \"Sonuna Boşluk Ekle\",\n        \"description\": \"Yapıştırılan transkripsiyondan sonra boşluk ekler\"\n      },\n      \"keyboardImplementation\": {\n        \"title\": \"Klavye Uygulaması\",\n        \"description\": \"Klavye kısayolları için kullanılacak altyapıyı seçin.\",\n        \"bindingsReset\": \"Klavye kısayolları uyumsuzdu ve varsayılan ayarlara sıfırlandı\"\n      },\n      \"paths\": {\n        \"appData\": \"Uygulama Verileri:\",\n        \"models\": \"Modeller:\",\n        \"settings\": \"Ayarlar:\"\n      },\n      \"pasteDelay\": {\n        \"title\": \"Yapıştırma gecikmesi\",\n        \"description\": \"Yapıştırma tuşu göndermeden önce gecikme (milisaniye cinsinden). Yanlış metin yapıştırılıyorsa artırın.\"\n      },\n      \"recordingBuffer\": {\n        \"title\": \"Ekstra kayıt tamponu\",\n        \"description\": \"Tuşu bıraktıktan sonra arka plandaki sesi yakalamak için kaydı sürdürme süresi (milisaniye). 0 = ekstra tampon yok.\"\n      }\n    },\n    \"about\": {\n      \"title\": \"Hakkında\",\n      \"version\": {\n        \"title\": \"Sürüm\",\n        \"description\": \"Handy'nin mevcut sürümü\"\n      },\n      \"appDataDirectory\": {\n        \"title\": \"Uygulama Veri Dizini\",\n        \"description\": \"Handy'nin verilerini sakladığı konum\"\n      },\n      \"sourceCode\": {\n        \"title\": \"Kaynak Kodu\",\n        \"description\": \"Kaynak kodunu görüntüle ve katkıda bulun\",\n        \"button\": \"GitHub'da Görüntüle\"\n      },\n      \"supportDevelopment\": {\n        \"title\": \"Geliştirmeyi Destekle\",\n        \"description\": \"Handy'yi geliştirmeye devam etmemize yardımcı olun\",\n        \"button\": \"Bağış Yap\"\n      },\n      \"acknowledgments\": {\n        \"title\": \"Teşekkürler\",\n        \"whisper\": {\n          \"title\": \"Whisper.cpp\",\n          \"description\": \"OpenAI'nin Whisper otomatik konuşma tanıma modelinin yüksek performanslı çıkarımı\",\n          \"details\": \"Handy, hızlı ve yerel konuşmadan metne dönüştürme için Whisper.cpp kullanır. Georgi Gerganov ve katkıda bulunanların harika çalışmaları için teşekkür ederiz.\"\n        }\n      }\n    }\n  },\n  \"footer\": {\n    \"downloadingModel\": \"{{model}} indiriliyor...\",\n    \"checkingUpdates\": \"Güncellemeler kontrol ediliyor...\",\n    \"updateAvailable\": \"Güncelleme mevcut: {{version}}\",\n    \"updateAvailableShort\": \"Güncelleme mevcut\",\n    \"upToDate\": \"Güncel\",\n    \"downloadUpdate\": \"Güncellemeyi İndir\",\n    \"restart\": \"Yeniden Başlat\",\n    \"updateCheckingDisabled\": \"Güncelleme Kontrolü Devre Dışı\",\n    \"downloading\": \"İndiriliyor... %{{progress}}\",\n    \"installing\": \"Yükleniyor...\",\n    \"preparing\": \"Hazırlanıyor...\",\n    \"checkForUpdates\": \"Güncellemeleri kontrol et\"\n  },\n  \"common\": {\n    \"loading\": \"Yükleniyor...\",\n    \"save\": \"Kaydet\",\n    \"cancel\": \"İptal\",\n    \"reset\": \"Sıfırla\",\n    \"add\": \"Ekle\",\n    \"remove\": \"Kaldır\",\n    \"delete\": \"Sil\",\n    \"edit\": \"Düzenle\",\n    \"create\": \"Oluştur\",\n    \"update\": \"Güncelle\",\n    \"close\": \"Kapat\",\n    \"open\": \"Aç\",\n    \"default\": \"Varsayılan\",\n    \"enabled\": \"Etkin\",\n    \"disabled\": \"Devre Dışı\",\n    \"on\": \"Açık\",\n    \"off\": \"Kapalı\",\n    \"yes\": \"Evet\",\n    \"no\": \"Hayır\",\n    \"noOptionsFound\": \"Seçenek bulunamadı\"\n  },\n  \"accessibility\": {\n    \"permissionsRequired\": \"Erişilebilirlik İzinleri Gerekli\",\n    \"permissionsDescription\": \"Handy, transkribe edilen metni yazabilmek için erişilebilirlik izinlerine ihtiyaç duyar.\",\n    \"openSettings\": \"Sistem Ayarlarını Aç\",\n    \"dismiss\": \"Yoksay\"\n  },\n  \"errors\": {\n    \"loadDirectory\": \"Dizin yüklenirken hata oluştu: {{error}}\",\n    \"micPermissionDeniedTitle\": \"Mikrofon erişimi reddedildi\",\n    \"micPermissionDenied\": {\n      \"generic\": \"Mikrofon erişimi işletim sistemi tarafından reddedildi. Lütfen sistem ayarlarından mikrofon iznini verin.\",\n      \"windows\": \"Ayarlar → Gizlilik ve güvenlik → Mikrofon (masaüstü uygulama erişimi dahil) bölümünden mikrofon erişimini etkinleştirin.\",\n      \"macos\": \"Sistem Ayarları → Gizlilik ve Güvenlik → Mikrofon bölümünden mikrofon erişimini verin.\",\n      \"linux\": \"Sisteminizin ses veya gizlilik ayarlarından mikrofon erişimini verin.\"\n    },\n    \"recordingFailed\": \"Kayıt başlatılamadı: {{error}}\",\n    \"modelLoadFailed\": \"Model yüklenemedi: {{model}}\",\n    \"modelLoadFailedUnknown\": \"bilinmeyen model\"\n  },\n  \"appLanguage\": {\n    \"title\": \"Uygulama Dili\",\n    \"description\": \"Handy arayüzünün dilini değiştirin\"\n  },\n  \"overlay\": {\n    \"transcribing\": \"Transkribe ediliyor...\",\n    \"processing\": \"İşleniyor...\"\n  }\n}\n"
  },
  {
    "path": "src/i18n/locales/uk/translation.json",
    "content": "{\n  \"tray\": {\n    \"settings\": \"Налаштування...\",\n    \"checkUpdates\": \"Перевірити оновлення...\",\n    \"copyLastTranscript\": \"Скопіювати останню транскрипцію\",\n    \"unloadModel\": \"Вивантажити модель\",\n    \"model\": \"Модель\",\n    \"quit\": \"Вийти\",\n    \"cancel\": \"Скасувати\"\n  },\n  \"sidebar\": {\n    \"general\": \"Загальні\",\n    \"models\": \"Моделі\",\n    \"advanced\": \"Розширені\",\n    \"postProcessing\": \"Постобробка\",\n    \"history\": \"Історія\",\n    \"debug\": \"Дебаг\",\n    \"about\": \"Інфо\"\n  },\n  \"onboarding\": {\n    \"subtitle\": \"Для початку оберіть модель транскрипції\",\n    \"recommended\": \"Рекомендовано\",\n    \"download\": \"Завантажити\",\n    \"downloading\": \"Завантаження...\",\n    \"customModelDescription\": \"Офіційно не підтримується\",\n    \"downloadFailed\": \"Завантаження не вдалося. Будь ласка, спробуйте ще раз.\",\n    \"modelCard\": {\n      \"accuracy\": \"точність\",\n      \"speed\": \"швидкість\"\n    },\n    \"models\": {\n      \"small\": {\n        \"name\": \"Whisper Small\",\n        \"description\": \"Швидка та досить точна\"\n      },\n      \"medium\": {\n        \"name\": \"Whisper Medium\",\n        \"description\": \"Хороша точність, середня швидкість\"\n      },\n      \"turbo\": {\n        \"name\": \"Whisper Turbo\",\n        \"description\": \"Збалансована точність та швидкість\"\n      },\n      \"large\": {\n        \"name\": \"Whisper Large\",\n        \"description\": \"Хороша точність, але повільна\"\n      },\n      \"parakeet-tdt-0.6b-v2\": {\n        \"name\": \"Parakeet V2\",\n        \"description\": \"Лише англійська. Найкраща модель для англомовних\"\n      },\n      \"parakeet-tdt-0.6b-v3\": {\n        \"name\": \"Parakeet V3\",\n        \"description\": \"Швидка та точна\"\n      },\n      \"moonshine-base\": {\n        \"name\": \"Moonshine Base\",\n        \"description\": \"Дуже швидкий, лише англійська. Добре справляється з акцентами.\"\n      },\n      \"moonshine-tiny-streaming-en\": {\n        \"name\": \"Moonshine V2 Tiny\",\n        \"description\": \"Надшвидкий, лише англійська\"\n      },\n      \"moonshine-small-streaming-en\": {\n        \"name\": \"Moonshine V2 Small\",\n        \"description\": \"Швидкий, лише англійська. Добрий баланс швидкості та точності.\"\n      },\n      \"moonshine-medium-streaming-en\": {\n        \"name\": \"Moonshine V2 Medium\",\n        \"description\": \"Лише англійська. Висока якість.\"\n      },\n      \"breeze-asr\": {\n        \"name\": \"Breeze ASR\",\n        \"description\": \"Оптимізовано для тайванської мандаринської. Підтримка перемикання мов.\"\n      },\n      \"sense-voice-int8\": {\n        \"name\": \"SenseVoice\",\n        \"description\": \"Дуже швидкий. Китайська, англійська, японська, корейська, кантонська.\"\n      },\n      \"gigaam-v3-e2e-ctc\": {\n        \"name\": \"GigaAM v3\",\n        \"description\": \"Розпізнавання російського мовлення. Швидко і точно.\"\n      },\n      \"canary-180m-flash\": {\n        \"name\": \"Canary 180M Flash\",\n        \"description\": \"Дуже швидка. Англійська, німецька, іспанська, французька. Підтримує переклад.\"\n      },\n      \"canary-1b-v2\": {\n        \"name\": \"Canary 1B v2\",\n        \"description\": \"Точна багатомовна. 25 європейських мов. Підтримує переклад.\"\n      }\n    },\n    \"errors\": {\n      \"loadModels\": \"Не вдалося завантажити доступні моделі\",\n      \"downloadModel\": \"Не вдалося завантажити модель: {{error}}\"\n    },\n    \"permissions\": {\n      \"title\": \"Потрібні дозволи\",\n      \"description\": \"Handy потребує деяких дозволів для коректної роботи.\",\n      \"microphone\": {\n        \"title\": \"Доступ до мікрофона\",\n        \"description\": \"Потрібен для прослуховування вашого голосу для транскрипції.\"\n      },\n      \"accessibility\": {\n        \"title\": \"Доступ до доступності\",\n        \"description\": \"Потрібен для введення транскрибованого тексту у ваші додатки.\"\n      },\n      \"grant\": \"Надати дозвіл\",\n      \"granted\": \"Надано\",\n      \"waiting\": \"Очікування...\",\n      \"allGranted\": \"Все готово!\",\n      \"errors\": {\n        \"checkFailed\": \"Не вдалося перевірити дозволи. Будь ласка, спробуйте ще раз.\",\n        \"requestFailed\": \"Не вдалося запросити дозвіл. Будь ласка, спробуйте ще раз.\"\n      }\n    }\n  },\n  \"modelSelector\": {\n    \"custom\": \"Користувацька\",\n    \"active\": \"Активна\",\n    \"noModelsAvailable\": \"Немає доступних моделей\",\n    \"extracting\": \"Розпакування {{modelName}}...\",\n    \"extractingMultiple\": \"Розпакування {{count}} моделей...\",\n    \"extractingGeneric\": \"Розпакування...\",\n    \"downloading\": \"Завантаження {{percentage}}%\",\n    \"downloadingMultiple\": \"Завантаження {{count}} моделей...\",\n    \"modelReady\": \"Модель готова\",\n    \"loading\": \"Завантаження {{modelName}}...\",\n    \"loadingGeneric\": \"Завантаження...\",\n    \"modelError\": \"Помилка моделі\",\n    \"modelUnloaded\": \"Модель вивантажена\",\n    \"noModelDownloadRequired\": \"Немає моделі - потрібно завантажити\",\n    \"deleteModel\": \"Видалити {{modelName}}\",\n    \"switching\": \"Перемикання...\",\n    \"downloadSpeed\": \"{{speed}} MB/s\",\n    \"capabilities\": {\n      \"languageSelection\": \"Підтримує кілька мов введення\",\n      \"multiLanguage\": \"Багатомовна\",\n      \"translation\": \"Може перекладати на англійську\",\n      \"translate\": \"Перекласти на англійську\",\n      \"singleLanguage\": \"Підтримує лише цю мову\",\n      \"languageOnly\": \"Лише {{language}}\"\n    },\n    \"cancel\": \"Скасувати\",\n    \"cancelDownload\": \"Скасувати завантаження\"\n  },\n  \"settings\": {\n    \"modelSettings\": {\n      \"title\": \"Налаштування {{model}}\",\n      \"noSettingsNeeded\": \"Ця модель працює автоматично без потреби в налаштуванні.\"\n    },\n    \"general\": {\n      \"title\": \"Загальні\",\n      \"shortcut\": {\n        \"title\": \"Комбінації клавіш\",\n        \"description\": \"Налаштуйте клавіатурні скорочення для запуску запису мовлення в текст\",\n        \"loading\": \"Завантаження скорочень...\",\n        \"none\": \"Скорочення не налаштовані\",\n        \"notFound\": \"Скорочення не знайдено\",\n        \"pressKeys\": \"Натисніть клавіші...\",\n        \"bindings\": {\n          \"transcribe\": {\n            \"name\": \"Гаряча клавіша транскрипції\",\n            \"description\": \"Комбінація клавіш для запису та транскрибування вашого голосу.\"\n          },\n          \"cancel\": {\n            \"name\": \"Гаряча клавіша скасування\",\n            \"description\": \"Комбінація клавіш для скасування поточного запису.\"\n          },\n          \"transcribe_with_post_process\": {\n            \"name\": \"Гаряча клавіша постобробки\",\n            \"description\": \"Необов'язково: Спеціальна гаряча клавіша, яка завжди застосовує AI-постобробку до вашої транскрипції.\"\n          }\n        },\n        \"errors\": {\n          \"restore\": \"Не вдалося відновити початкове скорочення\",\n          \"set\": \"Не вдалося встановити скорочення: {{error}}\",\n          \"reset\": \"Не вдалося скинути скорочення до початкового значення\"\n        }\n      },\n      \"language\": {\n        \"title\": \"Мова\",\n        \"description\": \"Оберіть мову для розпізнавання мовлення. Режим «Авто» визначить мову автоматично, а вибір конкретної мови може покращити точність.\",\n        \"descriptionUnsupported\": \"Модель Parakeet автоматично визначає мову. Ручний вибір не потрібен.\",\n        \"searchPlaceholder\": \"Пошук мов...\",\n        \"noResults\": \"Мов не знайдено\",\n        \"auto\": \"Авто\"\n      },\n      \"pushToTalk\": {\n        \"label\": \"Утримувати для запису (Push To Talk)\",\n        \"description\": \"Утримуйте для запису, відпустіть для зупинки\"\n      }\n    },\n    \"models\": {\n      \"title\": \"Моделі транскрипції\",\n      \"description\": \"Виберіть модель транскрипції або завантажте додаткові моделі. Різні моделі пропонують різні рівні точності та швидкості.\",\n      \"downloaded\": \"Завантажено\",\n      \"available\": \"Доступні для завантаження\",\n      \"deleteConfirm\": \"Ви впевнені, що хочете видалити {{modelName}}? Вам потрібно буде завантажити її знову, щоб використовувати.\",\n      \"deleteActiveConfirm\": \"{{modelName}} — ваша активна модель. Видалення зупинить транскрипцію, доки ви не оберете нову модель. Ви впевнені?\",\n      \"deleteTitle\": \"Видалити модель\",\n      \"filters\": {\n        \"all\": \"Усі\",\n        \"multiLanguage\": \"Багатомовні\",\n        \"translation\": \"Переклад\",\n        \"allLanguages\": \"Усі мови\"\n      },\n      \"noModelsMatch\": \"Жодна модель не відповідає цьому фільтру.\",\n      \"yourModels\": \"Завантажені моделі\",\n      \"availableModels\": \"Доступні для завантаження\"\n    },\n    \"sound\": {\n      \"title\": \"Звук\",\n      \"microphone\": {\n        \"title\": \"Мікрофон\",\n        \"description\": \"Оберіть бажаний мікрофон\",\n        \"placeholder\": \"Оберіть мікрофон...\",\n        \"loading\": \"Завантаження...\"\n      },\n      \"audioFeedback\": {\n        \"label\": \"Звукове сповіщення\",\n        \"description\": \"Відтворювати звук при початку та зупинці запису\"\n      },\n      \"outputDevice\": {\n        \"title\": \"Пристрій виводу\",\n        \"description\": \"Оберіть бажаний пристрій виводу звуку для звукових сповіщень\",\n        \"placeholder\": \"Оберіть пристрій виводу...\",\n        \"loading\": \"Завантаження...\"\n      },\n      \"volume\": {\n        \"title\": \"Гучність\",\n        \"description\": \"Налаштуйте гучність звукових сповіщень\"\n      }\n    },\n    \"advanced\": {\n      \"title\": \"Розширені\",\n      \"groups\": {\n        \"app\": \"Додаток\",\n        \"output\": \"Вивід\",\n        \"transcription\": \"Транскрипція\",\n        \"history\": \"Історія\",\n        \"experimental\": \"Експериментальне\"\n      },\n      \"experimentalToggle\": {\n        \"label\": \"Експериментальні функції\",\n        \"description\": \"Увімкнути експериментальні функції, які ще в розробці.\"\n      },\n      \"lazyStreamClose\": {\n        \"label\": \"Залишати мікрофон увімкненим між транскрипціями\",\n        \"description\": \"Залишає потік мікрофона відкритим протягом 30 секунд після зупинки запису, зменшуючи затримку при послідовних транскрипціях. Може погіршити якість звуку Bluetooth.\"\n      },\n      \"acceleration\": {\n        \"whisper\": {\n          \"title\": \"Прискорення Whisper\",\n          \"description\": \"Апаратне прискорення для моделей Whisper. Автоматичний режим використовує GPU за наявності (Metal на macOS, Vulkan на Windows/Linux).\"\n        },\n        \"ort\": {\n          \"title\": \"Прискорення ONNX\",\n          \"description\": \"Апаратне прискорення для моделей ONNX (Parakeet, Canary, Moonshine тощо). DirectML на Windows є експериментальним. Моделі можуть не виконувати транскрипцію.\"\n        }\n      },\n      \"startHidden\": {\n        \"label\": \"Запуск у фоні\",\n        \"description\": \"Запускати в системному треї без відкриття вікна\"\n      },\n      \"autostart\": {\n        \"label\": \"Запуск при старті системи\",\n        \"description\": \"Автоматично запускати Handy при вході в систему\"\n      },\n      \"showTrayIcon\": {\n        \"label\": \"Показати значок у треї\",\n        \"description\": \"Відображати значок Handy у системному треї.\"\n      },\n      \"overlay\": {\n        \"title\": \"Позиція оверлею\",\n        \"description\": \"Показувати візуальний оверлей під час запису та транскрипції. На Linux рекомендовано «Немає»\",\n        \"options\": {\n          \"none\": \"Немає\",\n          \"bottom\": \"Внизу\",\n          \"top\": \"Вгорі\"\n        }\n      },\n      \"pasteMethod\": {\n        \"title\": \"Метод вставки\",\n        \"description\": \"Оберіть спосіб вставки тексту. Прямий: емулює набір тексту. Немає: пропускає вставку, оновлює лише історію/буфер обміну.\",\n        \"options\": {\n          \"clipboard\": \"Буфер обміну ({{modifier}}+V)\",\n          \"clipboardCtrlShiftV\": \"Буфер обміну (Ctrl+Shift+V)\",\n          \"clipboardShiftInsert\": \"Буфер обміну (Shift+Insert)\",\n          \"direct\": \"Прямий\",\n          \"none\": \"Немає\",\n          \"externalScript\": \"Зовнішній скрипт\"\n        },\n        \"externalScriptPlaceholder\": \"/path/to/your/script.sh\"\n      },\n      \"typingTool\": {\n        \"title\": \"Інструмент введення\",\n        \"description\": \"Виберіть, який інструмент введення в Linux використовувати для методу прямого вставлення. Auto автоматично визначить і використає найкращий доступний інструмент для вашої системи.\",\n        \"options\": {\n          \"auto\": \"Auto (Рекомендовано)\"\n        }\n      },\n      \"clipboardHandling\": {\n        \"title\": \"Робота з буфером обміну\",\n        \"description\": \"Не змінювати буфер обміну зберігає поточний вміст буфера після транскрипції. Копіювати в буфер обміну залишає результат транскрипції в буфері після вставки.\",\n        \"options\": {\n          \"dontModify\": \"Не змінювати буфер обміну\",\n          \"copyToClipboard\": \"Копіювати в буфер обміну\"\n        }\n      },\n      \"autoSubmit\": {\n        \"title\": \"Автоматичне надсилання\",\n        \"description\": \"Автоматично надсилає вибрану комбінацію клавіш після вставки тексту. Cmd+Enter застосовується на macOS, тоді як Windows/Linux використовують Super+Enter.\",\n        \"options\": {\n          \"off\": \"Вимк.\",\n          \"enter\": \"Enter\",\n          \"cmdEnter\": \"Cmd+Enter\",\n          \"superEnter\": \"Super+Enter\",\n          \"ctrlEnter\": \"Ctrl+Enter\"\n        }\n      },\n      \"translateToEnglish\": {\n        \"label\": \"Перекласти на англійську\",\n        \"description\": \"Автоматично перекладати мовлення з інших мов англійською під час транскрипції.\",\n        \"descriptionUnsupported\": \"Переклад не підтримується моделлю {{model}}.\"\n      },\n      \"modelUnload\": {\n        \"title\": \"Вивантаження моделі\",\n        \"description\": \"Автоматично звільняти пам'ять GPU/CPU, коли модель не використовується протягом вказаного часу\",\n        \"options\": {\n          \"never\": \"Ніколи\",\n          \"immediately\": \"Негайно\",\n          \"min2\": \"Через 2 хвилини\",\n          \"min5\": \"Через 5 хвилин\",\n          \"min10\": \"Через 10 хвилин\",\n          \"min15\": \"Через 15 хвилин\",\n          \"hour1\": \"Через 1 годину\",\n          \"sec15\": \"Через 15 секунд (Дебаг)\"\n        }\n      },\n      \"customWords\": {\n        \"title\": \"Власні слова\",\n        \"description\": \"Додайте слова, які часто неправильно розпізнаються під час транскрипції. Система автоматично виправлятиме схожі за звучанням слова відповідно до вашого списку.\",\n        \"placeholder\": \"Додати слово\",\n        \"add\": \"Додати\",\n        \"remove\": \"Видалити {{word}}\",\n        \"duplicate\": \"\\\"{{word}}\\\" вже існує\"\n      }\n    },\n    \"postProcessing\": {\n      \"title\": \"Постобробка\",\n      \"hotkey\": {\n        \"title\": \"Гаряча клавіша\"\n      },\n      \"api\": {\n        \"title\": \"API (сумісний з OpenAI)\",\n        \"provider\": {\n          \"title\": \"Провайдер\",\n          \"description\": \"Оберіть OpenAI-сумісного провайдера\"\n        },\n        \"appleIntelligence\": {\n          \"title\": \"Apple Intelligence\",\n          \"description\": \"Працює повністю на пристрої. API-ключ або доступ до мережі не потрібні.\",\n          \"requirements\": \"Потрібен Mac з Apple Silicon під управлінням macOS Tahoe (26.0) або новіше. Apple Intelligence має бути увімкнений у Системних налаштуваннях.\",\n          \"unavailable\": \"Apple Intelligence недоступний на цьому пристрої. Потрібен Mac з Apple Silicon під управлінням macOS Tahoe (26.0) або новіше з увімкненим Apple Intelligence в Системних налаштуваннях.\"\n        },\n        \"baseUrl\": {\n          \"title\": \"Базова URL-адреса\",\n          \"description\": \"Базова URL-адреса API для обраного провайдера. Лише власний провайдер можна редагувати.\",\n          \"placeholder\": \"https://api.openai.com/v1\"\n        },\n        \"apiKey\": {\n          \"title\": \"API-ключ\",\n          \"description\": \"API-ключ для обраного провайдера\",\n          \"placeholder\": \"sk-...\"\n        },\n        \"model\": {\n          \"title\": \"Модель\",\n          \"descriptionApple\": \"Вкажіть необов'язковий числовий ліміт токенів або залиште стандартний пресет на пристрої.\",\n          \"descriptionCustom\": \"Вкажіть ідентифікатор моделі, який очікує ваш ендпоінт.\",\n          \"descriptionDefault\": \"Оберіть модель, надану обраним провайдером\",\n          \"placeholderApple\": \"Apple Intelligence\",\n          \"placeholderWithOptions\": \"Шукайте або оберіть модель\",\n          \"placeholderNoOptions\": \"Введіть назву моделі\",\n          \"refreshModels\": \"Оновити моделі\"\n        }\n      },\n      \"prompts\": {\n        \"title\": \"Промпт\",\n        \"selectedPrompt\": {\n          \"title\": \"Обраний промпт\",\n          \"description\": \"Оберіть шаблон для покращення транскрипцій або створіть новий. Використовуйте ${output} у тексті промпта для посилання на захоплену транскрипцію.\"\n        },\n        \"noPrompts\": \"Немає доступних промптів\",\n        \"selectPrompt\": \"Оберіть промпт\",\n        \"createNew\": \"Створити новий промпт\",\n        \"promptLabel\": \"Назва промпта\",\n        \"promptLabelPlaceholder\": \"Введіть назву промпта\",\n        \"promptInstructions\": \"Інструкції промпта\",\n        \"promptInstructionsPlaceholder\": \"Напишіть інструкції для виконання після транскрипції. Приклад: Покращити граматику та ясність для наступного тексту: ${output}\",\n        \"promptTip\": \"Порада: Використовуйте <code>${output}</code> для вставки транскрибованого тексту у ваш промпт.\",\n        \"updatePrompt\": \"Оновити промпт\",\n        \"deletePrompt\": \"Видалити промпт\",\n        \"createPrompt\": \"Створити промпт\",\n        \"cancel\": \"Скасувати\",\n        \"selectToEdit\": \"Оберіть промпт вище для перегляду та редагування його деталей.\",\n        \"createFirst\": \"Натисніть «Створити новий промпт» вище, щоб створити ваш перший промпт постобробки.\"\n      }\n    },\n    \"history\": {\n      \"title\": \"Історія\",\n      \"openFolder\": \"Відкрити папку записів\",\n      \"loading\": \"Завантаження історії...\",\n      \"empty\": \"Транскрипцій поки немає. Почніть запис, щоб створити історію!\",\n      \"copyToClipboard\": \"Копіювати транскрипцію в буфер обміну\",\n      \"save\": \"Зберегти транскрипцію\",\n      \"unsave\": \"Видалити зі збережених\",\n      \"delete\": \"Видалити запис\",\n      \"deleteError\": \"Не вдалося видалити запис. Спробуйте ще раз.\"\n    },\n    \"debug\": {\n      \"title\": \"Дебаг\",\n      \"logDirectory\": {\n        \"title\": \"Папка логів\",\n        \"description\": \"Розташування файлів логів\"\n      },\n      \"logLevel\": {\n        \"title\": \"Рівень логування\",\n        \"description\": \"Встановіть детальність логування\"\n      },\n      \"updateChecks\": {\n        \"label\": \"Перевірка оновлень\",\n        \"description\": \"Автоматично перевіряти наявність нових версій Handy\"\n      },\n      \"soundTheme\": {\n        \"label\": \"Звукова тема\",\n        \"description\": \"Оберіть звукову тему для сповіщень про початок і зупинку запису\"\n      },\n      \"wordCorrectionThreshold\": {\n        \"title\": \"Поріг корекції слів\",\n        \"description\": \"Чутливість для корекції власних слів\"\n      },\n      \"historyLimit\": {\n        \"title\": \"Ліміт історії\",\n        \"description\": \"Максимальна кількість записів в історії\",\n        \"entries\": \"записів\"\n      },\n      \"recordingRetention\": {\n        \"title\": \"Автовидалення записів\",\n        \"description\": \"Автоматично видаляти старі записи для економії місця\",\n        \"never\": \"Ніколи\",\n        \"preserveLimit\": \"Зберігати останні {{count}}\",\n        \"days3\": \"Через 3 дні\",\n        \"weeks2\": \"Через 2 тижні\",\n        \"months3\": \"Через 3 місяці\",\n        \"placeholder\": \"Оберіть період зберігання...\"\n      },\n      \"alwaysOnMicrophone\": {\n        \"label\": \"Постійно активний мікрофон\",\n        \"description\": \"Тримати мікрофон активним для швидшого відгуку\"\n      },\n      \"clamshellMicrophone\": {\n        \"title\": \"Мікрофон у закритому режимі\",\n        \"description\": \"Мікрофон для використання при закритій кришці ноутбука\"\n      },\n      \"postProcessingToggle\": {\n        \"label\": \"Постобробка\",\n        \"description\": \"Увімкнути покращення тексту за допомогою AI після транскрипції\"\n      },\n      \"muteWhileRecording\": {\n        \"label\": \"Вимкнути звук під час запису\",\n        \"description\": \"Вимикати системний звук під час запису\"\n      },\n      \"appendTrailingSpace\": {\n        \"label\": \"Додавати пробіл в кінці\",\n        \"description\": \"Додавати пробіл після вставленої транскрипції\"\n      },\n      \"keyboardImplementation\": {\n        \"title\": \"Реалізація клавіатури\",\n        \"description\": \"Оберіть бекенд для клавіатурних скорочень.\",\n        \"bindingsReset\": \"Клавіатурні скорочення були несумісні та скинуті до значень за замовчуванням\"\n      },\n      \"paths\": {\n        \"appData\": \"Дані програми:\",\n        \"models\": \"Моделі:\",\n        \"settings\": \"Налаштування:\"\n      },\n      \"pasteDelay\": {\n        \"title\": \"Затримка вставки\",\n        \"description\": \"Затримка перед надсиланням натискання клавіші вставки (у мілісекундах). Збільшіть, якщо вставляється неправильний текст.\"\n      },\n      \"recordingBuffer\": {\n        \"title\": \"Додатковий буфер запису\",\n        \"description\": \"Додатковий час (у мілісекундах) для продовження запису після відпускання клавіші, щоб захопити завершальний звук. 0 = без додаткового буфера.\"\n      }\n    },\n    \"about\": {\n      \"title\": \"Інфо\",\n      \"version\": {\n        \"title\": \"Версія\",\n        \"description\": \"Поточна версія Handy\"\n      },\n      \"appDataDirectory\": {\n        \"title\": \"Папка даних програми\",\n        \"description\": \"Розташування даних Handy\"\n      },\n      \"sourceCode\": {\n        \"title\": \"Вихідний код\",\n        \"description\": \"Переглянути вихідний код та зробити внесок\",\n        \"button\": \"Переглянути на GitHub\"\n      },\n      \"supportDevelopment\": {\n        \"title\": \"Підтримати розробку\",\n        \"description\": \"Допоможіть нам продовжувати розвивати Handy\",\n        \"button\": \"Підтримати\"\n      },\n      \"acknowledgments\": {\n        \"title\": \"Подяки\",\n        \"whisper\": {\n          \"title\": \"Whisper.cpp\",\n          \"description\": \"Високопродуктивний інференс моделі автоматичного розпізнавання мовлення Whisper від OpenAI\",\n          \"details\": \"Handy використовує Whisper.cpp для швидкої локальної обробки мовлення в текст. Дякуємо за чудову роботу Георгію Герганову та контриб'юторам.\"\n        }\n      }\n    }\n  },\n  \"footer\": {\n    \"downloadingModel\": \"Завантаження {{model}}...\",\n    \"checkingUpdates\": \"Перевірка оновлень...\",\n    \"updateAvailable\": \"Доступне оновлення: {{version}}\",\n    \"updateAvailableShort\": \"Доступне оновлення\",\n    \"upToDate\": \"Актуальна версія\",\n    \"downloadUpdate\": \"Завантажити оновлення\",\n    \"restart\": \"Перезапустити\",\n    \"updateCheckingDisabled\": \"Перевірка оновлень вимкнена\",\n    \"downloading\": \"Завантаження... {{progress}}%\",\n    \"installing\": \"Встановлення...\",\n    \"preparing\": \"Підготовка...\",\n    \"checkForUpdates\": \"Перевірити оновлення\"\n  },\n  \"common\": {\n    \"loading\": \"Завантаження...\",\n    \"save\": \"Зберегти\",\n    \"cancel\": \"Скасувати\",\n    \"reset\": \"Скинути\",\n    \"add\": \"Додати\",\n    \"remove\": \"Видалити\",\n    \"delete\": \"Видалити\",\n    \"edit\": \"Редагувати\",\n    \"create\": \"Створити\",\n    \"update\": \"Оновити\",\n    \"close\": \"Закрити\",\n    \"open\": \"Відкрити\",\n    \"default\": \"За замовчуванням\",\n    \"enabled\": \"Увімкнено\",\n    \"disabled\": \"Вимкнено\",\n    \"on\": \"Увімк.\",\n    \"off\": \"Вимк.\",\n    \"yes\": \"Так\",\n    \"no\": \"Ні\",\n    \"noOptionsFound\": \"Опцій не знайдено\"\n  },\n  \"accessibility\": {\n    \"permissionsRequired\": \"Потрібні права доступу\",\n    \"permissionsDescription\": \"Handy потребує дозволи доступності для введення транскрибованого тексту.\",\n    \"openSettings\": \"Відкрити Системні налаштування\",\n    \"dismiss\": \"Закрити\"\n  },\n  \"errors\": {\n    \"loadDirectory\": \"Помилка завантаження папки: {{error}}\",\n    \"micPermissionDeniedTitle\": \"Доступ до мікрофона заборонено\",\n    \"micPermissionDenied\": {\n      \"generic\": \"Доступ до мікрофона було заборонено операційною системою. Надайте дозвіл на використання мікрофона в налаштуваннях системи.\",\n      \"windows\": \"Увімкніть доступ до мікрофона в Параметри → Конфіденційність і безпека → Мікрофон (включно з доступом класичних програм).\",\n      \"macos\": \"Надайте доступ до мікрофона в Системні налаштування → Конфіденційність і безпека → Мікрофон.\",\n      \"linux\": \"Надайте доступ до мікрофона в налаштуваннях звуку або конфіденційності вашої системи.\"\n    },\n    \"recordingFailed\": \"Не вдалося розпочати запис: {{error}}\",\n    \"modelLoadFailed\": \"Не вдалося завантажити модель: {{model}}\",\n    \"modelLoadFailedUnknown\": \"невідома модель\"\n  },\n  \"appLanguage\": {\n    \"title\": \"Мова інтерфейсу\",\n    \"description\": \"Змінити мову інтерфейсу Handy\"\n  },\n  \"overlay\": {\n    \"transcribing\": \"Обробка...\",\n    \"processing\": \"Постобробка...\"\n  }\n}\n"
  },
  {
    "path": "src/i18n/locales/vi/translation.json",
    "content": "{\n  \"tray\": {\n    \"settings\": \"Cài đặt...\",\n    \"checkUpdates\": \"Kiểm tra cập nhật...\",\n    \"copyLastTranscript\": \"Sao chép bản chép lời mới nhất\",\n    \"unloadModel\": \"Dỡ mô hình\",\n    \"model\": \"Mô hình\",\n    \"quit\": \"Thoát\",\n    \"cancel\": \"Hủy\"\n  },\n  \"sidebar\": {\n    \"general\": \"Chung\",\n    \"models\": \"Mô hình\",\n    \"advanced\": \"Nâng cao\",\n    \"postProcessing\": \"Xử lý sau\",\n    \"history\": \"Lịch sử\",\n    \"debug\": \"Gỡ lỗi\",\n    \"about\": \"Giới thiệu\"\n  },\n  \"onboarding\": {\n    \"subtitle\": \"Để bắt đầu, hãy chọn một mô hình chuyển đổi giọng nói\",\n    \"recommended\": \"Đề xuất\",\n    \"download\": \"Tải xuống\",\n    \"downloading\": \"Đang tải xuống...\",\n    \"customModelDescription\": \"Không được hỗ trợ chính thức\",\n    \"downloadFailed\": \"Tải xuống thất bại. Vui lòng thử lại.\",\n    \"modelCard\": {\n      \"accuracy\": \"độ chính xác\",\n      \"speed\": \"tốc độ\"\n    },\n    \"models\": {\n      \"small\": {\n        \"name\": \"Whisper Small\",\n        \"description\": \"Nhanh và khá chính xác.\"\n      },\n      \"medium\": {\n        \"name\": \"Whisper Medium\",\n        \"description\": \"Độ chính xác tốt, tốc độ trung bình\"\n      },\n      \"turbo\": {\n        \"name\": \"Whisper Turbo\",\n        \"description\": \"Cân bằng giữa độ chính xác và tốc độ.\"\n      },\n      \"large\": {\n        \"name\": \"Whisper Large\",\n        \"description\": \"Độ chính xác tốt, nhưng chậm.\"\n      },\n      \"parakeet-tdt-0.6b-v2\": {\n        \"name\": \"Parakeet V2\",\n        \"description\": \"Chỉ tiếng Anh. Mô hình tốt nhất cho người nói tiếng Anh.\"\n      },\n      \"parakeet-tdt-0.6b-v3\": {\n        \"name\": \"Parakeet V3\",\n        \"description\": \"Nhanh và chính xác\"\n      },\n      \"moonshine-base\": {\n        \"name\": \"Moonshine Base\",\n        \"description\": \"Rất nhanh, chỉ tiếng Anh. Xử lý tốt các giọng nói.\"\n      },\n      \"moonshine-tiny-streaming-en\": {\n        \"name\": \"Moonshine V2 Tiny\",\n        \"description\": \"Cực nhanh, chỉ tiếng Anh\"\n      },\n      \"moonshine-small-streaming-en\": {\n        \"name\": \"Moonshine V2 Small\",\n        \"description\": \"Nhanh, chỉ tiếng Anh. Cân bằng tốt giữa tốc độ và độ chính xác.\"\n      },\n      \"moonshine-medium-streaming-en\": {\n        \"name\": \"Moonshine V2 Medium\",\n        \"description\": \"Chỉ tiếng Anh. Chất lượng cao.\"\n      },\n      \"breeze-asr\": {\n        \"name\": \"Breeze ASR\",\n        \"description\": \"Tối ưu hóa cho tiếng Quan Thoại Đài Loan. Hỗ trợ chuyển đổi ngôn ngữ.\"\n      },\n      \"sense-voice-int8\": {\n        \"name\": \"SenseVoice\",\n        \"description\": \"Rất nhanh. Tiếng Trung, tiếng Anh, tiếng Nhật, tiếng Hàn, tiếng Quảng Đông.\"\n      },\n      \"gigaam-v3-e2e-ctc\": {\n        \"name\": \"GigaAM v3\",\n        \"description\": \"Nhận dạng giọng nói tiếng Nga. Nhanh và chính xác.\"\n      },\n      \"canary-180m-flash\": {\n        \"name\": \"Canary 180M Flash\",\n        \"description\": \"Rất nhanh. Tiếng Anh, tiếng Đức, tiếng Tây Ban Nha, tiếng Pháp. Hỗ trợ dịch thuật.\"\n      },\n      \"canary-1b-v2\": {\n        \"name\": \"Canary 1B v2\",\n        \"description\": \"Đa ngôn ngữ chính xác. 25 ngôn ngữ châu Âu. Hỗ trợ dịch thuật.\"\n      }\n    },\n    \"errors\": {\n      \"loadModels\": \"Không thể tải các mô hình có sẵn\",\n      \"downloadModel\": \"Không thể tải mô hình: {{error}}\"\n    },\n    \"permissions\": {\n      \"title\": \"Cần cấp quyền\",\n      \"description\": \"Handy cần một số quyền để hoạt động bình thường.\",\n      \"microphone\": {\n        \"title\": \"Quyền truy cập Micrô\",\n        \"description\": \"Cần thiết để nghe giọng nói của bạn để chuyển đổi.\"\n      },\n      \"accessibility\": {\n        \"title\": \"Quyền truy cập Trợ năng\",\n        \"description\": \"Cần thiết để nhập văn bản đã chuyển đổi vào các ứng dụng của bạn.\"\n      },\n      \"grant\": \"Cấp quyền\",\n      \"granted\": \"Đã cấp\",\n      \"waiting\": \"Đang chờ...\",\n      \"allGranted\": \"Hoàn tất!\",\n      \"errors\": {\n        \"checkFailed\": \"Không thể kiểm tra quyền. Vui lòng thử lại.\",\n        \"requestFailed\": \"Không thể yêu cầu quyền. Vui lòng thử lại.\"\n      }\n    }\n  },\n  \"modelSelector\": {\n    \"custom\": \"Tùy chỉnh\",\n    \"active\": \"Đang hoạt động\",\n    \"switching\": \"Đang chuyển...\",\n    \"noModelsAvailable\": \"Không có mô hình nào\",\n    \"extracting\": \"Đang giải nén {{modelName}}...\",\n    \"extractingMultiple\": \"Đang giải nén {{count}} mô hình...\",\n    \"extractingGeneric\": \"Đang giải nén...\",\n    \"downloading\": \"Đang tải xuống {{percentage}}%\",\n    \"downloadingMultiple\": \"Đang tải xuống {{count}} mô hình...\",\n    \"modelReady\": \"Mô Hình Sẵn Sàng\",\n    \"loading\": \"Đang tải {{modelName}}...\",\n    \"loadingGeneric\": \"Đang tải...\",\n    \"modelError\": \"Lỗi Mô Hình\",\n    \"modelUnloaded\": \"Mô Hình Đã Gỡ\",\n    \"noModelDownloadRequired\": \"Chưa Có Mô Hình - Cần Tải Xuống\",\n    \"deleteModel\": \"Xóa {{modelName}}\",\n    \"downloadSpeed\": \"{{speed}} MB/s\",\n    \"capabilities\": {\n      \"languageSelection\": \"Hỗ trợ nhiều ngôn ngữ đầu vào\",\n      \"multiLanguage\": \"Đa ngôn ngữ\",\n      \"translation\": \"Có thể dịch sang tiếng Anh\",\n      \"translate\": \"Dịch sang tiếng Anh\",\n      \"singleLanguage\": \"Chỉ hỗ trợ ngôn ngữ này\",\n      \"languageOnly\": \"Chỉ {{language}}\"\n    },\n    \"cancel\": \"Hủy\",\n    \"cancelDownload\": \"Hủy tải xuống\"\n  },\n  \"settings\": {\n    \"modelSettings\": {\n      \"title\": \"Cài đặt {{model}}\",\n      \"noSettingsNeeded\": \"Mô hình này hoạt động tự động mà không cần cấu hình.\"\n    },\n    \"models\": {\n      \"title\": \"Mô hình chuyển đổi\",\n      \"description\": \"Chọn mô hình chuyển đổi hoặc tải thêm mô hình. Các mô hình khác nhau có mức độ chính xác và tốc độ khác nhau.\",\n      \"downloaded\": \"Đã tải xuống\",\n      \"available\": \"Có thể tải xuống\",\n      \"deleteConfirm\": \"Bạn có chắc chắn muốn xóa {{modelName}}? Bạn sẽ cần tải lại để sử dụng.\",\n      \"deleteActiveConfirm\": \"{{modelName}} là mô hình đang hoạt động của bạn. Xóa nó sẽ dừng phiên âm cho đến khi bạn chọn một mô hình mới. Bạn có chắc không?\",\n      \"deleteTitle\": \"Xóa mô hình\",\n      \"filters\": {\n        \"all\": \"Tất cả\",\n        \"multiLanguage\": \"Đa ngôn ngữ\",\n        \"translation\": \"Dịch thuật\",\n        \"allLanguages\": \"Tất cả ngôn ngữ\"\n      },\n      \"noModelsMatch\": \"Không có mô hình nào khớp với bộ lọc này.\",\n      \"yourModels\": \"Mô hình đã tải\",\n      \"availableModels\": \"Có sẵn để tải xuống\"\n    },\n    \"general\": {\n      \"title\": \"Chung\",\n      \"shortcut\": {\n        \"title\": \"Phím tắt Handy\",\n        \"description\": \"Cấu hình phím tắt để kích hoạt ghi âm chuyển đổi giọng nói thành văn bản\",\n        \"loading\": \"Đang tải phím tắt...\",\n        \"none\": \"Chưa cấu hình phím tắt\",\n        \"notFound\": \"Không tìm thấy phím tắt\",\n        \"pressKeys\": \"Nhấn phím...\",\n        \"bindings\": {\n          \"transcribe\": {\n            \"name\": \"Phím tắt chuyển đổi\",\n            \"description\": \"Phím tắt để ghi âm và chuyển đổi giọng nói của bạn.\"\n          },\n          \"cancel\": {\n            \"name\": \"Phím tắt hủy\",\n            \"description\": \"Phím tắt để hủy bản ghi hiện tại.\"\n          },\n          \"transcribe_with_post_process\": {\n            \"name\": \"Phím tắt xử lý sau\",\n            \"description\": \"Tùy chọn: Phím tắt chuyên dụng luôn áp dụng xử lý sau bằng AI cho bản chuyển đổi của bạn.\"\n          }\n        },\n        \"errors\": {\n          \"restore\": \"Không thể khôi phục phím tắt gốc\",\n          \"set\": \"Không thể đặt phím tắt: {{error}}\",\n          \"reset\": \"Không thể đặt lại phím tắt về giá trị gốc\"\n        }\n      },\n      \"language\": {\n        \"title\": \"Ngôn ngữ\",\n        \"description\": \"Chọn ngôn ngữ để nhận dạng giọng nói. Tự động sẽ tự động xác định ngôn ngữ, trong khi chọn một ngôn ngữ cụ thể có thể cải thiện độ chính xác cho ngôn ngữ đó.\",\n        \"descriptionUnsupported\": \"Mô hình Parakeet tự động phát hiện ngôn ngữ. Không cần chọn thủ công.\",\n        \"searchPlaceholder\": \"Tìm kiếm ngôn ngữ...\",\n        \"noResults\": \"Không tìm thấy ngôn ngữ\",\n        \"auto\": \"Tự động\"\n      },\n      \"pushToTalk\": {\n        \"label\": \"Nhấn để nói\",\n        \"description\": \"Giữ để ghi âm, thả để dừng\"\n      }\n    },\n    \"sound\": {\n      \"title\": \"Âm thanh\",\n      \"microphone\": {\n        \"title\": \"Micrô\",\n        \"description\": \"Chọn thiết bị micrô ưa thích của bạn\",\n        \"placeholder\": \"Chọn micrô...\",\n        \"loading\": \"Đang tải...\"\n      },\n      \"audioFeedback\": {\n        \"label\": \"Phản hồi âm thanh\",\n        \"description\": \"Phát âm thanh khi bắt đầu và kết thúc ghi âm\"\n      },\n      \"outputDevice\": {\n        \"title\": \"Thiết bị đầu ra\",\n        \"description\": \"Chọn thiết bị đầu ra âm thanh ưa thích của bạn cho âm thanh phản hồi\",\n        \"placeholder\": \"Chọn thiết bị đầu ra...\",\n        \"loading\": \"Đang tải...\"\n      },\n      \"volume\": {\n        \"title\": \"Âm lượng\",\n        \"description\": \"Điều chỉnh âm lượng của âm thanh phản hồi\"\n      }\n    },\n    \"advanced\": {\n      \"title\": \"Nâng cao\",\n      \"groups\": {\n        \"app\": \"Ứng dụng\",\n        \"output\": \"Đầu ra\",\n        \"transcription\": \"Chuyển đổi\",\n        \"history\": \"Lịch sử\",\n        \"experimental\": \"Thử nghiệm\"\n      },\n      \"experimentalToggle\": {\n        \"label\": \"Tính năng thử nghiệm\",\n        \"description\": \"Bật các tính năng thử nghiệm đang trong quá trình phát triển.\"\n      },\n      \"lazyStreamClose\": {\n        \"label\": \"Giữ micro mở giữa các lần phiên âm\",\n        \"description\": \"Giữ luồng micro mở trong 30 giây sau khi dừng ghi âm, giảm độ trễ khi phiên âm liên tiếp. Có thể làm giảm chất lượng âm thanh Bluetooth khi đang hoạt động.\"\n      },\n      \"acceleration\": {\n        \"whisper\": {\n          \"title\": \"Tăng tốc Whisper\",\n          \"description\": \"Tăng tốc phần cứng cho các mô hình Whisper. Chế độ tự động sử dụng GPU nếu có (Metal trên macOS, Vulkan trên Windows/Linux).\"\n        },\n        \"ort\": {\n          \"title\": \"Tăng tốc ONNX\",\n          \"description\": \"Tăng tốc phần cứng cho các mô hình ONNX (Parakeet, Canary, Moonshine, v.v.). DirectML trên Windows là thử nghiệm. Các mô hình có thể không chuyển đổi giọng nói được.\"\n        }\n      },\n      \"startHidden\": {\n        \"label\": \"Khởi động ẩn\",\n        \"description\": \"Khởi động vào khay hệ thống mà không mở cửa sổ.\"\n      },\n      \"autostart\": {\n        \"label\": \"Khởi động cùng hệ thống\",\n        \"description\": \"Tự động khởi động Handy khi bạn đăng nhập vào máy tính.\"\n      },\n      \"showTrayIcon\": {\n        \"label\": \"Hiển thị biểu tượng khay\",\n        \"description\": \"Hiển thị biểu tượng Handy trong khay hệ thống.\"\n      },\n      \"overlay\": {\n        \"title\": \"Vị trí lớp phủ\",\n        \"description\": \"Hiển thị lớp phủ phản hồi trực quan trong quá trình ghi âm và chuyển đổi. Trên Linux, 'Không có' được khuyến nghị.\",\n        \"options\": {\n          \"none\": \"Không có\",\n          \"bottom\": \"Dưới\",\n          \"top\": \"Trên\"\n        }\n      },\n      \"pasteMethod\": {\n        \"title\": \"Phương thức dán\",\n        \"description\": \"Chọn cách chèn văn bản. Trực tiếp: mô phỏng gõ phím qua đầu vào hệ thống. Không có: bỏ qua dán, chỉ cập nhật lịch sử/clipboard.\",\n        \"options\": {\n          \"clipboard\": \"Clipboard ({{modifier}}+V)\",\n          \"clipboardCtrlShiftV\": \"Clipboard (Ctrl+Shift+V)\",\n          \"clipboardShiftInsert\": \"Clipboard (Shift+Insert)\",\n          \"direct\": \"Trực tiếp\",\n          \"none\": \"Không có\",\n          \"externalScript\": \"Script bên ngoài\"\n        },\n        \"externalScriptPlaceholder\": \"/duong-dan/toi/script.sh\"\n      },\n      \"typingTool\": {\n        \"title\": \"Công cụ gõ\",\n        \"description\": \"Chọn công cụ gõ trên Linux cho phương thức dán trực tiếp. Auto sẽ tự động phát hiện và dùng công cụ tốt nhất có sẵn cho hệ thống của bạn.\",\n        \"options\": {\n          \"auto\": \"Auto (Khuyến nghị)\"\n        }\n      },\n      \"clipboardHandling\": {\n        \"title\": \"Xử lý Clipboard\",\n        \"description\": \"Không sửa đổi Clipboard giữ nguyên nội dung clipboard hiện tại sau khi chuyển đổi. Sao chép vào Clipboard để lại kết quả chuyển đổi trong clipboard sau khi dán.\",\n        \"options\": {\n          \"dontModify\": \"Không sửa đổi Clipboard\",\n          \"copyToClipboard\": \"Sao chép vào Clipboard\"\n        }\n      },\n      \"autoSubmit\": {\n        \"title\": \"Gửi tự động\",\n        \"description\": \"Tự động gửi tổ hợp phím đã chọn sau khi chèn văn bản. Cmd+Enter áp dụng trên macOS, còn Windows/Linux dùng Super+Enter.\",\n        \"options\": {\n          \"off\": \"Tắt\",\n          \"enter\": \"Enter\",\n          \"cmdEnter\": \"Cmd+Enter\",\n          \"superEnter\": \"Super+Enter\",\n          \"ctrlEnter\": \"Ctrl+Enter\"\n        }\n      },\n      \"translateToEnglish\": {\n        \"label\": \"Dịch sang tiếng Anh\",\n        \"description\": \"Tự động dịch giọng nói từ các ngôn ngữ khác sang tiếng Anh trong quá trình chuyển đổi.\",\n        \"descriptionUnsupported\": \"Mô hình {{model}} không hỗ trợ dịch thuật.\"\n      },\n      \"modelUnload\": {\n        \"title\": \"Giải phóng mô hình\",\n        \"description\": \"Tự động giải phóng bộ nhớ GPU/CPU khi mô hình không được sử dụng trong thời gian quy định\",\n        \"options\": {\n          \"never\": \"Không bao giờ\",\n          \"immediately\": \"Ngay lập tức\",\n          \"min2\": \"Sau 2 phút\",\n          \"min5\": \"Sau 5 phút\",\n          \"min10\": \"Sau 10 phút\",\n          \"min15\": \"Sau 15 phút\",\n          \"hour1\": \"Sau 1 giờ\",\n          \"sec15\": \"Sau 15 giây (Gỡ lỗi)\"\n        }\n      },\n      \"customWords\": {\n        \"title\": \"Từ tùy chỉnh\",\n        \"description\": \"Thêm các từ thường bị nghe nhầm hoặc viết sai trong quá trình chuyển đổi. Hệ thống sẽ tự động sửa các từ có âm thanh tương tự để khớp với danh sách của bạn.\",\n        \"placeholder\": \"Thêm một từ\",\n        \"add\": \"Thêm\",\n        \"remove\": \"Xóa {{word}}\",\n        \"duplicate\": \"\\\"{{word}}\\\" đã tồn tại\"\n      }\n    },\n    \"postProcessing\": {\n      \"title\": \"Xử lý sau\",\n      \"hotkey\": {\n        \"title\": \"Phím tắt\"\n      },\n      \"api\": {\n        \"title\": \"API (Tương thích OpenAI)\",\n        \"provider\": {\n          \"title\": \"Nhà cung cấp\",\n          \"description\": \"Chọn một nhà cung cấp tương thích OpenAI.\"\n        },\n        \"appleIntelligence\": {\n          \"title\": \"Apple Intelligence\",\n          \"description\": \"Chạy hoàn toàn trên thiết bị. Không cần khóa API hoặc truy cập mạng.\",\n          \"requirements\": \"Yêu cầu Mac Apple Silicon chạy macOS Tahoe (26.0) trở lên. Apple Intelligence phải được bật trong Cài đặt Hệ thống.\",\n          \"unavailable\": \"Apple Intelligence không khả dụng trên thiết bị này. Yêu cầu Mac Apple Silicon chạy macOS Tahoe (26.0) trở lên với Apple Intelligence được bật trong Cài đặt Hệ thống.\"\n        },\n        \"baseUrl\": {\n          \"title\": \"URL cơ sở\",\n          \"description\": \"URL cơ sở API cho nhà cung cấp đã chọn. Chỉ có thể chỉnh sửa nhà cung cấp tùy chỉnh.\",\n          \"placeholder\": \"https://api.openai.com/v1\"\n        },\n        \"apiKey\": {\n          \"title\": \"Khóa API\",\n          \"description\": \"Khóa API cho nhà cung cấp đã chọn.\",\n          \"placeholder\": \"sk-...\"\n        },\n        \"model\": {\n          \"title\": \"Mô hình\",\n          \"descriptionApple\": \"Cung cấp giới hạn token tùy chọn hoặc giữ cài đặt mặc định trên thiết bị.\",\n          \"descriptionCustom\": \"Cung cấp định danh mô hình được yêu cầu bởi điểm cuối tùy chỉnh của bạn.\",\n          \"descriptionDefault\": \"Chọn một mô hình được cung cấp bởi nhà cung cấp đã chọn.\",\n          \"placeholderApple\": \"Apple Intelligence\",\n          \"placeholderWithOptions\": \"Tìm kiếm hoặc chọn một mô hình\",\n          \"placeholderNoOptions\": \"Nhập tên mô hình\",\n          \"refreshModels\": \"Làm mới mô hình\"\n        }\n      },\n      \"prompts\": {\n        \"title\": \"Prompt\",\n        \"selectedPrompt\": {\n          \"title\": \"Prompt đã chọn\",\n          \"description\": \"Chọn một mẫu để tinh chỉnh bản ghi hoặc tạo mới. Sử dụng ${output} trong văn bản prompt để tham chiếu bản ghi đã chụp.\"\n        },\n        \"noPrompts\": \"Không có prompt nào\",\n        \"selectPrompt\": \"Chọn một prompt\",\n        \"createNew\": \"Tạo Prompt mới\",\n        \"promptLabel\": \"Nhãn Prompt\",\n        \"promptLabelPlaceholder\": \"Nhập tên prompt\",\n        \"promptInstructions\": \"Hướng dẫn Prompt\",\n        \"promptInstructionsPlaceholder\": \"Viết hướng dẫn để chạy sau khi chuyển đổi. Ví dụ: Cải thiện ngữ pháp và độ rõ ràng cho văn bản sau: ${output}\",\n        \"promptTip\": \"Mẹo: Sử dụng <code>${output}</code> để chèn văn bản đã chuyển đổi vào prompt của bạn.\",\n        \"updatePrompt\": \"Cập nhật Prompt\",\n        \"deletePrompt\": \"Xóa Prompt\",\n        \"createPrompt\": \"Tạo Prompt\",\n        \"cancel\": \"Hủy\",\n        \"selectToEdit\": \"Chọn một prompt ở trên để xem và chỉnh sửa chi tiết.\",\n        \"createFirst\": \"Nhấn 'Tạo Prompt mới' ở trên để tạo prompt xử lý sau đầu tiên của bạn.\"\n      }\n    },\n    \"history\": {\n      \"title\": \"Lịch sử\",\n      \"openFolder\": \"Mở thư mục ghi âm\",\n      \"loading\": \"Đang tải lịch sử...\",\n      \"empty\": \"Chưa có bản ghi nào. Bắt đầu ghi âm để xây dựng lịch sử của bạn!\",\n      \"copyToClipboard\": \"Sao chép bản ghi vào clipboard\",\n      \"save\": \"Lưu bản ghi\",\n      \"unsave\": \"Xóa khỏi đã lưu\",\n      \"delete\": \"Xóa mục\",\n      \"deleteError\": \"Không thể xóa mục. Vui lòng thử lại.\"\n    },\n    \"debug\": {\n      \"title\": \"Gỡ lỗi\",\n      \"logDirectory\": {\n        \"title\": \"Thư mục nhật ký\",\n        \"description\": \"Vị trí lưu trữ các tệp nhật ký\"\n      },\n      \"logLevel\": {\n        \"title\": \"Mức nhật ký\",\n        \"description\": \"Đặt mức độ chi tiết của nhật ký\"\n      },\n      \"updateChecks\": {\n        \"label\": \"Kiểm tra cập nhật\",\n        \"description\": \"Tự động kiểm tra phiên bản mới của Handy\"\n      },\n      \"soundTheme\": {\n        \"label\": \"Chủ đề âm thanh\",\n        \"description\": \"Chọn chủ đề âm thanh cho phản hồi bắt đầu và kết thúc ghi âm\"\n      },\n      \"wordCorrectionThreshold\": {\n        \"title\": \"Ngưỡng sửa từ\",\n        \"description\": \"Độ nhạy cho việc sửa từ tùy chỉnh\"\n      },\n      \"historyLimit\": {\n        \"title\": \"Giới hạn lịch sử\",\n        \"description\": \"Số lượng mục lịch sử tối đa cần giữ\",\n        \"entries\": \"mục\"\n      },\n      \"recordingRetention\": {\n        \"title\": \"Tự động xóa ghi âm\",\n        \"description\": \"Tự động xóa các bản ghi âm cũ để tiết kiệm dung lượng\",\n        \"never\": \"Không bao giờ\",\n        \"preserveLimit\": \"Giữ {{count}} bản mới nhất\",\n        \"days3\": \"Sau 3 ngày\",\n        \"weeks2\": \"Sau 2 tuần\",\n        \"months3\": \"Sau 3 tháng\",\n        \"placeholder\": \"Chọn thời gian lưu giữ...\"\n      },\n      \"alwaysOnMicrophone\": {\n        \"label\": \"Micrô luôn bật\",\n        \"description\": \"Giữ micrô hoạt động để phản hồi nhanh hơn\"\n      },\n      \"clamshellMicrophone\": {\n        \"title\": \"Micrô chế độ gập\",\n        \"description\": \"Micrô sử dụng khi nắp laptop được đóng\"\n      },\n      \"postProcessingToggle\": {\n        \"label\": \"Xử lý sau\",\n        \"description\": \"Bật tinh chỉnh văn bản bằng AI sau khi chuyển đổi\"\n      },\n      \"muteWhileRecording\": {\n        \"label\": \"Tắt tiếng khi ghi âm\",\n        \"description\": \"Tắt tiếng âm thanh hệ thống trong khi ghi âm\"\n      },\n      \"appendTrailingSpace\": {\n        \"label\": \"Thêm dấu cách cuối\",\n        \"description\": \"Thêm một dấu cách sau bản ghi đã dán\"\n      },\n      \"keyboardImplementation\": {\n        \"title\": \"Triển khai bàn phím\",\n        \"description\": \"Chọn hệ thống xử lý phím tắt.\",\n        \"bindingsReset\": \"Các phím tắt không tương thích và đã được đặt lại về mặc định\"\n      },\n      \"paths\": {\n        \"appData\": \"Dữ liệu ứng dụng:\",\n        \"models\": \"Mô hình:\",\n        \"settings\": \"Cài đặt:\"\n      },\n      \"pasteDelay\": {\n        \"title\": \"Độ trễ dán\",\n        \"description\": \"Độ trễ trước khi gửi phím dán (tính bằng mili giây). Tăng nếu văn bản sai đang được dán.\"\n      },\n      \"recordingBuffer\": {\n        \"title\": \"Bộ đệm ghi âm thêm\",\n        \"description\": \"Thời gian thêm (tính bằng mili giây) để tiếp tục ghi âm sau khi nhả phím, để thu âm thanh cuối. 0 = không có bộ đệm thêm.\"\n      }\n    },\n    \"about\": {\n      \"title\": \"Giới thiệu\",\n      \"version\": {\n        \"title\": \"Phiên bản\",\n        \"description\": \"Phiên bản hiện tại của Handy\"\n      },\n      \"appDataDirectory\": {\n        \"title\": \"Thư mục dữ liệu ứng dụng\",\n        \"description\": \"Vị trí Handy lưu trữ dữ liệu\"\n      },\n      \"sourceCode\": {\n        \"title\": \"Mã nguồn\",\n        \"description\": \"Xem mã nguồn và đóng góp\",\n        \"button\": \"Xem trên GitHub\"\n      },\n      \"supportDevelopment\": {\n        \"title\": \"Hỗ trợ phát triển\",\n        \"description\": \"Giúp chúng tôi tiếp tục xây dựng Handy\",\n        \"button\": \"Quyên góp\"\n      },\n      \"acknowledgments\": {\n        \"title\": \"Lời cảm ơn\",\n        \"whisper\": {\n          \"title\": \"Whisper.cpp\",\n          \"description\": \"Suy luận hiệu suất cao của mô hình nhận dạng giọng nói tự động Whisper của OpenAI\",\n          \"details\": \"Handy sử dụng Whisper.cpp để xử lý chuyển đổi giọng nói thành văn bản nhanh, cục bộ. Cảm ơn công việc tuyệt vời của Georgi Gerganov và các cộng tác viên.\"\n        }\n      }\n    }\n  },\n  \"footer\": {\n    \"downloadingModel\": \"Đang tải {{model}}...\",\n    \"checkingUpdates\": \"Đang kiểm tra cập nhật...\",\n    \"updateAvailable\": \"Có bản cập nhật: {{version}}\",\n    \"updateAvailableShort\": \"Có bản cập nhật\",\n    \"upToDate\": \"Đã cập nhật\",\n    \"downloadUpdate\": \"Tải cập nhật\",\n    \"restart\": \"Khởi động lại\",\n    \"updateCheckingDisabled\": \"Đã tắt kiểm tra cập nhật\",\n    \"downloading\": \"Đang tải... {{progress}}%\",\n    \"installing\": \"Đang cài đặt...\",\n    \"preparing\": \"Đang chuẩn bị...\",\n    \"checkForUpdates\": \"Kiểm tra cập nhật\"\n  },\n  \"common\": {\n    \"loading\": \"Đang tải...\",\n    \"save\": \"Lưu\",\n    \"cancel\": \"Hủy\",\n    \"reset\": \"Đặt lại\",\n    \"add\": \"Thêm\",\n    \"remove\": \"Xóa\",\n    \"delete\": \"Xóa\",\n    \"edit\": \"Chỉnh sửa\",\n    \"create\": \"Tạo\",\n    \"update\": \"Cập nhật\",\n    \"close\": \"Đóng\",\n    \"open\": \"Mở\",\n    \"default\": \"Mặc định\",\n    \"enabled\": \"Đã bật\",\n    \"disabled\": \"Đã tắt\",\n    \"on\": \"Bật\",\n    \"off\": \"Tắt\",\n    \"yes\": \"Có\",\n    \"no\": \"Không\",\n    \"noOptionsFound\": \"Không tìm thấy tùy chọn\"\n  },\n  \"accessibility\": {\n    \"permissionsRequired\": \"Cần quyền truy cập\",\n    \"permissionsDescription\": \"Handy cần quyền truy cập để gõ văn bản đã chuyển đổi.\",\n    \"openSettings\": \"Mở Cài đặt Hệ thống\",\n    \"dismiss\": \"Bỏ qua\"\n  },\n  \"errors\": {\n    \"loadDirectory\": \"Lỗi khi tải thư mục: {{error}}\",\n    \"micPermissionDeniedTitle\": \"Quyền truy cập micrô bị từ chối\",\n    \"micPermissionDenied\": {\n      \"generic\": \"Quyền truy cập micrô đã bị hệ điều hành từ chối. Vui lòng cấp quyền micrô trong cài đặt hệ thống.\",\n      \"windows\": \"Bật quyền truy cập micrô trong Cài đặt → Quyền riêng tư & bảo mật → Micrô (bao gồm quyền truy cập ứng dụng máy tính).\",\n      \"macos\": \"Cấp quyền truy cập micrô trong Cài đặt hệ thống → Quyền riêng tư & Bảo mật → Micrô.\",\n      \"linux\": \"Cấp quyền truy cập micrô trong cài đặt âm thanh hoặc quyền riêng tư của hệ thống.\"\n    },\n    \"recordingFailed\": \"Không thể bắt đầu ghi âm: {{error}}\",\n    \"modelLoadFailed\": \"Không thể tải mô hình: {{model}}\",\n    \"modelLoadFailedUnknown\": \"mô hình không xác định\"\n  },\n  \"appLanguage\": {\n    \"title\": \"Ngôn ngữ ứng dụng\",\n    \"description\": \"Thay đổi ngôn ngữ giao diện của Handy\"\n  },\n  \"overlay\": {\n    \"transcribing\": \"Đang chuyển đổi...\",\n    \"processing\": \"Đang xử lý...\"\n  }\n}\n"
  },
  {
    "path": "src/i18n/locales/zh/translation.json",
    "content": "{\n  \"tray\": {\n    \"settings\": \"设置...\",\n    \"checkUpdates\": \"检查更新...\",\n    \"copyLastTranscript\": \"复制最新转录\",\n    \"unloadModel\": \"卸载模型\",\n    \"model\": \"模型\",\n    \"quit\": \"退出\",\n    \"cancel\": \"取消\"\n  },\n  \"sidebar\": {\n    \"general\": \"通用\",\n    \"models\": \"模型\",\n    \"advanced\": \"高级\",\n    \"postProcessing\": \"后处理\",\n    \"history\": \"历史记录\",\n    \"debug\": \"调试\",\n    \"about\": \"关于\"\n  },\n  \"onboarding\": {\n    \"subtitle\": \"请选择一个转录模型以开始使用\",\n    \"recommended\": \"推荐\",\n    \"download\": \"下载\",\n    \"downloading\": \"下载中...\",\n    \"customModelDescription\": \"非官方支持\",\n    \"downloadFailed\": \"下载失败。请重试。\",\n    \"modelCard\": {\n      \"accuracy\": \"准确度\",\n      \"speed\": \"速度\"\n    },\n    \"models\": {\n      \"small\": {\n        \"name\": \"Whisper Small\",\n        \"description\": \"快速且相当准确。\"\n      },\n      \"medium\": {\n        \"name\": \"Whisper Medium\",\n        \"description\": \"准确度高，速度适中\"\n      },\n      \"turbo\": {\n        \"name\": \"Whisper Turbo\",\n        \"description\": \"准确度和速度均衡。\"\n      },\n      \"large\": {\n        \"name\": \"Whisper Large\",\n        \"description\": \"准确度高，但速度较慢。\"\n      },\n      \"parakeet-tdt-0.6b-v2\": {\n        \"name\": \"Parakeet V2\",\n        \"description\": \"仅支持英语。英语用户的最佳模型。\"\n      },\n      \"parakeet-tdt-0.6b-v3\": {\n        \"name\": \"Parakeet V3\",\n        \"description\": \"快速且准确\"\n      },\n      \"moonshine-base\": {\n        \"name\": \"Moonshine Base\",\n        \"description\": \"非常快速，仅支持英语。口音处理良好。\"\n      },\n      \"moonshine-tiny-streaming-en\": {\n        \"name\": \"Moonshine V2 Tiny\",\n        \"description\": \"超快速，仅支持英语\"\n      },\n      \"moonshine-small-streaming-en\": {\n        \"name\": \"Moonshine V2 Small\",\n        \"description\": \"快速，仅支持英语。速度与准确度的良好平衡。\"\n      },\n      \"moonshine-medium-streaming-en\": {\n        \"name\": \"Moonshine V2 Medium\",\n        \"description\": \"仅支持英语。高质量。\"\n      },\n      \"breeze-asr\": {\n        \"name\": \"Breeze ASR\",\n        \"description\": \"针对台湾国语优化。支持语码转换。\"\n      },\n      \"sense-voice-int8\": {\n        \"name\": \"SenseVoice\",\n        \"description\": \"非常快速。支持中文、英语、日语、韩语、粤语。\"\n      },\n      \"gigaam-v3-e2e-ctc\": {\n        \"name\": \"GigaAM v3\",\n        \"description\": \"俄语语音识别。快速且准确。\"\n      },\n      \"canary-180m-flash\": {\n        \"name\": \"Canary 180M Flash\",\n        \"description\": \"非常快速。支持英语、德语、西班牙语、法语。支持翻译。\"\n      },\n      \"canary-1b-v2\": {\n        \"name\": \"Canary 1B v2\",\n        \"description\": \"准确的多语言模型。25种欧洲语言。支持翻译。\"\n      }\n    },\n    \"errors\": {\n      \"loadModels\": \"无法加载可用模型\",\n      \"downloadModel\": \"模型下载失败: {{error}}\"\n    },\n    \"permissions\": {\n      \"title\": \"需要权限\",\n      \"description\": \"Handy 需要一些权限才能正常工作。\",\n      \"microphone\": {\n        \"title\": \"麦克风访问\",\n        \"description\": \"需要听取您的语音进行转录。\"\n      },\n      \"accessibility\": {\n        \"title\": \"辅助功能访问\",\n        \"description\": \"需要将转录文本输入到您的应用程序中。\"\n      },\n      \"grant\": \"授予权限\",\n      \"granted\": \"已授予\",\n      \"waiting\": \"等待中...\",\n      \"allGranted\": \"全部完成！\",\n      \"errors\": {\n        \"checkFailed\": \"检查权限失败。请重试。\",\n        \"requestFailed\": \"请求权限失败。请重试。\"\n      }\n    }\n  },\n  \"modelSelector\": {\n    \"custom\": \"自定义\",\n    \"active\": \"活跃\",\n    \"switching\": \"切换中...\",\n    \"noModelsAvailable\": \"没有可用的模型\",\n    \"extracting\": \"正在解压 {{modelName}}...\",\n    \"extractingMultiple\": \"正在解压 {{count}} 个模型...\",\n    \"extractingGeneric\": \"解压中...\",\n    \"downloading\": \"下载中 {{percentage}}%\",\n    \"downloadingMultiple\": \"正在下载 {{count}} 个模型...\",\n    \"modelReady\": \"模型已就绪\",\n    \"loading\": \"正在加载 {{modelName}}...\",\n    \"loadingGeneric\": \"加载中...\",\n    \"modelError\": \"模型错误\",\n    \"modelUnloaded\": \"模型已卸载\",\n    \"noModelDownloadRequired\": \"无模型 - 需要下载\",\n    \"deleteModel\": \"删除 {{modelName}}\",\n    \"downloadSpeed\": \"{{speed}} MB/s\",\n    \"capabilities\": {\n      \"languageSelection\": \"支持多种输入语言\",\n      \"multiLanguage\": \"多语言\",\n      \"translation\": \"可翻译为英语\",\n      \"translate\": \"翻译为英语\",\n      \"singleLanguage\": \"仅支持此语言\",\n      \"languageOnly\": \"仅 {{language}}\"\n    },\n    \"cancel\": \"取消\",\n    \"cancelDownload\": \"取消下载\"\n  },\n  \"settings\": {\n    \"modelSettings\": {\n      \"title\": \"{{model}} 设置\",\n      \"noSettingsNeeded\": \"此模型自动运行，无需配置。\"\n    },\n    \"models\": {\n      \"title\": \"转录模型\",\n      \"description\": \"选择转录模型或下载其他模型。不同模型提供不同的准确度和速度。\",\n      \"downloaded\": \"已下载\",\n      \"available\": \"可下载\",\n      \"deleteConfirm\": \"确定要删除 {{modelName}} 吗？您需要重新下载才能使用。\",\n      \"deleteActiveConfirm\": \"{{modelName}} 是您当前使用的模型。删除后将停止转录，直到您选择新的模型。确定要删除吗？\",\n      \"deleteTitle\": \"删除模型\",\n      \"filters\": {\n        \"all\": \"全部\",\n        \"multiLanguage\": \"多语言\",\n        \"translation\": \"翻译\",\n        \"allLanguages\": \"所有语言\"\n      },\n      \"noModelsMatch\": \"没有符合此筛选条件的模型。\",\n      \"yourModels\": \"已下载的模型\",\n      \"availableModels\": \"可供下载\"\n    },\n    \"general\": {\n      \"title\": \"通用\",\n      \"shortcut\": {\n        \"title\": \"Handy 快捷键\",\n        \"description\": \"配置启动语音转文字录制的键盘快捷键\",\n        \"loading\": \"加载快捷键中...\",\n        \"none\": \"未配置快捷键\",\n        \"notFound\": \"未找到快捷键\",\n        \"pressKeys\": \"请按键...\",\n        \"bindings\": {\n          \"transcribe\": {\n            \"name\": \"转录快捷键\",\n            \"description\": \"用于录制和转录语音的键盘快捷键。\"\n          },\n          \"cancel\": {\n            \"name\": \"取消快捷键\",\n            \"description\": \"用于取消当前录制的键盘快捷键。\"\n          },\n          \"transcribe_with_post_process\": {\n            \"name\": \"后处理快捷键\",\n            \"description\": \"可选：一个专用快捷键，始终对您的转录应用 AI 后处理。\"\n          }\n        },\n        \"errors\": {\n          \"restore\": \"无法恢复原始快捷键\",\n          \"set\": \"无法设置快捷键: {{error}}\",\n          \"reset\": \"无法将快捷键重置为原始值\"\n        }\n      },\n      \"language\": {\n        \"title\": \"语言\",\n        \"description\": \"选择语音识别的语言。自动将自动确定语言，选择特定语言可以提高该语言的准确度。\",\n        \"descriptionUnsupported\": \"Parakeet 模型会自动检测语言，无需手动选择。\",\n        \"searchPlaceholder\": \"搜索语言...\",\n        \"noResults\": \"未找到语言\",\n        \"auto\": \"自动\"\n      },\n      \"pushToTalk\": {\n        \"label\": \"按住说话\",\n        \"description\": \"按住录制，松开停止\"\n      }\n    },\n    \"sound\": {\n      \"title\": \"声音\",\n      \"microphone\": {\n        \"title\": \"麦克风\",\n        \"description\": \"选择您偏好的麦克风设备\",\n        \"placeholder\": \"选择麦克风...\",\n        \"loading\": \"加载中...\"\n      },\n      \"audioFeedback\": {\n        \"label\": \"音频反馈\",\n        \"description\": \"录制开始和停止时播放声音\"\n      },\n      \"outputDevice\": {\n        \"title\": \"输出设备\",\n        \"description\": \"选择用于反馈声音的音频输出设备\",\n        \"placeholder\": \"选择输出设备...\",\n        \"loading\": \"加载中...\"\n      },\n      \"volume\": {\n        \"title\": \"音量\",\n        \"description\": \"调整音频反馈的音量\"\n      }\n    },\n    \"advanced\": {\n      \"title\": \"高级\",\n      \"groups\": {\n        \"app\": \"应用\",\n        \"output\": \"输出\",\n        \"transcription\": \"转录\",\n        \"history\": \"历史\",\n        \"experimental\": \"实验性\"\n      },\n      \"experimentalToggle\": {\n        \"label\": \"实验性功能\",\n        \"description\": \"启用仍在开发中的实验性功能。\"\n      },\n      \"lazyStreamClose\": {\n        \"label\": \"在转录之间保持麦克风开启\",\n        \"description\": \"录音停止后保持麦克风流开启30秒，减少连续转录时的延迟。激活时可能会降低蓝牙音频质量。\"\n      },\n      \"acceleration\": {\n        \"whisper\": {\n          \"title\": \"Whisper 加速\",\n          \"description\": \"Whisper 模型的硬件加速。自动模式在可用时使用 GPU（macOS 上使用 Metal，Windows/Linux 上使用 Vulkan）。\"\n        },\n        \"ort\": {\n          \"title\": \"ONNX 加速\",\n          \"description\": \"ONNX 模型的硬件加速（Parakeet、Canary、Moonshine 等）。Windows 上的 DirectML 为实验性功能。模型可能无法正常转录。\"\n        }\n      },\n      \"startHidden\": {\n        \"label\": \"隐藏启动\",\n        \"description\": \"启动到系统托盘而不打开窗口。\"\n      },\n      \"autostart\": {\n        \"label\": \"开机启动\",\n        \"description\": \"登录计算机时自动启动 Handy。\"\n      },\n      \"showTrayIcon\": {\n        \"label\": \"显示托盘图标\",\n        \"description\": \"在系统托盘中显示 Handy 图标。\"\n      },\n      \"overlay\": {\n        \"title\": \"悬浮窗位置\",\n        \"description\": \"在录制和转录期间显示可视反馈悬浮窗。在 Linux 上建议选择「无」。\",\n        \"options\": {\n          \"none\": \"无\",\n          \"bottom\": \"底部\",\n          \"top\": \"顶部\"\n        }\n      },\n      \"pasteMethod\": {\n        \"title\": \"粘贴方式\",\n        \"description\": \"选择文字插入方式。直接：通过系统输入模拟打字。无：跳过粘贴，仅更新历史记录/剪贴板。\",\n        \"options\": {\n          \"clipboard\": \"剪贴板 ({{modifier}}+V)\",\n          \"clipboardCtrlShiftV\": \"剪贴板 (Ctrl+Shift+V)\",\n          \"clipboardShiftInsert\": \"剪贴板 (Shift+Insert)\",\n          \"direct\": \"直接\",\n          \"none\": \"无\",\n          \"externalScript\": \"外部脚本\"\n        },\n        \"externalScriptPlaceholder\": \"/path/to/your/script.sh\"\n      },\n      \"typingTool\": {\n        \"title\": \"输入工具\",\n        \"description\": \"选择在直接粘贴方式下使用的 Linux 输入工具。Auto 会自动检测并使用系统中可用的最佳工具。\",\n        \"options\": {\n          \"auto\": \"Auto（推荐）\"\n        }\n      },\n      \"clipboardHandling\": {\n        \"title\": \"剪贴板处理\",\n        \"description\": \"不修改剪贴板将在转录后保留当前剪贴板内容。复制到剪贴板将在粘贴后将转录结果留在剪贴板中。\",\n        \"options\": {\n          \"dontModify\": \"不修改剪贴板\",\n          \"copyToClipboard\": \"复制到剪贴板\"\n        }\n      },\n      \"autoSubmit\": {\n        \"title\": \"自动提交\",\n        \"description\": \"在文本插入后自动发送所选的按键组合。macOS 上使用 Cmd+Enter，Windows/Linux 上使用 Super+Enter。\",\n        \"options\": {\n          \"off\": \"关闭\",\n          \"enter\": \"Enter\",\n          \"cmdEnter\": \"Cmd+Enter\",\n          \"superEnter\": \"Super+Enter\",\n          \"ctrlEnter\": \"Ctrl+Enter\"\n        }\n      },\n      \"translateToEnglish\": {\n        \"label\": \"翻译为英语\",\n        \"description\": \"在转录过程中自动将其他语言的语音翻译为英语。\",\n        \"descriptionUnsupported\": \"{{model}} 模型不支持翻译功能。\"\n      },\n      \"modelUnload\": {\n        \"title\": \"卸载模型\",\n        \"description\": \"当模型在指定时间内未使用时自动释放 GPU/CPU 内存\",\n        \"options\": {\n          \"never\": \"从不\",\n          \"immediately\": \"立即\",\n          \"min2\": \"2 分钟后\",\n          \"min5\": \"5 分钟后\",\n          \"min10\": \"10 分钟后\",\n          \"min15\": \"15 分钟后\",\n          \"hour1\": \"1 小时后\",\n          \"sec15\": \"15 秒后（调试）\"\n        }\n      },\n      \"customWords\": {\n        \"title\": \"自定义词汇\",\n        \"description\": \"添加经常被误听或拼写错误的词汇。系统将自动将发音相似的词汇修正为您列表中的词汇。\",\n        \"placeholder\": \"添加词汇\",\n        \"add\": \"添加\",\n        \"remove\": \"删除 {{word}}\",\n        \"duplicate\": \"\\\"{{word}}\\\" 已存在\"\n      }\n    },\n    \"postProcessing\": {\n      \"title\": \"后处理\",\n      \"hotkey\": {\n        \"title\": \"快捷键\"\n      },\n      \"api\": {\n        \"title\": \"API（兼容 OpenAI）\",\n        \"provider\": {\n          \"title\": \"提供商\",\n          \"description\": \"选择一个兼容 OpenAI 的提供商。\"\n        },\n        \"appleIntelligence\": {\n          \"title\": \"Apple Intelligence\",\n          \"description\": \"完全在设备上运行。无需 API 密钥或网络访问。\",\n          \"requirements\": \"需要运行 macOS Tahoe（26.0）或更高版本的 Apple Silicon Mac。必须在系统设置中启用 Apple Intelligence。\",\n          \"unavailable\": \"Apple Intelligence 在此设备上不可用。需要运行 macOS Tahoe（26.0）或更高版本的 Apple Silicon Mac，并在系统设置中启用 Apple Intelligence。\"\n        },\n        \"baseUrl\": {\n          \"title\": \"基础 URL\",\n          \"description\": \"所选提供商的 API 基础 URL。仅自定义提供商可编辑。\",\n          \"placeholder\": \"https://api.openai.com/v1\"\n        },\n        \"apiKey\": {\n          \"title\": \"API 密钥\",\n          \"description\": \"所选提供商的 API 密钥。\",\n          \"placeholder\": \"sk-...\"\n        },\n        \"model\": {\n          \"title\": \"模型\",\n          \"descriptionApple\": \"提供可选的数字令牌限制或保持默认的设备预设。\",\n          \"descriptionCustom\": \"提供自定义端点期望的模型标识符。\",\n          \"descriptionDefault\": \"选择所选提供商提供的模型。\",\n          \"placeholderApple\": \"Apple Intelligence\",\n          \"placeholderWithOptions\": \"搜索或选择模型\",\n          \"placeholderNoOptions\": \"输入模型名称\",\n          \"refreshModels\": \"刷新模型\"\n        }\n      },\n      \"prompts\": {\n        \"title\": \"提示词\",\n        \"selectedPrompt\": {\n          \"title\": \"已选提示词\",\n          \"description\": \"选择用于优化转录的模板或创建新模板。在提示词文本中使用 ${output} 来引用捕获的转录。\"\n        },\n        \"noPrompts\": \"没有可用的提示词\",\n        \"selectPrompt\": \"选择提示词\",\n        \"createNew\": \"创建新提示词\",\n        \"promptLabel\": \"提示词名称\",\n        \"promptLabelPlaceholder\": \"输入提示词名称\",\n        \"promptInstructions\": \"提示词指令\",\n        \"promptInstructionsPlaceholder\": \"编写转录后要执行的指令。示例：改进以下文本的语法和清晰度: ${output}\",\n        \"promptTip\": \"提示：使用 <code>${output}</code> 将转录文本插入到您的提示词中。\",\n        \"updatePrompt\": \"更新提示词\",\n        \"deletePrompt\": \"删除提示词\",\n        \"createPrompt\": \"创建提示词\",\n        \"cancel\": \"取消\",\n        \"selectToEdit\": \"选择上方的提示词以查看和编辑其详细信息。\",\n        \"createFirst\": \"点击上方的「创建新提示词」来创建您的第一个后处理提示词。\"\n      }\n    },\n    \"history\": {\n      \"title\": \"历史记录\",\n      \"openFolder\": \"打开录音文件夹\",\n      \"loading\": \"加载历史记录中...\",\n      \"empty\": \"还没有转录记录。开始录制以建立您的历史记录！\",\n      \"copyToClipboard\": \"复制转录到剪贴板\",\n      \"save\": \"保存转录\",\n      \"unsave\": \"从已保存中移除\",\n      \"delete\": \"删除条目\",\n      \"deleteError\": \"删除条目失败，请重试。\"\n    },\n    \"debug\": {\n      \"title\": \"调试\",\n      \"logDirectory\": {\n        \"title\": \"日志目录\",\n        \"description\": \"日志文件的存储位置\"\n      },\n      \"logLevel\": {\n        \"title\": \"日志级别\",\n        \"description\": \"设置日志的详细程度\"\n      },\n      \"updateChecks\": {\n        \"label\": \"检查更新\",\n        \"description\": \"自动检查 Handy 的新版本\"\n      },\n      \"soundTheme\": {\n        \"label\": \"声音主题\",\n        \"description\": \"选择录制开始和停止反馈的声音主题\"\n      },\n      \"wordCorrectionThreshold\": {\n        \"title\": \"词汇修正阈值\",\n        \"description\": \"自定义词汇修正的灵敏度\"\n      },\n      \"historyLimit\": {\n        \"title\": \"历史记录上限\",\n        \"description\": \"保留的最大历史条目数\",\n        \"entries\": \"条\"\n      },\n      \"recordingRetention\": {\n        \"title\": \"自动删除录音\",\n        \"description\": \"自动删除旧录音以节省空间\",\n        \"never\": \"从不\",\n        \"preserveLimit\": \"保留最新 {{count}} 条\",\n        \"days3\": \"3 天后\",\n        \"weeks2\": \"2 周后\",\n        \"months3\": \"3 个月后\",\n        \"placeholder\": \"选择保留期限...\"\n      },\n      \"alwaysOnMicrophone\": {\n        \"label\": \"麦克风常开\",\n        \"description\": \"保持麦克风活跃以获得更快的响应\"\n      },\n      \"clamshellMicrophone\": {\n        \"title\": \"合盖麦克风\",\n        \"description\": \"笔记本电脑盖子关闭时使用的麦克风\"\n      },\n      \"postProcessingToggle\": {\n        \"label\": \"后处理\",\n        \"description\": \"启用转录后的 AI 文本优化\"\n      },\n      \"muteWhileRecording\": {\n        \"label\": \"录制时静音\",\n        \"description\": \"录制期间静音系统音频\"\n      },\n      \"appendTrailingSpace\": {\n        \"label\": \"追加尾部空格\",\n        \"description\": \"在粘贴的转录后添加空格\"\n      },\n      \"keyboardImplementation\": {\n        \"title\": \"键盘实现\",\n        \"description\": \"选择键盘快捷键的后端。\",\n        \"bindingsReset\": \"键盘快捷键不兼容，已重置为默认值\"\n      },\n      \"paths\": {\n        \"appData\": \"应用数据:\",\n        \"models\": \"模型:\",\n        \"settings\": \"设置:\"\n      },\n      \"pasteDelay\": {\n        \"title\": \"粘贴延迟\",\n        \"description\": \"发送粘贴按键前的延迟（毫秒）。如果粘贴了错误的文本，请增加此值。\"\n      },\n      \"recordingBuffer\": {\n        \"title\": \"额外录音缓冲\",\n        \"description\": \"放开按键后继续录音的额外时间（毫秒），以捕捉尾音。0 = 无额外缓冲。\"\n      }\n    },\n    \"about\": {\n      \"title\": \"关于\",\n      \"version\": {\n        \"title\": \"版本\",\n        \"description\": \"Handy 的当前版本\"\n      },\n      \"appDataDirectory\": {\n        \"title\": \"应用数据目录\",\n        \"description\": \"Handy 存储数据的位置\"\n      },\n      \"sourceCode\": {\n        \"title\": \"源代码\",\n        \"description\": \"查看源代码并贡献\",\n        \"button\": \"在 GitHub 上查看\"\n      },\n      \"supportDevelopment\": {\n        \"title\": \"支持开发\",\n        \"description\": \"帮助我们继续开发 Handy\",\n        \"button\": \"捐赠\"\n      },\n      \"acknowledgments\": {\n        \"title\": \"致谢\",\n        \"whisper\": {\n          \"title\": \"Whisper.cpp\",\n          \"description\": \"OpenAI Whisper 自动语音识别模型的高性能推理\",\n          \"details\": \"Handy 使用 Whisper.cpp 进行快速的本地语音转文字处理。感谢 Georgi Gerganov 和贡献者们的出色工作。\"\n        }\n      }\n    }\n  },\n  \"footer\": {\n    \"downloadingModel\": \"正在下载 {{model}}...\",\n    \"checkingUpdates\": \"检查更新中...\",\n    \"updateAvailable\": \"有可用更新: {{version}}\",\n    \"updateAvailableShort\": \"有可用更新\",\n    \"upToDate\": \"已是最新版本\",\n    \"downloadUpdate\": \"下载更新\",\n    \"restart\": \"重启\",\n    \"updateCheckingDisabled\": \"更新检查已禁用\",\n    \"downloading\": \"下载中... {{progress}}%\",\n    \"installing\": \"安装中...\",\n    \"preparing\": \"准备中...\",\n    \"checkForUpdates\": \"检查更新\"\n  },\n  \"common\": {\n    \"loading\": \"加载中...\",\n    \"save\": \"保存\",\n    \"cancel\": \"取消\",\n    \"reset\": \"重置\",\n    \"add\": \"添加\",\n    \"remove\": \"移除\",\n    \"delete\": \"删除\",\n    \"edit\": \"编辑\",\n    \"create\": \"创建\",\n    \"update\": \"更新\",\n    \"close\": \"关闭\",\n    \"open\": \"打开\",\n    \"default\": \"默认\",\n    \"enabled\": \"已启用\",\n    \"disabled\": \"已禁用\",\n    \"on\": \"开\",\n    \"off\": \"关\",\n    \"yes\": \"是\",\n    \"no\": \"否\",\n    \"noOptionsFound\": \"未找到选项\"\n  },\n  \"accessibility\": {\n    \"permissionsRequired\": \"需要辅助功能权限\",\n    \"permissionsDescription\": \"Handy 需要辅助功能权限才能输入转录的文字。\",\n    \"openSettings\": \"打开系统设置\",\n    \"dismiss\": \"关闭\"\n  },\n  \"errors\": {\n    \"loadDirectory\": \"加载目录时出错: {{error}}\",\n    \"micPermissionDeniedTitle\": \"麦克风访问被拒绝\",\n    \"micPermissionDenied\": {\n      \"generic\": \"操作系统拒绝了麦克风访问。请在系统设置中授予麦克风权限。\",\n      \"windows\": \"在设置 → 隐私和安全性 → 麦克风（包括桌面应用访问）中启用麦克风访问。\",\n      \"macos\": \"在系统设置 → 隐私与安全性 → 麦克风中授予麦克风访问权限。\",\n      \"linux\": \"在系统的声音或隐私设置中授予麦克风访问权限。\"\n    },\n    \"recordingFailed\": \"录音启动失败: {{error}}\",\n    \"modelLoadFailed\": \"加载模型失败: {{model}}\",\n    \"modelLoadFailedUnknown\": \"未知模型\"\n  },\n  \"appLanguage\": {\n    \"title\": \"应用语言\",\n    \"description\": \"更改 Handy 界面的语言\"\n  },\n  \"overlay\": {\n    \"transcribing\": \"正在转录...\",\n    \"processing\": \"处理中...\"\n  }\n}\n"
  },
  {
    "path": "src/i18n/locales/zh-TW/translation.json",
    "content": "{\n  \"tray\": {\n    \"settings\": \"設定...\",\n    \"checkUpdates\": \"檢查更新...\",\n    \"copyLastTranscript\": \"複製最新轉錄\",\n    \"unloadModel\": \"卸載模型\",\n    \"model\": \"模型\",\n    \"quit\": \"結束\",\n    \"cancel\": \"取消\"\n  },\n  \"sidebar\": {\n    \"general\": \"一般\",\n    \"models\": \"模型\",\n    \"advanced\": \"進階\",\n    \"postProcessing\": \"後處理\",\n    \"history\": \"歷史紀錄\",\n    \"debug\": \"偵錯\",\n    \"about\": \"關於\"\n  },\n  \"onboarding\": {\n    \"subtitle\": \"首先，請選擇一種模型\",\n    \"recommended\": \"推薦\",\n    \"download\": \"下載\",\n    \"downloading\": \"下載中...\",\n    \"customModelDescription\": \"官方不支援\",\n    \"downloadFailed\": \"下載失敗，請重試\",\n    \"modelCard\": {\n      \"accuracy\": \"準確度\",\n      \"speed\": \"速度\"\n    },\n    \"models\": {\n      \"small\": {\n        \"name\": \"Whisper Small\",\n        \"description\": \"快速且相當準確\"\n      },\n      \"medium\": {\n        \"name\": \"Whisper Medium\",\n        \"description\": \"準確度高，速度適中\"\n      },\n      \"turbo\": {\n        \"name\": \"Whisper Turbo\",\n        \"description\": \"準確度和速度均衡\"\n      },\n      \"large\": {\n        \"name\": \"Whisper Large\",\n        \"description\": \"準確度高，但速度較慢\"\n      },\n      \"parakeet-tdt-0.6b-v2\": {\n        \"name\": \"Parakeet V2\",\n        \"description\": \"僅支援英語，英語使用者的最佳模型\"\n      },\n      \"parakeet-tdt-0.6b-v3\": {\n        \"name\": \"Parakeet V3\",\n        \"description\": \"快速且準確\"\n      },\n      \"moonshine-base\": {\n        \"name\": \"Moonshine Base\",\n        \"description\": \"速度極快，僅支援英語，擅長處理各種口音\"\n      },\n      \"moonshine-tiny-streaming-en\": {\n        \"name\": \"Moonshine V2 Tiny\",\n        \"description\": \"超快速，僅支援英語\"\n      },\n      \"moonshine-small-streaming-en\": {\n        \"name\": \"Moonshine V2 Small\",\n        \"description\": \"快速，僅支援英語。速度與準確度的良好平衡。\"\n      },\n      \"moonshine-medium-streaming-en\": {\n        \"name\": \"Moonshine V2 Medium\",\n        \"description\": \"僅支援英語。高品質。\"\n      },\n      \"breeze-asr\": {\n        \"name\": \"Breeze ASR\",\n        \"description\": \"針對臺灣華語最佳化。支援語碼轉換。\"\n      },\n      \"sense-voice-int8\": {\n        \"name\": \"SenseVoice\",\n        \"description\": \"速度極快。支援中文、英語、日語、韓語、粵語。\"\n      },\n      \"gigaam-v3-e2e-ctc\": {\n        \"name\": \"GigaAM v3\",\n        \"description\": \"俄語語音辨識。快速且準確。\"\n      },\n      \"canary-180m-flash\": {\n        \"name\": \"Canary 180M Flash\",\n        \"description\": \"速度極快。支援英語、德語、西班牙語、法語。支援翻譯。\"\n      },\n      \"canary-1b-v2\": {\n        \"name\": \"Canary 1B v2\",\n        \"description\": \"準確的多語言模型。25種歐洲語言。支援翻譯。\"\n      }\n    },\n    \"errors\": {\n      \"loadModels\": \"無法載入可用模型\",\n      \"downloadModel\": \"模型下載失敗: {{error}}\"\n    },\n    \"permissions\": {\n      \"title\": \"需要權限\",\n      \"description\": \"Handy 需要一些權限才能正常運作\",\n      \"microphone\": {\n        \"title\": \"麥克風存取\",\n        \"description\": \"需要聽取您的語音進行轉錄\"\n      },\n      \"accessibility\": {\n        \"title\": \"輔助使用存取\",\n        \"description\": \"需要將轉錄文字輸入到您的應用程式中\"\n      },\n      \"grant\": \"授予權限\",\n      \"granted\": \"已授予\",\n      \"waiting\": \"等待中...\",\n      \"allGranted\": \"全部完成！\",\n      \"errors\": {\n        \"checkFailed\": \"檢查權限失敗，請重試\",\n        \"requestFailed\": \"請求權限失敗，請重試\"\n      }\n    }\n  },\n  \"modelSelector\": {\n    \"custom\": \"自訂\",\n    \"active\": \"使用中\",\n    \"switching\": \"切換中...\",\n    \"noModelsAvailable\": \"沒有可用的模型\",\n    \"extracting\": \"正在解壓 {{modelName}}...\",\n    \"extractingMultiple\": \"正在解壓 {{count}} 個模型...\",\n    \"extractingGeneric\": \"解壓中...\",\n    \"downloading\": \"下載中 {{percentage}}%\",\n    \"downloadingMultiple\": \"正在下載 {{count}} 個模型...\",\n    \"modelReady\": \"模型已就緒\",\n    \"loading\": \"正在載入 {{modelName}}...\",\n    \"loadingGeneric\": \"載入中...\",\n    \"modelError\": \"模型錯誤\",\n    \"modelUnloaded\": \"模型已卸載\",\n    \"noModelDownloadRequired\": \"無模型 - 需要下載\",\n    \"deleteModel\": \"刪除 {{modelName}}\",\n    \"downloadSpeed\": \"{{speed}} MB/s\",\n    \"cancel\": \"取消\",\n    \"cancelDownload\": \"取消下載\",\n    \"capabilities\": {\n      \"languageSelection\": \"支援多種輸入語言\",\n      \"singleLanguage\": \"僅支援此語言\",\n      \"multiLanguage\": \"多語言\",\n      \"languageOnly\": \"僅 {{language}}\",\n      \"translation\": \"可翻譯為英語\",\n      \"translate\": \"翻譯為英語\"\n    }\n  },\n  \"settings\": {\n    \"modelSettings\": {\n      \"title\": \"{{model}} 設定\",\n      \"noSettingsNeeded\": \"此模型自動運行，無需設定\"\n    },\n    \"general\": {\n      \"title\": \"一般\",\n      \"shortcut\": {\n        \"title\": \"Handy 快捷鍵\",\n        \"description\": \"設定啟動語音轉文字錄製的鍵盤快捷鍵\",\n        \"loading\": \"載入快捷鍵中...\",\n        \"none\": \"未設定快捷鍵\",\n        \"notFound\": \"未找到快捷鍵\",\n        \"pressKeys\": \"請按鍵...\",\n        \"bindings\": {\n          \"transcribe\": {\n            \"name\": \"轉錄快捷鍵\",\n            \"description\": \"用於錄製和轉錄語音的鍵盤快捷鍵\"\n          },\n          \"cancel\": {\n            \"name\": \"取消快捷鍵\",\n            \"description\": \"用於取消目前錄製的鍵盤快捷鍵\"\n          },\n          \"transcribe_with_post_process\": {\n            \"name\": \"後處理快捷鍵\",\n            \"description\": \"可選：專用快捷鍵，使用時一律對轉錄結果套用 AI 後處理\"\n          }\n        },\n        \"errors\": {\n          \"restore\": \"無法還原原始快捷鍵\",\n          \"set\": \"無法設定快捷鍵: {{error}}\",\n          \"reset\": \"無法將快捷鍵重設為原始值\"\n        }\n      },\n      \"language\": {\n        \"title\": \"語言\",\n        \"description\": \"選擇語音識別的語言。選擇自動將自動判定語言，選擇特定語言可以提高該語言的準確度\",\n        \"descriptionUnsupported\": \"Parakeet 模型會自動偵測語言，無需手動選擇\",\n        \"searchPlaceholder\": \"搜尋語言...\",\n        \"noResults\": \"未找到語言\",\n        \"auto\": \"自動\"\n      },\n      \"pushToTalk\": {\n        \"label\": \"按住說話\",\n        \"description\": \"按住錄製，放開停止\"\n      }\n    },\n    \"models\": {\n      \"title\": \"轉錄模型\",\n      \"description\": \"選擇轉錄模型或下載其他模型。不同模型提供不同的準確度和速度\",\n      \"yourModels\": \"已下載的模型\",\n      \"availableModels\": \"可供下載\",\n      \"downloaded\": \"已下載\",\n      \"available\": \"可下載\",\n      \"deleteConfirm\": \"確定要刪除 {{modelName}} 嗎？刪除後須重新下載才能使用\",\n      \"deleteActiveConfirm\": \"{{modelName}} 是您目前使用的模型。刪除後將停止轉錄，直到您選擇新的模型。確定要刪除嗎？\",\n      \"deleteTitle\": \"刪除模型\",\n      \"filters\": {\n        \"all\": \"全部\",\n        \"multiLanguage\": \"多語言\",\n        \"translation\": \"翻譯\",\n        \"allLanguages\": \"所有語言\"\n      },\n      \"noModelsMatch\": \"沒有符合此篩選條件的模型\"\n    },\n    \"sound\": {\n      \"title\": \"聲音\",\n      \"microphone\": {\n        \"title\": \"麥克風\",\n        \"description\": \"選擇您偏好的麥克風裝置\",\n        \"placeholder\": \"選擇麥克風...\",\n        \"loading\": \"載入中...\"\n      },\n      \"audioFeedback\": {\n        \"label\": \"聲音回饋\",\n        \"description\": \"錄製開始和停止時播放聲音\"\n      },\n      \"outputDevice\": {\n        \"title\": \"輸出裝置\",\n        \"description\": \"選擇用於聲音回饋的音訊輸出裝置\",\n        \"placeholder\": \"選擇輸出裝置...\",\n        \"loading\": \"載入中...\"\n      },\n      \"volume\": {\n        \"title\": \"音量\",\n        \"description\": \"調整聲音回饋的音量\"\n      }\n    },\n    \"advanced\": {\n      \"title\": \"進階\",\n      \"groups\": {\n        \"app\": \"應用程式\",\n        \"output\": \"輸出\",\n        \"transcription\": \"轉錄\",\n        \"history\": \"歷史\",\n        \"experimental\": \"實驗性\"\n      },\n      \"experimentalToggle\": {\n        \"label\": \"實驗性功能\",\n        \"description\": \"啟用仍在開發中的實驗性功能\"\n      },\n      \"lazyStreamClose\": {\n        \"label\": \"在轉錄之間保持麥克風開啟\",\n        \"description\": \"錄音停止後保持麥克風串流開啟30秒，減少連續轉錄時的延遲。啟用時可能會降低藍牙音訊品質。\"\n      },\n      \"acceleration\": {\n        \"whisper\": {\n          \"title\": \"Whisper 加速\",\n          \"description\": \"Whisper 模型的硬體加速。自動模式在可用時使用 GPU（macOS 上使用 Metal，Windows/Linux 上使用 Vulkan）。\"\n        },\n        \"ort\": {\n          \"title\": \"ONNX 加速\",\n          \"description\": \"ONNX 模型的硬體加速（Parakeet、Canary、Moonshine 等）。Windows 上的 DirectML 為實驗性功能。模型可能無法正常轉錄。\"\n        }\n      },\n      \"startHidden\": {\n        \"label\": \"隱藏啟動\",\n        \"description\": \"啟動至系統匣而不開啟視窗\"\n      },\n      \"autostart\": {\n        \"label\": \"開機啟動\",\n        \"description\": \"登入電腦時自動啟動 Handy\"\n      },\n      \"showTrayIcon\": {\n        \"label\": \"顯示系統匣圖示\",\n        \"description\": \"在系統匣中顯示 Handy 圖示\"\n      },\n      \"overlay\": {\n        \"title\": \"懸浮窗位置\",\n        \"description\": \"在錄製和轉錄期間顯示視覺回饋懸浮窗。在 Linux 上建議選擇「無」\",\n        \"options\": {\n          \"none\": \"無\",\n          \"bottom\": \"底部\",\n          \"top\": \"頂部\"\n        }\n      },\n      \"pasteMethod\": {\n        \"title\": \"貼上方式\",\n        \"description\": \"選擇文字插入方式。直接：透過系統輸入模擬打字。無：跳過貼上，僅更新歷史紀錄/剪貼簿\",\n        \"options\": {\n          \"clipboard\": \"剪貼簿 ({{modifier}}+V)\",\n          \"clipboardCtrlShiftV\": \"剪貼簿 (Ctrl+Shift+V)\",\n          \"clipboardShiftInsert\": \"剪貼簿 (Shift+Insert)\",\n          \"direct\": \"直接\",\n          \"none\": \"無\",\n          \"externalScript\": \"外部腳本\"\n        },\n        \"externalScriptPlaceholder\": \"/path/to/your/script.sh\"\n      },\n      \"typingTool\": {\n        \"title\": \"輸入工具\",\n        \"description\": \"選擇在直接貼上方式下使用的 Linux 輸入工具。選擇自動會自動偵測並使用系統中可用的最佳工具\",\n        \"options\": {\n          \"auto\": \"自動（推薦）\"\n        }\n      },\n      \"clipboardHandling\": {\n        \"title\": \"剪貼簿處理\",\n        \"description\": \"不修改剪貼簿將在轉錄後保留目前剪貼簿內容。複製到剪貼簿會在貼上後將轉錄結果留在剪貼簿中\",\n        \"options\": {\n          \"dontModify\": \"不修改剪貼簿\",\n          \"copyToClipboard\": \"複製到剪貼簿\"\n        }\n      },\n      \"autoSubmit\": {\n        \"title\": \"自動送出\",\n        \"description\": \"插入文字後自動發送指定的按鍵組合。macOS 為 Cmd+Enter，Windows/Linux 為 Super+Enter\",\n        \"options\": {\n          \"off\": \"關閉\",\n          \"enter\": \"Enter\",\n          \"cmdEnter\": \"Cmd+Enter\",\n          \"superEnter\": \"Super+Enter\",\n          \"ctrlEnter\": \"Ctrl+Enter\"\n        }\n      },\n      \"translateToEnglish\": {\n        \"label\": \"翻譯為英語\",\n        \"description\": \"在轉錄過程中自動將其他語言的語音翻譯為英語\",\n        \"descriptionUnsupported\": \"{{model}} 模型不支援翻譯功能\"\n      },\n      \"modelUnload\": {\n        \"title\": \"卸載模型\",\n        \"description\": \"當模型在指定時間內未使用時自動釋放 GPU/CPU 記憶體\",\n        \"options\": {\n          \"never\": \"從不\",\n          \"immediately\": \"立即\",\n          \"min2\": \"2 分鐘後\",\n          \"min5\": \"5 分鐘後\",\n          \"min10\": \"10 分鐘後\",\n          \"min15\": \"15 分鐘後\",\n          \"hour1\": \"1 小時後\",\n          \"sec15\": \"15 秒後（偵錯）\"\n        }\n      },\n      \"customWords\": {\n        \"title\": \"自訂詞彙\",\n        \"description\": \"新增經常被誤聽或拼寫錯誤的詞彙。系統會自動將發音相似的詞彙修正為您列表中的詞彙\",\n        \"placeholder\": \"新增詞彙\",\n        \"add\": \"新增\",\n        \"remove\": \"刪除 {{word}}\",\n        \"duplicate\": \"「{{word}}」已存在\"\n      }\n    },\n    \"postProcessing\": {\n      \"title\": \"後處理\",\n      \"hotkey\": {\n        \"title\": \"快捷鍵\"\n      },\n      \"api\": {\n        \"title\": \"API（相容 OpenAI）\",\n        \"provider\": {\n          \"title\": \"供應商\",\n          \"description\": \"選擇一個相容 OpenAI 的供應商\"\n        },\n        \"appleIntelligence\": {\n          \"title\": \"Apple Intelligence\",\n          \"description\": \"完全在裝置上運行，無需 API 金鑰或網路存取\",\n          \"requirements\": \"需要執行 macOS Tahoe（26.0）或更高版本的 Apple Silicon Mac。必須在系統設定中啟用 Apple Intelligence\",\n          \"unavailable\": \"Apple Intelligence 在此裝置上不可用。需要執行 macOS Tahoe（26.0）或更高版本的 Apple Silicon Mac，並在系統設定中啟用 Apple Intelligence\"\n        },\n        \"baseUrl\": {\n          \"title\": \"基礎網址\",\n          \"description\": \"所選供應商的 API 基礎網址，僅自訂供應商可編輯\",\n          \"placeholder\": \"https://api.openai.com/v1\"\n        },\n        \"apiKey\": {\n          \"title\": \"API 金鑰\",\n          \"description\": \"所選供應商的 API 金鑰\",\n          \"placeholder\": \"sk-...\"\n        },\n        \"model\": {\n          \"title\": \"模型\",\n          \"descriptionApple\": \"可設定 token 數量上限，或保持裝置預設值\",\n          \"descriptionCustom\": \"輸入自訂端點所需的模型 ID\",\n          \"descriptionDefault\": \"從所選供應商的可用模型中選擇\",\n          \"placeholderApple\": \"Apple Intelligence\",\n          \"placeholderWithOptions\": \"搜尋或選擇模型\",\n          \"placeholderNoOptions\": \"輸入模型名稱\",\n          \"refreshModels\": \"重新整理模型\"\n        }\n      },\n      \"prompts\": {\n        \"title\": \"提示詞\",\n        \"selectedPrompt\": {\n          \"title\": \"已選提示詞\",\n          \"description\": \"選擇用於最佳化轉錄的範本或建立新範本。在提示詞文字中使用 ${output} 來引用轉錄結果\"\n        },\n        \"noPrompts\": \"沒有可用的提示詞\",\n        \"selectPrompt\": \"選擇提示詞\",\n        \"createNew\": \"建立新提示詞\",\n        \"promptLabel\": \"提示詞名稱\",\n        \"promptLabelPlaceholder\": \"輸入提示詞名稱\",\n        \"promptInstructions\": \"提示詞指令\",\n        \"promptInstructionsPlaceholder\": \"撰寫轉錄後要執行的指令。範例：改善以下文字的語法和清晰度: ${output}\",\n        \"promptTip\": \"提示：使用 <code>${output}</code> 將轉錄文字插入到您的提示詞中\",\n        \"updatePrompt\": \"更新提示詞\",\n        \"deletePrompt\": \"刪除提示詞\",\n        \"createPrompt\": \"建立提示詞\",\n        \"cancel\": \"取消\",\n        \"selectToEdit\": \"選擇上方的提示詞以檢視和編輯其詳細資訊\",\n        \"createFirst\": \"點選上方的「建立新提示詞」來建立您的第一個後處理提示詞\"\n      }\n    },\n    \"history\": {\n      \"title\": \"歷史紀錄\",\n      \"openFolder\": \"開啟錄音資料夾\",\n      \"loading\": \"載入歷史紀錄中...\",\n      \"empty\": \"還沒有轉錄紀錄。開始錄製以建立您的歷史紀錄！\",\n      \"copyToClipboard\": \"複製轉錄到剪貼簿\",\n      \"save\": \"儲存轉錄\",\n      \"unsave\": \"從已儲存中移除\",\n      \"delete\": \"刪除條目\",\n      \"deleteError\": \"刪除條目失敗，請重試\"\n    },\n    \"debug\": {\n      \"title\": \"偵錯\",\n      \"logDirectory\": {\n        \"title\": \"日誌目錄\",\n        \"description\": \"日誌檔案的儲存位置\"\n      },\n      \"logLevel\": {\n        \"title\": \"日誌等級\",\n        \"description\": \"設定日誌的詳細程度\"\n      },\n      \"updateChecks\": {\n        \"label\": \"檢查更新\",\n        \"description\": \"自動檢查 Handy 的新版本\"\n      },\n      \"soundTheme\": {\n        \"label\": \"聲音主題\",\n        \"description\": \"選擇錄製開始和停止回饋的聲音主題\"\n      },\n      \"wordCorrectionThreshold\": {\n        \"title\": \"詞彙修正閾值\",\n        \"description\": \"自訂詞彙修正的靈敏度\"\n      },\n      \"historyLimit\": {\n        \"title\": \"歷史紀錄上限\",\n        \"description\": \"歷史紀錄的最大保留筆數\",\n        \"entries\": \"筆\"\n      },\n      \"recordingRetention\": {\n        \"title\": \"自動刪除錄音\",\n        \"description\": \"自動刪除舊錄音以節省空間\",\n        \"never\": \"從不\",\n        \"preserveLimit\": \"保留最新 {{count}} 筆\",\n        \"days3\": \"3 天後\",\n        \"weeks2\": \"2 週後\",\n        \"months3\": \"3 個月後\",\n        \"placeholder\": \"選擇保留期限...\"\n      },\n      \"alwaysOnMicrophone\": {\n        \"label\": \"麥克風常開\",\n        \"description\": \"保持麥克風啟用以獲得更快的回應\"\n      },\n      \"clamshellMicrophone\": {\n        \"title\": \"闔蓋麥克風\",\n        \"description\": \"筆記型電腦闔蓋時使用的麥克風\"\n      },\n      \"postProcessingToggle\": {\n        \"label\": \"後處理\",\n        \"description\": \"啟用轉錄後的 AI 文字最佳化\"\n      },\n      \"muteWhileRecording\": {\n        \"label\": \"錄製時靜音\",\n        \"description\": \"錄製期間靜音系統音訊\"\n      },\n      \"appendTrailingSpace\": {\n        \"label\": \"附加尾部空格\",\n        \"description\": \"在貼上的轉錄後新增空格\"\n      },\n      \"keyboardImplementation\": {\n        \"title\": \"鍵盤實作\",\n        \"description\": \"選擇鍵盤快捷鍵的後端\",\n        \"bindingsReset\": \"鍵盤快捷鍵不相容，已重設為預設值\"\n      },\n      \"paths\": {\n        \"appData\": \"應用程式資料:\",\n        \"models\": \"模型:\",\n        \"settings\": \"設定:\"\n      },\n      \"pasteDelay\": {\n        \"title\": \"貼上延遲\",\n        \"description\": \"發送貼上按鍵前的延遲（毫秒）。如果貼上了錯誤的文字，請增加此值\"\n      },\n      \"recordingBuffer\": {\n        \"title\": \"額外錄音緩衝\",\n        \"description\": \"放開按鍵後繼續錄音的額外時間（毫秒），以捕捉尾音。0 = 無額外緩衝。\"\n      }\n    },\n    \"about\": {\n      \"title\": \"關於\",\n      \"version\": {\n        \"title\": \"版本\",\n        \"description\": \"Handy 的目前版本\"\n      },\n      \"appDataDirectory\": {\n        \"title\": \"應用程式資料目錄\",\n        \"description\": \"Handy 儲存資料的位置\"\n      },\n      \"sourceCode\": {\n        \"title\": \"原始碼\",\n        \"description\": \"檢視原始碼並參與貢獻\",\n        \"button\": \"在 GitHub 上檢視\"\n      },\n      \"supportDevelopment\": {\n        \"title\": \"支持開發\",\n        \"description\": \"幫助我們繼續開發 Handy\",\n        \"button\": \"捐贈\"\n      },\n      \"acknowledgments\": {\n        \"title\": \"致謝\",\n        \"whisper\": {\n          \"title\": \"Whisper.cpp\",\n          \"description\": \"針對 OpenAI Whisper 自動語音識別模型的高效能推理引擎\",\n          \"details\": \"Handy 使用 Whisper.cpp 進行快速的本機語音轉文字處理。感謝 Georgi Gerganov 和貢獻者們的傑出工作\"\n        }\n      }\n    }\n  },\n  \"footer\": {\n    \"downloadingModel\": \"正在下載 {{model}}...\",\n    \"checkingUpdates\": \"檢查更新中...\",\n    \"updateAvailable\": \"有可用更新: {{version}}\",\n    \"updateAvailableShort\": \"有可用更新\",\n    \"upToDate\": \"已是最新版本\",\n    \"downloadUpdate\": \"下載更新\",\n    \"restart\": \"重新啟動\",\n    \"updateCheckingDisabled\": \"更新檢查已停用\",\n    \"downloading\": \"下載中... {{progress}}%\",\n    \"installing\": \"安裝中...\",\n    \"preparing\": \"準備中...\",\n    \"checkForUpdates\": \"檢查更新\"\n  },\n  \"common\": {\n    \"loading\": \"載入中...\",\n    \"save\": \"儲存\",\n    \"cancel\": \"取消\",\n    \"reset\": \"重設\",\n    \"add\": \"新增\",\n    \"remove\": \"移除\",\n    \"delete\": \"刪除\",\n    \"edit\": \"編輯\",\n    \"create\": \"建立\",\n    \"update\": \"更新\",\n    \"close\": \"關閉\",\n    \"open\": \"開啟\",\n    \"default\": \"預設\",\n    \"enabled\": \"已啟用\",\n    \"disabled\": \"已停用\",\n    \"on\": \"開\",\n    \"off\": \"關\",\n    \"yes\": \"是\",\n    \"no\": \"否\",\n    \"noOptionsFound\": \"未找到選項\"\n  },\n  \"accessibility\": {\n    \"permissionsRequired\": \"需要輔助使用權限\",\n    \"permissionsDescription\": \"Handy 需要輔助使用權限才能輸入轉錄的文字\",\n    \"openSettings\": \"開啟系統設定\",\n    \"dismiss\": \"關閉\"\n  },\n  \"errors\": {\n    \"loadDirectory\": \"載入目錄時發生錯誤: {{error}}\",\n    \"micPermissionDeniedTitle\": \"麥克風存取被拒絕\",\n    \"micPermissionDenied\": {\n      \"generic\": \"作業系統拒絕了麥克風存取。請在系統設定中授予麥克風權限。\",\n      \"windows\": \"在設定 → 隱私與安全性 → 麥克風（包括桌面應用程式存取）中啟用麥克風存取。\",\n      \"macos\": \"在系統設定 → 隱私與安全性 → 麥克風中授予麥克風存取權限。\",\n      \"linux\": \"在系統的聲音或隱私設定中授予麥克風存取權限。\"\n    },\n    \"recordingFailed\": \"錄音啟動失敗: {{error}}\",\n    \"modelLoadFailed\": \"載入模型失敗: {{model}}\",\n    \"modelLoadFailedUnknown\": \"未知模型\"\n  },\n  \"appLanguage\": {\n    \"title\": \"應用程式語言\",\n    \"description\": \"變更 Handy 介面的語言\"\n  },\n  \"overlay\": {\n    \"transcribing\": \"正在轉錄...\",\n    \"processing\": \"處理中...\"\n  }\n}\n"
  },
  {
    "path": "src/lib/constants/languages.ts",
    "content": "export interface Language {\n  value: string;\n  label: string;\n}\n\nexport const LANGUAGES: Language[] = [\n  { value: \"auto\", label: \"Auto Detect\" },\n  { value: \"en\", label: \"English\" },\n  { value: \"zh-Hans\", label: \"Simplified Chinese\" },\n  { value: \"zh-Hant\", label: \"Traditional Chinese\" },\n  { value: \"yue\", label: \"Cantonese\" },\n  { value: \"de\", label: \"German\" },\n  { value: \"es\", label: \"Spanish\" },\n  { value: \"ru\", label: \"Russian\" },\n  { value: \"ko\", label: \"Korean\" },\n  { value: \"fr\", label: \"French\" },\n  { value: \"ja\", label: \"Japanese\" },\n  { value: \"pt\", label: \"Portuguese\" },\n  { value: \"tr\", label: \"Turkish\" },\n  { value: \"pl\", label: \"Polish\" },\n  { value: \"ca\", label: \"Catalan\" },\n  { value: \"nl\", label: \"Dutch\" },\n  { value: \"ar\", label: \"Arabic\" },\n  { value: \"sv\", label: \"Swedish\" },\n  { value: \"it\", label: \"Italian\" },\n  { value: \"id\", label: \"Indonesian\" },\n  { value: \"hi\", label: \"Hindi\" },\n  { value: \"fi\", label: \"Finnish\" },\n  { value: \"vi\", label: \"Vietnamese\" },\n  { value: \"he\", label: \"Hebrew\" },\n  { value: \"uk\", label: \"Ukrainian\" },\n  { value: \"el\", label: \"Greek\" },\n  { value: \"ms\", label: \"Malay\" },\n  { value: \"cs\", label: \"Czech\" },\n  { value: \"ro\", label: \"Romanian\" },\n  { value: \"da\", label: \"Danish\" },\n  { value: \"hu\", label: \"Hungarian\" },\n  { value: \"ta\", label: \"Tamil\" },\n  { value: \"no\", label: \"Norwegian\" },\n  { value: \"th\", label: \"Thai\" },\n  { value: \"ur\", label: \"Urdu\" },\n  { value: \"hr\", label: \"Croatian\" },\n  { value: \"bg\", label: \"Bulgarian\" },\n  { value: \"lt\", label: \"Lithuanian\" },\n  { value: \"la\", label: \"Latin\" },\n  { value: \"mi\", label: \"Maori\" },\n  { value: \"ml\", label: \"Malayalam\" },\n  { value: \"cy\", label: \"Welsh\" },\n  { value: \"sk\", label: \"Slovak\" },\n  { value: \"te\", label: \"Telugu\" },\n  { value: \"fa\", label: \"Persian\" },\n  { value: \"lv\", label: \"Latvian\" },\n  { value: \"bn\", label: \"Bengali\" },\n  { value: \"sr\", label: \"Serbian\" },\n  { value: \"az\", label: \"Azerbaijani\" },\n  { value: \"sl\", label: \"Slovenian\" },\n  { value: \"kn\", label: \"Kannada\" },\n  { value: \"et\", label: \"Estonian\" },\n  { value: \"mk\", label: \"Macedonian\" },\n  { value: \"br\", label: \"Breton\" },\n  { value: \"eu\", label: \"Basque\" },\n  { value: \"is\", label: \"Icelandic\" },\n  { value: \"hy\", label: \"Armenian\" },\n  { value: \"ne\", label: \"Nepali\" },\n  { value: \"mn\", label: \"Mongolian\" },\n  { value: \"bs\", label: \"Bosnian\" },\n  { value: \"kk\", label: \"Kazakh\" },\n  { value: \"sq\", label: \"Albanian\" },\n  { value: \"sw\", label: \"Swahili\" },\n  { value: \"gl\", label: \"Galician\" },\n  { value: \"mr\", label: \"Marathi\" },\n  { value: \"pa\", label: \"Punjabi\" },\n  { value: \"si\", label: \"Sinhala\" },\n  { value: \"km\", label: \"Khmer\" },\n  { value: \"sn\", label: \"Shona\" },\n  { value: \"yo\", label: \"Yoruba\" },\n  { value: \"so\", label: \"Somali\" },\n  { value: \"af\", label: \"Afrikaans\" },\n  { value: \"oc\", label: \"Occitan\" },\n  { value: \"ka\", label: \"Georgian\" },\n  { value: \"be\", label: \"Belarusian\" },\n  { value: \"tg\", label: \"Tajik\" },\n  { value: \"sd\", label: \"Sindhi\" },\n  { value: \"gu\", label: \"Gujarati\" },\n  { value: \"am\", label: \"Amharic\" },\n  { value: \"yi\", label: \"Yiddish\" },\n  { value: \"lo\", label: \"Lao\" },\n  { value: \"uz\", label: \"Uzbek\" },\n  { value: \"fo\", label: \"Faroese\" },\n  { value: \"ht\", label: \"Haitian Creole\" },\n  { value: \"ps\", label: \"Pashto\" },\n  { value: \"tk\", label: \"Turkmen\" },\n  { value: \"nn\", label: \"Nynorsk\" },\n  { value: \"mt\", label: \"Maltese\" },\n  { value: \"sa\", label: \"Sanskrit\" },\n  { value: \"lb\", label: \"Luxembourgish\" },\n  { value: \"my\", label: \"Myanmar\" },\n  { value: \"bo\", label: \"Tibetan\" },\n  { value: \"tl\", label: \"Tagalog\" },\n  { value: \"mg\", label: \"Malagasy\" },\n  { value: \"as\", label: \"Assamese\" },\n  { value: \"tt\", label: \"Tatar\" },\n  { value: \"haw\", label: \"Hawaiian\" },\n  { value: \"ln\", label: \"Lingala\" },\n  { value: \"ha\", label: \"Hausa\" },\n  { value: \"ba\", label: \"Bashkir\" },\n  { value: \"jw\", label: \"Javanese\" },\n  { value: \"su\", label: \"Sundanese\" },\n];\n"
  },
  {
    "path": "src/lib/types/events.ts",
    "content": "export interface ModelStateEvent {\n  event_type: string;\n  model_id?: string;\n  model_name?: string;\n  error?: string;\n}\n\nexport interface RecordingErrorEvent {\n  error_type: string;\n  detail?: string;\n}\n"
  },
  {
    "path": "src/lib/utils/format.ts",
    "content": "export const formatModelSize = (sizeMb: number | null | undefined): string => {\n  if (!sizeMb || !Number.isFinite(sizeMb) || sizeMb <= 0) {\n    return \"Unknown size\";\n  }\n\n  if (sizeMb >= 1024) {\n    const sizeGb = sizeMb / 1024;\n    const formatter = new Intl.NumberFormat(undefined, {\n      minimumFractionDigits: sizeGb >= 10 ? 0 : 1,\n      maximumFractionDigits: sizeGb >= 10 ? 0 : 1,\n    });\n    return `${formatter.format(sizeGb)} GB`;\n  }\n\n  const formatter = new Intl.NumberFormat(undefined, {\n    minimumFractionDigits: sizeMb >= 100 ? 0 : 1,\n    maximumFractionDigits: sizeMb >= 100 ? 0 : 1,\n  });\n\n  return `${formatter.format(sizeMb)} MB`;\n};\n"
  },
  {
    "path": "src/lib/utils/keyboard.ts",
    "content": "/**\n * Keyboard utility functions for handling keyboard events\n */\n\nexport type OSType = \"macos\" | \"windows\" | \"linux\" | \"unknown\";\n\n/**\n * Extract a consistent key name from a KeyboardEvent\n * This function provides cross-platform keyboard event handling\n * and returns key names appropriate for the target operating system\n */\nexport const getKeyName = (\n  e: KeyboardEvent,\n  osType: OSType = \"unknown\",\n): string => {\n  // Handle special cases first\n  if (e.code) {\n    const code = e.code;\n\n    // Handle function keys (F1-F24)\n    if (code.match(/^F\\d+$/)) {\n      return code.toLowerCase(); // F1, F2, ..., F14, F15, etc.\n    }\n\n    // Handle regular letter keys (KeyA -> a)\n    if (code.match(/^Key[A-Z]$/)) {\n      return code.replace(\"Key\", \"\").toLowerCase();\n    }\n\n    // Handle digit keys (Digit0 -> 0)\n    if (code.match(/^Digit\\d$/)) {\n      return code.replace(\"Digit\", \"\");\n    }\n\n    // Handle numpad digit keys (Numpad0 -> numpad 0)\n    if (code.match(/^Numpad\\d$/)) {\n      return code.replace(\"Numpad\", \"numpad \").toLowerCase();\n    }\n\n    // Handle modifier keys - OS-specific naming\n    const getModifierName = (baseModifier: string): string => {\n      switch (baseModifier) {\n        case \"shift\":\n          return \"shift\";\n        case \"ctrl\":\n          return osType === \"macos\" ? \"ctrl\" : \"ctrl\";\n        case \"alt\":\n          return osType === \"macos\" ? \"option\" : \"alt\";\n        case \"meta\":\n          // Windows key on Windows/Linux, Command key on Mac\n          if (osType === \"macos\") return \"command\";\n          return \"super\";\n        default:\n          return baseModifier;\n      }\n    };\n\n    const modifierMap: Record<string, string> = {\n      ShiftLeft: getModifierName(\"shift\"),\n      ShiftRight: getModifierName(\"shift\"),\n      ControlLeft: getModifierName(\"ctrl\"),\n      ControlRight: getModifierName(\"ctrl\"),\n      AltLeft: getModifierName(\"alt\"),\n      AltRight: getModifierName(\"alt\"),\n      MetaLeft: getModifierName(\"meta\"),\n      MetaRight: getModifierName(\"meta\"),\n      OSLeft: getModifierName(\"meta\"),\n      OSRight: getModifierName(\"meta\"),\n      CapsLock: \"caps lock\",\n      Tab: \"tab\",\n      Enter: \"enter\",\n      Space: \"space\",\n      Backspace: \"backspace\",\n      Delete: \"delete\",\n      Escape: \"esc\",\n      ArrowUp: \"up\",\n      ArrowDown: \"down\",\n      ArrowLeft: \"left\",\n      ArrowRight: \"right\",\n      Home: \"home\",\n      End: \"end\",\n      PageUp: \"page up\",\n      PageDown: \"page down\",\n      Insert: \"insert\",\n      PrintScreen: \"print screen\",\n      ScrollLock: \"scroll lock\",\n      Pause: \"pause\",\n      ContextMenu: \"menu\",\n      NumpadMultiply: \"numpad *\",\n      NumpadAdd: \"numpad +\",\n      NumpadSubtract: \"numpad -\",\n      NumpadDecimal: \"numpad .\",\n      NumpadDivide: \"numpad /\",\n      NumLock: \"num lock\",\n    };\n\n    if (modifierMap[code]) {\n      return modifierMap[code];\n    }\n\n    // Handle punctuation and special characters\n    const punctuationMap: Record<string, string> = {\n      Semicolon: \";\",\n      Equal: \"=\",\n      Comma: \",\",\n      Minus: \"-\",\n      Period: \".\",\n      Slash: \"/\",\n      Backquote: \"`\",\n      BracketLeft: \"[\",\n      Backslash: \"\\\\\",\n      BracketRight: \"]\",\n      Quote: \"'\",\n    };\n\n    if (punctuationMap[code]) {\n      return punctuationMap[code];\n    }\n\n    // For any other codes, try to convert to a reasonable format\n    return code.toLowerCase().replace(/([a-z])([A-Z])/g, \"$1 $2\");\n  }\n\n  // Fallback to e.key if e.code is not available\n  if (e.key) {\n    const key = e.key;\n\n    // Handle special key names with OS-specific formatting\n    const keyMap: Record<string, string> = {\n      Control: osType === \"macos\" ? \"ctrl\" : \"ctrl\",\n      Alt: osType === \"macos\" ? \"option\" : \"alt\",\n      Shift: \"shift\",\n      Meta:\n        osType === \"macos\" ? \"command\" : osType === \"windows\" ? \"win\" : \"super\",\n      OS:\n        osType === \"macos\" ? \"command\" : osType === \"windows\" ? \"win\" : \"super\",\n      CapsLock: \"caps lock\",\n      ArrowUp: \"up\",\n      ArrowDown: \"down\",\n      ArrowLeft: \"left\",\n      ArrowRight: \"right\",\n      Escape: \"esc\",\n      \" \": \"space\",\n    };\n\n    if (keyMap[key]) {\n      return keyMap[key];\n    }\n\n    return key.toLowerCase();\n  }\n\n  // Last resort fallback\n  return `unknown-${e.keyCode || e.which || 0}`;\n};\n\n/**\n * Capitalize a key name for display (e.g. \"space\" -> \"Space\", \"f1\" -> \"F1\")\n */\nconst capitalizeKey = (key: string): string => {\n  // fn key: keep lowercase\n  if (key === \"fn\") return \"fn\";\n  // Function keys: f1 -> F1\n  if (/^f\\d+$/.test(key)) return key.toUpperCase();\n  // Single char: a -> A\n  if (key.length === 1) return key.toUpperCase();\n  // Multi-word: capitalize first letter of each word\n  return key.replace(/\\b\\w/g, (c) => c.toUpperCase());\n};\n\n/**\n * Format a single key part for display.\n * Handles _left/_right suffixes and capitalizes names.\n * e.g. \"shift_left\" -> \"Left Shift\", \"option\" -> \"Option\", \"space\" -> \"Space\"\n */\nconst formatKeyPart = (part: string): string => {\n  const trimmed = part.trim();\n  if (!trimmed) return \"\";\n\n  if (trimmed.endsWith(\"_left\")) {\n    const name = trimmed.slice(0, -5);\n    return `Left ${capitalizeKey(name)}`;\n  }\n  if (trimmed.endsWith(\"_right\")) {\n    const name = trimmed.slice(0, -6);\n    return `Right ${capitalizeKey(name)}`;\n  }\n\n  return capitalizeKey(trimmed);\n};\n\n/**\n * Get display-friendly key combination string for the current OS\n * Formats raw hotkey strings like \"option_left+shift+space\" into\n * human-readable form like \"Left Option + Shift + Space\"\n */\nexport const formatKeyCombination = (\n  combination: string,\n  _osType: OSType,\n): string => {\n  if (!combination) return \"\";\n  return combination.split(\"+\").map(formatKeyPart).join(\" + \");\n};\n\n/**\n * Normalize modifier keys to handle left/right variants\n */\nexport const normalizeKey = (key: string): string => {\n  // Handle left/right variants of modifier keys\n  if (key.startsWith(\"left \") || key.startsWith(\"right \")) {\n    const parts = key.split(\" \");\n    if (parts.length === 2) {\n      // Return just the modifier name without left/right prefix\n      return parts[1];\n    }\n  }\n  return key;\n};\n"
  },
  {
    "path": "src/lib/utils/modelTranslation.ts",
    "content": "import type { TFunction } from \"i18next\";\nimport type { ModelInfo } from \"@/bindings\";\n\n/**\n * Get the translated name for a model\n * @param model - The model info object\n * @param t - The translation function from useTranslation\n * @returns The translated model name, or the original name if no translation exists\n */\nexport function getTranslatedModelName(model: ModelInfo, t: TFunction): string {\n  const translationKey = `onboarding.models.${model.id}.name`;\n  const translated = t(translationKey, { defaultValue: \"\" });\n  return translated !== \"\" ? translated : model.name;\n}\n\n/**\n * Get the translated description for a model\n * @param model - The model info object\n * @param t - The translation function from useTranslation\n * @returns The translated model description, or the original description if no translation exists\n */\nexport function getTranslatedModelDescription(\n  model: ModelInfo,\n  t: TFunction,\n): string {\n  // Custom models use a generic translation key\n  if (model.is_custom) {\n    return t(\"onboarding.customModelDescription\");\n  }\n  const translationKey = `onboarding.models.${model.id}.description`;\n  const translated = t(translationKey, { defaultValue: \"\" });\n  return translated !== \"\" ? translated : model.description;\n}\n"
  },
  {
    "path": "src/lib/utils/rtl.ts",
    "content": "/**\n * RTL (Right-to-Left) utilities for handling text direction in the application.\n *\n * These utilities help manage RTL languages like Arabic, Hebrew, Persian, and Urdu.\n * They work with the i18n system to automatically update HTML attributes when\n * the language changes.\n */\nimport { LANGUAGE_METADATA } from \"@/i18n/languages\";\n\n/**\n * Check if a language code is RTL (Right-to-Left)\n * @param langCode - The language code (e.g., 'ar', 'en', 'he')\n * @returns true if the language is RTL, false otherwise\n */\nexport const isRTLLanguage = (langCode: string): boolean => {\n  if (!langCode) return false;\n  const code = langCode.split(\"-\")[0].toLowerCase();\n  return LANGUAGE_METADATA[code]?.direction === \"rtl\";\n};\n\n/**\n * Get the text direction ('ltr' or 'rtl') for a language\n * @param langCode - The language code (e.g., 'ar', 'en', 'he')\n * @returns 'rtl' if RTL language, 'ltr' otherwise\n */\nexport const getLanguageDirection = (langCode: string): \"ltr\" | \"rtl\" => {\n  return isRTLLanguage(langCode) ? \"rtl\" : \"ltr\";\n};\n\n/**\n * Update the HTML document's dir attribute\n * @param dir - The direction ('ltr' or 'rtl')\n */\nexport const updateDocumentDirection = (dir: \"ltr\" | \"rtl\"): void => {\n  if (typeof document !== \"undefined\") {\n    document.documentElement.setAttribute(\"dir\", dir);\n  }\n};\n\n/**\n * Update the HTML document's lang attribute\n * @param lang - The language code (e.g., 'ar', 'en')\n */\nexport const updateDocumentLanguage = (lang: string): void => {\n  if (typeof document !== \"undefined\") {\n    document.documentElement.setAttribute(\"lang\", lang);\n  }\n};\n\n/**\n * Initialize RTL support for the current document\n * Should be called when the app initializes and when language changes\n * @param langCode - The current language code\n */\nexport const initializeRTL = (langCode: string): void => {\n  const dir = getLanguageDirection(langCode);\n  updateDocumentDirection(dir);\n  updateDocumentLanguage(langCode);\n};\n"
  },
  {
    "path": "src/main.tsx",
    "content": "import React from \"react\";\nimport ReactDOM from \"react-dom/client\";\nimport { platform } from \"@tauri-apps/plugin-os\";\nimport App from \"./App\";\n\n// Set platform before render so CSS can scope per-platform (e.g. scrollbar styles)\ndocument.documentElement.dataset.platform = platform();\n\n// Initialize i18n\nimport \"./i18n\";\n\n// Initialize model store (loads models and sets up event listeners)\nimport { useModelStore } from \"./stores/modelStore\";\nuseModelStore.getState().initialize();\n\nReactDOM.createRoot(document.getElementById(\"root\") as HTMLElement).render(\n  <React.StrictMode>\n    <App />\n  </React.StrictMode>,\n);\n"
  },
  {
    "path": "src/overlay/RecordingOverlay.css",
    "content": ".recording-overlay {\n  height: 36px;\n  width: 172px;\n  display: grid;\n  grid-template-columns: auto 1fr auto;\n  align-items: center;\n  padding: 6px;\n  background: #000000cc;\n  border-radius: 18px;\n  opacity: 0;\n  transition: opacity 300ms ease-out;\n  box-sizing: border-box;\n}\n\n.overlay-left {\n  display: flex;\n  align-items: center;\n}\n\n.overlay-middle {\n  display: flex;\n  align-items: center;\n  justify-content: center;\n}\n\n.overlay-right {\n  display: flex;\n  align-items: center;\n  justify-content: flex-end;\n}\n\n.bars-container {\n  display: flex;\n  align-items: end;\n  justify-content: center;\n  gap: 3px;\n  padding-bottom: 0px;\n  height: 24px;\n  overflow: hidden;\n}\n\n.bar {\n  width: 6px;\n  background: #ffe5ee;\n  max-height: 20px;\n  border-radius: 2px;\n  transition: height 80ms linear;\n  min-height: 4px;\n}\n\n.recording-overlay.fade-in {\n  opacity: 1;\n}\n\n.transcribing-text {\n  color: white;\n  font-size: 12px;\n  font-family:\n    -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, sans-serif;\n  animation: transcribing-pulse 1.5s infinite ease-in-out;\n}\n\n@keyframes transcribing-pulse {\n  0%,\n  100% {\n    opacity: 0.6;\n  }\n  50% {\n    opacity: 1;\n  }\n}\n\n.cancel-button {\n  width: 24px;\n  height: 24px;\n  border-radius: 50%;\n  background: transparent;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  cursor: pointer;\n  transition:\n    background-color 150ms ease-out,\n    transform 100ms ease-out;\n  flex-shrink: 0;\n}\n\n.cancel-button:hover {\n  background: #faa2ca33;\n  transform: scale(1.05);\n}\n\n.cancel-button:active {\n  transform: scale(0.95);\n}\n"
  },
  {
    "path": "src/overlay/RecordingOverlay.tsx",
    "content": "import { listen } from \"@tauri-apps/api/event\";\nimport React, { useEffect, useRef, useState } from \"react\";\nimport { useTranslation } from \"react-i18next\";\nimport {\n  MicrophoneIcon,\n  TranscriptionIcon,\n  CancelIcon,\n} from \"../components/icons\";\nimport \"./RecordingOverlay.css\";\nimport { commands } from \"@/bindings\";\nimport i18n, { syncLanguageFromSettings } from \"@/i18n\";\nimport { getLanguageDirection } from \"@/lib/utils/rtl\";\n\ntype OverlayState = \"recording\" | \"transcribing\" | \"processing\";\n\nconst RecordingOverlay: React.FC = () => {\n  const { t } = useTranslation();\n  const [isVisible, setIsVisible] = useState(false);\n  const [state, setState] = useState<OverlayState>(\"recording\");\n  const [levels, setLevels] = useState<number[]>(Array(16).fill(0));\n  const smoothedLevelsRef = useRef<number[]>(Array(16).fill(0));\n  const direction = getLanguageDirection(i18n.language);\n\n  useEffect(() => {\n    const setupEventListeners = async () => {\n      // Listen for show-overlay event from Rust\n      const unlistenShow = await listen(\"show-overlay\", async (event) => {\n        // Sync language from settings each time overlay is shown\n        await syncLanguageFromSettings();\n        const overlayState = event.payload as OverlayState;\n        setState(overlayState);\n        setIsVisible(true);\n      });\n\n      // Listen for hide-overlay event from Rust\n      const unlistenHide = await listen(\"hide-overlay\", () => {\n        setIsVisible(false);\n      });\n\n      // Listen for mic-level updates\n      const unlistenLevel = await listen<number[]>(\"mic-level\", (event) => {\n        const newLevels = event.payload as number[];\n\n        // Apply smoothing to reduce jitter\n        const smoothed = smoothedLevelsRef.current.map((prev, i) => {\n          const target = newLevels[i] || 0;\n          return prev * 0.7 + target * 0.3; // Smooth transition\n        });\n\n        smoothedLevelsRef.current = smoothed;\n        setLevels(smoothed.slice(0, 9));\n      });\n\n      // Cleanup function\n      return () => {\n        unlistenShow();\n        unlistenHide();\n        unlistenLevel();\n      };\n    };\n\n    setupEventListeners();\n  }, []);\n\n  const getIcon = () => {\n    if (state === \"recording\") {\n      return <MicrophoneIcon />;\n    } else {\n      return <TranscriptionIcon />;\n    }\n  };\n\n  return (\n    <div\n      dir={direction}\n      className={`recording-overlay ${isVisible ? \"fade-in\" : \"\"}`}\n    >\n      <div className=\"overlay-left\">{getIcon()}</div>\n\n      <div className=\"overlay-middle\">\n        {state === \"recording\" && (\n          <div className=\"bars-container\">\n            {levels.map((v, i) => (\n              <div\n                key={i}\n                className=\"bar\"\n                style={{\n                  height: `${Math.min(20, 4 + Math.pow(v, 0.7) * 16)}px`, // Cap at 20px max height\n                  transition: \"height 60ms ease-out, opacity 120ms ease-out\",\n                  opacity: Math.max(0.2, v * 1.7), // Minimum opacity for visibility\n                }}\n              />\n            ))}\n          </div>\n        )}\n        {state === \"transcribing\" && (\n          <div className=\"transcribing-text\">{t(\"overlay.transcribing\")}</div>\n        )}\n        {state === \"processing\" && (\n          <div className=\"transcribing-text\">{t(\"overlay.processing\")}</div>\n        )}\n      </div>\n\n      <div className=\"overlay-right\">\n        {state === \"recording\" && (\n          <div\n            className=\"cancel-button\"\n            onClick={() => {\n              commands.cancelOperation();\n            }}\n          >\n            <CancelIcon />\n          </div>\n        )}\n      </div>\n    </div>\n  );\n};\n\nexport default RecordingOverlay;\n"
  },
  {
    "path": "src/overlay/index.html",
    "content": "<!doctype html>\n<html lang=\"en\" dir=\"ltr\">\n  <head>\n    <meta charset=\"utf-8\" />\n    <title>Recording Overlay</title>\n    <style>\n      html,\n      body {\n        margin: 0;\n        padding: 0;\n        background: transparent;\n        overflow: hidden;\n        width: 100%;\n        height: 100%;\n      }\n      #root {\n        width: 100%;\n        height: 100%;\n        overflow: hidden;\n      }\n    </style>\n  </head>\n  <body>\n    <div id=\"root\"></div>\n    <script type=\"module\" src=\"/src/overlay/main.tsx\"></script>\n  </body>\n</html>\n"
  },
  {
    "path": "src/overlay/main.tsx",
    "content": "import React from \"react\";\nimport ReactDOM from \"react-dom/client\";\nimport RecordingOverlay from \"./RecordingOverlay\";\nimport \"@/i18n\";\n\nReactDOM.createRoot(document.getElementById(\"root\") as HTMLElement).render(\n  <React.StrictMode>\n    <RecordingOverlay />\n  </React.StrictMode>,\n);\n"
  },
  {
    "path": "src/stores/modelStore.ts",
    "content": "import { create } from \"zustand\";\nimport { subscribeWithSelector } from \"zustand/middleware\";\nimport { produce } from \"immer\";\nimport { listen } from \"@tauri-apps/api/event\";\nimport { commands, type ModelInfo } from \"@/bindings\";\n\ninterface DownloadProgress {\n  model_id: string;\n  downloaded: number;\n  total: number;\n  percentage: number;\n}\n\ninterface DownloadStats {\n  startTime: number;\n  lastUpdate: number;\n  totalDownloaded: number;\n  speed: number; // MB/s\n}\n\n// Using Record instead of Set/Map for Immer compatibility\ninterface ModelsStore {\n  models: ModelInfo[];\n  currentModel: string;\n  downloadingModels: Record<string, true>;\n  extractingModels: Record<string, true>;\n  downloadProgress: Record<string, DownloadProgress>;\n  downloadStats: Record<string, DownloadStats>;\n  loading: boolean;\n  error: string | null;\n  hasAnyModels: boolean;\n  isFirstRun: boolean;\n  initialized: boolean;\n\n  // Actions\n  initialize: () => Promise<void>;\n  loadModels: () => Promise<void>;\n  loadCurrentModel: () => Promise<void>;\n  checkFirstRun: () => Promise<boolean>;\n  selectModel: (modelId: string) => Promise<boolean>;\n  downloadModel: (modelId: string) => Promise<boolean>;\n  cancelDownload: (modelId: string) => Promise<boolean>;\n  deleteModel: (modelId: string) => Promise<boolean>;\n  getModelInfo: (modelId: string) => ModelInfo | undefined;\n  isModelDownloading: (modelId: string) => boolean;\n  isModelExtracting: (modelId: string) => boolean;\n  getDownloadProgress: (modelId: string) => DownloadProgress | undefined;\n\n  // Internal setters\n  setModels: (models: ModelInfo[]) => void;\n  setCurrentModel: (modelId: string) => void;\n  setError: (error: string | null) => void;\n  setLoading: (loading: boolean) => void;\n}\n\nexport const useModelStore = create<ModelsStore>()(\n  subscribeWithSelector((set, get) => ({\n    models: [],\n    currentModel: \"\",\n    downloadingModels: {},\n    extractingModels: {},\n    downloadProgress: {},\n    downloadStats: {},\n    loading: true,\n    error: null,\n    hasAnyModels: false,\n    isFirstRun: false,\n    initialized: false,\n\n    // Internal setters\n    setModels: (models) => set({ models }),\n    setCurrentModel: (currentModel) => set({ currentModel }),\n    setError: (error) => set({ error }),\n    setLoading: (loading) => set({ loading }),\n\n    loadModels: async () => {\n      try {\n        const result = await commands.getAvailableModels();\n        if (result.status === \"ok\") {\n          set({ models: result.data, error: null });\n\n          // Sync downloading state from backend\n          set(\n            produce((state) => {\n              const backendDownloading: Record<string, true> = {};\n              result.data\n                .filter((m) => m.is_downloading)\n                .forEach((m) => {\n                  backendDownloading[m.id] = true;\n                });\n\n              // Merge: keep frontend state if downloading, add backend state\n              Object.keys(backendDownloading).forEach((id) => {\n                state.downloadingModels[id] = true;\n              });\n\n              // Remove models that backend says are NOT downloading AND\n              // frontend doesn't have progress for (completed/cancelled)\n              Object.keys(state.downloadingModels).forEach((id) => {\n                if (!backendDownloading[id] && !state.downloadProgress[id]) {\n                  delete state.downloadingModels[id];\n                }\n              });\n            }),\n          );\n        } else {\n          set({ error: `Failed to load models: ${result.error}` });\n        }\n      } catch (err) {\n        set({ error: `Failed to load models: ${err}` });\n      } finally {\n        set({ loading: false });\n      }\n    },\n\n    loadCurrentModel: async () => {\n      try {\n        const result = await commands.getCurrentModel();\n        if (result.status === \"ok\") {\n          set({ currentModel: result.data });\n        }\n      } catch (err) {\n        console.error(\"Failed to load current model:\", err);\n      }\n    },\n\n    checkFirstRun: async () => {\n      try {\n        const result = await commands.hasAnyModelsAvailable();\n        if (result.status === \"ok\") {\n          const hasModels = result.data;\n          set({ hasAnyModels: hasModels, isFirstRun: !hasModels });\n          return !hasModels;\n        }\n        return false;\n      } catch (err) {\n        console.error(\"Failed to check model availability:\", err);\n        return false;\n      }\n    },\n\n    selectModel: async (modelId: string) => {\n      try {\n        set({ error: null });\n        const result = await commands.setActiveModel(modelId);\n        if (result.status === \"ok\") {\n          set({\n            currentModel: modelId,\n            isFirstRun: false,\n            hasAnyModels: true,\n          });\n          return true;\n        } else {\n          set({ error: `Failed to switch to model: ${result.error}` });\n          return false;\n        }\n      } catch (err) {\n        set({ error: `Failed to switch to model: ${err}` });\n        return false;\n      }\n    },\n\n    downloadModel: async (modelId: string) => {\n      try {\n        set({ error: null });\n        set(\n          produce((state) => {\n            state.downloadingModels[modelId] = true;\n            state.downloadProgress[modelId] = {\n              model_id: modelId,\n              downloaded: 0,\n              total: 0,\n              percentage: 0,\n            };\n          }),\n        );\n        const result = await commands.downloadModel(modelId);\n        if (result.status === \"ok\") {\n          return true;\n        } else {\n          set({ error: `Failed to download model: ${result.error}` });\n          set(\n            produce((state) => {\n              delete state.downloadingModels[modelId];\n            }),\n          );\n          return false;\n        }\n      } catch (err) {\n        set({ error: `Failed to download model: ${err}` });\n        set(\n          produce((state) => {\n            delete state.downloadingModels[modelId];\n          }),\n        );\n        return false;\n      }\n    },\n\n    cancelDownload: async (modelId: string) => {\n      try {\n        set({ error: null });\n        const result = await commands.cancelDownload(modelId);\n        if (result.status === \"ok\") {\n          set(\n            produce((state) => {\n              delete state.downloadingModels[modelId];\n              delete state.downloadProgress[modelId];\n              delete state.downloadStats[modelId];\n            }),\n          );\n\n          // Reload models to sync with backend state\n          await get().loadModels();\n          return true;\n        } else {\n          set({ error: `Failed to cancel download: ${result.error}` });\n          return false;\n        }\n      } catch (err) {\n        set({ error: `Failed to cancel download: ${err}` });\n        return false;\n      }\n    },\n\n    deleteModel: async (modelId: string) => {\n      try {\n        set({ error: null });\n        const result = await commands.deleteModel(modelId);\n        if (result.status === \"ok\") {\n          await get().loadModels();\n          await get().loadCurrentModel();\n          return true;\n        } else {\n          set({ error: `Failed to delete model: ${result.error}` });\n          return false;\n        }\n      } catch (err) {\n        set({ error: `Failed to delete model: ${err}` });\n        return false;\n      }\n    },\n\n    getModelInfo: (modelId: string) => {\n      return get().models.find((model) => model.id === modelId);\n    },\n\n    isModelDownloading: (modelId: string) => {\n      return modelId in get().downloadingModels;\n    },\n\n    isModelExtracting: (modelId: string) => {\n      return modelId in get().extractingModels;\n    },\n\n    getDownloadProgress: (modelId: string) => {\n      return get().downloadProgress[modelId];\n    },\n\n    initialize: async () => {\n      if (get().initialized) return;\n\n      const { loadModels, loadCurrentModel, checkFirstRun } = get();\n\n      // Load initial data\n      await Promise.all([loadModels(), loadCurrentModel(), checkFirstRun()]);\n\n      // Set up event listeners\n      listen<DownloadProgress>(\"model-download-progress\", (event) => {\n        const progress = event.payload;\n        set(\n          produce((state) => {\n            state.downloadProgress[progress.model_id] = progress;\n          }),\n        );\n\n        // Update download stats for speed calculation\n        const now = Date.now();\n        set(\n          produce((state) => {\n            const current = state.downloadStats[progress.model_id];\n\n            if (!current) {\n              state.downloadStats[progress.model_id] = {\n                startTime: now,\n                lastUpdate: now,\n                totalDownloaded: progress.downloaded,\n                speed: 0,\n              };\n            } else {\n              const timeDiff = (now - current.lastUpdate) / 1000;\n              const bytesDiff = progress.downloaded - current.totalDownloaded;\n\n              if (timeDiff > 0.5) {\n                const currentSpeed = bytesDiff / (1024 * 1024) / timeDiff;\n                const validCurrentSpeed = Math.max(0, currentSpeed);\n                const smoothedSpeed =\n                  current.speed > 0\n                    ? current.speed * 0.8 + validCurrentSpeed * 0.2\n                    : validCurrentSpeed;\n\n                state.downloadStats[progress.model_id] = {\n                  startTime: current.startTime,\n                  lastUpdate: now,\n                  totalDownloaded: progress.downloaded,\n                  speed: Math.max(0, smoothedSpeed),\n                };\n              }\n            }\n          }),\n        );\n      });\n\n      listen<string>(\"model-download-complete\", (event) => {\n        const modelId = event.payload;\n        set(\n          produce((state) => {\n            delete state.downloadingModels[modelId];\n            delete state.downloadProgress[modelId];\n            delete state.downloadStats[modelId];\n          }),\n        );\n        get().loadModels();\n      });\n\n      listen<string>(\"model-extraction-started\", (event) => {\n        const modelId = event.payload;\n        set(\n          produce((state) => {\n            state.extractingModels[modelId] = true;\n          }),\n        );\n      });\n\n      listen<string>(\"model-extraction-completed\", (event) => {\n        const modelId = event.payload;\n        set(\n          produce((state) => {\n            delete state.extractingModels[modelId];\n          }),\n        );\n        get().loadModels();\n      });\n\n      listen<{ model_id: string; error: string }>(\n        \"model-extraction-failed\",\n        (event) => {\n          const modelId = event.payload.model_id;\n          set(\n            produce((state) => {\n              delete state.extractingModels[modelId];\n              state.error = `Failed to extract model: ${event.payload.error}`;\n            }),\n          );\n        },\n      );\n\n      listen<string>(\"model-download-cancelled\", (event) => {\n        const modelId = event.payload;\n        set(\n          produce((state) => {\n            delete state.downloadingModels[modelId];\n            delete state.downloadProgress[modelId];\n            delete state.downloadStats[modelId];\n          }),\n        );\n      });\n\n      listen<string>(\"model-deleted\", () => {\n        get().loadModels();\n        get().loadCurrentModel();\n      });\n\n      listen(\"model-state-changed\", () => {\n        get().loadModels();\n        get().loadCurrentModel();\n      });\n\n      set({ initialized: true });\n    },\n  })),\n);\n"
  },
  {
    "path": "src/stores/settingsStore.ts",
    "content": "import { create } from \"zustand\";\nimport { subscribeWithSelector } from \"zustand/middleware\";\nimport { listen } from \"@tauri-apps/api/event\";\nimport type {\n  AppSettings as Settings,\n  AudioDevice,\n  WhisperAcceleratorSetting,\n  OrtAcceleratorSetting,\n} from \"@/bindings\";\nimport { commands } from \"@/bindings\";\n\ninterface SettingsStore {\n  settings: Settings | null;\n  defaultSettings: Settings | null;\n  isLoading: boolean;\n  isUpdating: Record<string, boolean>;\n  audioDevices: AudioDevice[];\n  outputDevices: AudioDevice[];\n  customSounds: { start: boolean; stop: boolean };\n  postProcessModelOptions: Record<string, string[]>;\n\n  // Actions\n  initialize: () => Promise<void>;\n  loadDefaultSettings: () => Promise<void>;\n  updateSetting: <K extends keyof Settings>(\n    key: K,\n    value: Settings[K],\n  ) => Promise<void>;\n  resetSetting: (key: keyof Settings) => Promise<void>;\n  refreshSettings: () => Promise<void>;\n  refreshAudioDevices: () => Promise<void>;\n  refreshOutputDevices: () => Promise<void>;\n  updateBinding: (id: string, binding: string) => Promise<void>;\n  resetBinding: (id: string) => Promise<void>;\n  getSetting: <K extends keyof Settings>(key: K) => Settings[K] | undefined;\n  isUpdatingKey: (key: string) => boolean;\n  playTestSound: (soundType: \"start\" | \"stop\") => Promise<void>;\n  checkCustomSounds: () => Promise<void>;\n  setPostProcessProvider: (providerId: string) => Promise<void>;\n  updatePostProcessSetting: (\n    settingType: \"base_url\" | \"api_key\" | \"model\",\n    providerId: string,\n    value: string,\n  ) => Promise<void>;\n  updatePostProcessBaseUrl: (\n    providerId: string,\n    baseUrl: string,\n  ) => Promise<void>;\n  updatePostProcessApiKey: (\n    providerId: string,\n    apiKey: string,\n  ) => Promise<void>;\n  updatePostProcessModel: (providerId: string, model: string) => Promise<void>;\n  fetchPostProcessModels: (providerId: string) => Promise<string[]>;\n  setPostProcessModelOptions: (providerId: string, models: string[]) => void;\n\n  // Internal state setters\n  setSettings: (settings: Settings | null) => void;\n  setDefaultSettings: (defaultSettings: Settings | null) => void;\n  setLoading: (loading: boolean) => void;\n  setUpdating: (key: string, updating: boolean) => void;\n  setAudioDevices: (devices: AudioDevice[]) => void;\n  setOutputDevices: (devices: AudioDevice[]) => void;\n  setCustomSounds: (sounds: { start: boolean; stop: boolean }) => void;\n}\n\n// Note: Default settings are now fetched from Rust via commands.getDefaultSettings()\n// This ensures platform-specific defaults (like overlay_position, shortcuts, paste_method) work correctly\n\nconst DEFAULT_AUDIO_DEVICE: AudioDevice = {\n  index: \"default\",\n  name: \"Default\",\n  is_default: true,\n};\n\nconst settingUpdaters: {\n  [K in keyof Settings]?: (value: Settings[K]) => Promise<unknown>;\n} = {\n  always_on_microphone: (value) =>\n    commands.updateMicrophoneMode(value as boolean),\n  audio_feedback: (value) =>\n    commands.changeAudioFeedbackSetting(value as boolean),\n  audio_feedback_volume: (value) =>\n    commands.changeAudioFeedbackVolumeSetting(value as number),\n  sound_theme: (value) => commands.changeSoundThemeSetting(value as string),\n  start_hidden: (value) => commands.changeStartHiddenSetting(value as boolean),\n  autostart_enabled: (value) =>\n    commands.changeAutostartSetting(value as boolean),\n  update_checks_enabled: (value) =>\n    commands.changeUpdateChecksSetting(value as boolean),\n  push_to_talk: (value) => commands.changePttSetting(value as boolean),\n  selected_microphone: (value) =>\n    commands.setSelectedMicrophone(\n      (value as string) === \"Default\" || value === null\n        ? \"default\"\n        : (value as string),\n    ),\n  clamshell_microphone: (value) =>\n    commands.setClamshellMicrophone(\n      (value as string) === \"Default\" ? \"default\" : (value as string),\n    ),\n  selected_output_device: (value) =>\n    commands.setSelectedOutputDevice(\n      (value as string) === \"Default\" || value === null\n        ? \"default\"\n        : (value as string),\n    ),\n  recording_retention_period: (value) =>\n    commands.updateRecordingRetentionPeriod(value as string),\n  translate_to_english: (value) =>\n    commands.changeTranslateToEnglishSetting(value as boolean),\n  selected_language: (value) =>\n    commands.changeSelectedLanguageSetting(value as string),\n  overlay_position: (value) =>\n    commands.changeOverlayPositionSetting(value as string),\n  debug_mode: (value) => commands.changeDebugModeSetting(value as boolean),\n  custom_words: (value) => commands.updateCustomWords(value as string[]),\n  word_correction_threshold: (value) =>\n    commands.changeWordCorrectionThresholdSetting(value as number),\n  paste_method: (value) => commands.changePasteMethodSetting(value as string),\n  typing_tool: (value) => commands.changeTypingToolSetting(value as string),\n  external_script_path: (value) =>\n    commands.changeExternalScriptPathSetting(value as string | null),\n  clipboard_handling: (value) =>\n    commands.changeClipboardHandlingSetting(value as string),\n  auto_submit: (value) => commands.changeAutoSubmitSetting(value as boolean),\n  auto_submit_key: (value) =>\n    commands.changeAutoSubmitKeySetting(value as string),\n  history_limit: (value) => commands.updateHistoryLimit(value as number),\n  post_process_enabled: (value) =>\n    commands.changePostProcessEnabledSetting(value as boolean),\n  post_process_selected_prompt_id: (value) =>\n    commands.setPostProcessSelectedPrompt(value as string),\n  mute_while_recording: (value) =>\n    commands.changeMuteWhileRecordingSetting(value as boolean),\n  append_trailing_space: (value) =>\n    commands.changeAppendTrailingSpaceSetting(value as boolean),\n  log_level: (value) => commands.setLogLevel(value as any),\n  app_language: (value) => commands.changeAppLanguageSetting(value as string),\n  experimental_enabled: (value) =>\n    commands.changeExperimentalEnabledSetting(value as boolean),\n  lazy_stream_close: (value) =>\n    commands.changeLazyStreamCloseSetting(value as boolean),\n  show_tray_icon: (value) =>\n    commands.changeShowTrayIconSetting(value as boolean),\n  whisper_accelerator: (value) =>\n    commands.changeWhisperAcceleratorSetting(\n      value as WhisperAcceleratorSetting,\n    ),\n  ort_accelerator: (value) =>\n    commands.changeOrtAcceleratorSetting(value as OrtAcceleratorSetting),\n  extra_recording_buffer_ms: (value) =>\n    commands.changeExtraRecordingBufferSetting(value as number),\n};\n\nexport const useSettingsStore = create<SettingsStore>()(\n  subscribeWithSelector((set, get) => ({\n    settings: null,\n    defaultSettings: null,\n    isLoading: true,\n    isUpdating: {},\n    audioDevices: [],\n    outputDevices: [],\n    customSounds: { start: false, stop: false },\n    postProcessModelOptions: {},\n\n    // Internal setters\n    setSettings: (settings) => set({ settings }),\n    setDefaultSettings: (defaultSettings) => set({ defaultSettings }),\n    setLoading: (isLoading) => set({ isLoading }),\n    setUpdating: (key, updating) =>\n      set((state) => ({\n        isUpdating: { ...state.isUpdating, [key]: updating },\n      })),\n    setAudioDevices: (audioDevices) => set({ audioDevices }),\n    setOutputDevices: (outputDevices) => set({ outputDevices }),\n    setCustomSounds: (customSounds) => set({ customSounds }),\n\n    // Getters\n    getSetting: (key) => get().settings?.[key],\n    isUpdatingKey: (key) => get().isUpdating[key] || false,\n\n    // Load settings from store\n    refreshSettings: async () => {\n      try {\n        const result = await commands.getAppSettings();\n        if (result.status === \"ok\") {\n          const settings = result.data;\n          const normalizedSettings: Settings = {\n            ...settings,\n            always_on_microphone: settings.always_on_microphone ?? false,\n            selected_microphone: settings.selected_microphone ?? \"Default\",\n            clamshell_microphone: settings.clamshell_microphone ?? \"Default\",\n            selected_output_device:\n              settings.selected_output_device ?? \"Default\",\n          };\n          set({ settings: normalizedSettings, isLoading: false });\n        } else {\n          console.error(\"Failed to load settings:\", result.error);\n          set({ isLoading: false });\n        }\n      } catch (error) {\n        console.error(\"Failed to load settings:\", error);\n        set({ isLoading: false });\n      }\n    },\n\n    // Load audio devices\n    refreshAudioDevices: async () => {\n      try {\n        const result = await commands.getAvailableMicrophones();\n        if (result.status === \"ok\") {\n          const devicesWithDefault = [\n            DEFAULT_AUDIO_DEVICE,\n            ...result.data.filter(\n              (d) => d.name !== \"Default\" && d.name !== \"default\",\n            ),\n          ];\n          set({ audioDevices: devicesWithDefault });\n        } else {\n          set({ audioDevices: [DEFAULT_AUDIO_DEVICE] });\n        }\n      } catch (error) {\n        console.error(\"Failed to load audio devices:\", error);\n        set({ audioDevices: [DEFAULT_AUDIO_DEVICE] });\n      }\n    },\n\n    // Load output devices\n    refreshOutputDevices: async () => {\n      try {\n        const result = await commands.getAvailableOutputDevices();\n        if (result.status === \"ok\") {\n          const devicesWithDefault = [\n            DEFAULT_AUDIO_DEVICE,\n            ...result.data.filter(\n              (d) => d.name !== \"Default\" && d.name !== \"default\",\n            ),\n          ];\n          set({ outputDevices: devicesWithDefault });\n        } else {\n          set({ outputDevices: [DEFAULT_AUDIO_DEVICE] });\n        }\n      } catch (error) {\n        console.error(\"Failed to load output devices:\", error);\n        set({ outputDevices: [DEFAULT_AUDIO_DEVICE] });\n      }\n    },\n\n    // Play a test sound\n    playTestSound: async (soundType: \"start\" | \"stop\") => {\n      try {\n        await commands.playTestSound(soundType);\n      } catch (error) {\n        console.error(`Failed to play test sound (${soundType}):`, error);\n      }\n    },\n\n    checkCustomSounds: async () => {\n      try {\n        const sounds = await commands.checkCustomSounds();\n        get().setCustomSounds(sounds);\n      } catch (error) {\n        console.error(\"Failed to check custom sounds:\", error);\n      }\n    },\n\n    // Update a specific setting\n    updateSetting: async <K extends keyof Settings>(\n      key: K,\n      value: Settings[K],\n    ) => {\n      const { settings, setUpdating } = get();\n      const updateKey = String(key);\n      const originalValue = settings?.[key];\n\n      setUpdating(updateKey, true);\n\n      try {\n        set((state) => ({\n          settings: state.settings ? { ...state.settings, [key]: value } : null,\n        }));\n\n        const updater = settingUpdaters[key];\n        if (updater) {\n          await updater(value);\n        } else if (key !== \"bindings\" && key !== \"selected_model\") {\n          console.warn(`No handler for setting: ${String(key)}`);\n        }\n      } catch (error) {\n        console.error(`Failed to update setting ${String(key)}:`, error);\n        if (settings) {\n          set({ settings: { ...settings, [key]: originalValue } });\n        }\n      } finally {\n        setUpdating(updateKey, false);\n      }\n    },\n\n    // Reset a setting to its default value\n    resetSetting: async (key) => {\n      const { defaultSettings } = get();\n      if (defaultSettings) {\n        const defaultValue = defaultSettings[key];\n        if (defaultValue !== undefined) {\n          await get().updateSetting(key, defaultValue as any);\n        }\n      }\n    },\n\n    // Update a specific binding\n    updateBinding: async (id, binding) => {\n      const { settings, setUpdating } = get();\n      const updateKey = `binding_${id}`;\n      const originalBinding = settings?.bindings?.[id]?.current_binding;\n\n      setUpdating(updateKey, true);\n\n      try {\n        // Optimistic update\n        set((state) => ({\n          settings: state.settings\n            ? {\n                ...state.settings,\n                bindings: {\n                  ...state.settings.bindings,\n                  [id]: {\n                    ...state.settings.bindings[id]!,\n                    current_binding: binding,\n                  },\n                },\n              }\n            : null,\n        }));\n\n        const result = await commands.changeBinding(id, binding);\n\n        // Check if the command executed successfully\n        if (result.status === \"error\") {\n          throw new Error(result.error);\n        }\n\n        // Check if the binding change was successful\n        if (!result.data.success) {\n          throw new Error(result.data.error || \"Failed to update binding\");\n        }\n      } catch (error) {\n        console.error(`Failed to update binding ${id}:`, error);\n\n        // Rollback on error\n        if (originalBinding && get().settings) {\n          set((state) => ({\n            settings: state.settings\n              ? {\n                  ...state.settings,\n                  bindings: {\n                    ...state.settings.bindings,\n                    [id]: {\n                      ...state.settings.bindings[id]!,\n                      current_binding: originalBinding,\n                    },\n                  },\n                }\n              : null,\n          }));\n        }\n\n        // Re-throw to let the caller know it failed\n        throw error;\n      } finally {\n        setUpdating(updateKey, false);\n      }\n    },\n\n    // Reset a specific binding\n    resetBinding: async (id) => {\n      const { setUpdating, refreshSettings } = get();\n      const updateKey = `binding_${id}`;\n\n      setUpdating(updateKey, true);\n\n      try {\n        await commands.resetBinding(id);\n        await refreshSettings();\n      } catch (error) {\n        console.error(`Failed to reset binding ${id}:`, error);\n      } finally {\n        setUpdating(updateKey, false);\n      }\n    },\n\n    setPostProcessProvider: async (providerId) => {\n      const {\n        settings,\n        setUpdating,\n        refreshSettings,\n        setPostProcessModelOptions,\n      } = get();\n      const updateKey = \"post_process_provider_id\";\n      const previousId = settings?.post_process_provider_id ?? null;\n\n      setUpdating(updateKey, true);\n\n      if (settings) {\n        set((state) => ({\n          settings: state.settings\n            ? { ...state.settings, post_process_provider_id: providerId }\n            : null,\n        }));\n      }\n\n      // Clear cached model options for the new provider so the dropdown\n      // doesn't show stale models from a previous fetch or base_url.\n      setPostProcessModelOptions(providerId, []);\n\n      try {\n        await commands.setPostProcessProvider(providerId);\n        await refreshSettings();\n      } catch (error) {\n        console.error(\"Failed to set post-process provider:\", error);\n        if (previousId !== null) {\n          set((state) => ({\n            settings: state.settings\n              ? { ...state.settings, post_process_provider_id: previousId }\n              : null,\n          }));\n        }\n      } finally {\n        setUpdating(updateKey, false);\n      }\n    },\n\n    // Generic updater for post-processing provider settings\n    updatePostProcessSetting: async (\n      settingType: \"base_url\" | \"api_key\" | \"model\",\n      providerId: string,\n      value: string,\n    ) => {\n      const { setUpdating, refreshSettings } = get();\n      const updateKey = `post_process_${settingType}:${providerId}`;\n\n      setUpdating(updateKey, true);\n\n      try {\n        if (settingType === \"base_url\") {\n          await commands.changePostProcessBaseUrlSetting(providerId, value);\n        } else if (settingType === \"api_key\") {\n          await commands.changePostProcessApiKeySetting(providerId, value);\n        } else if (settingType === \"model\") {\n          await commands.changePostProcessModelSetting(providerId, value);\n        }\n        await refreshSettings();\n      } catch (error) {\n        console.error(\n          `Failed to update post-process ${settingType.replace(\"_\", \" \")}:`,\n          error,\n        );\n      } finally {\n        setUpdating(updateKey, false);\n      }\n    },\n\n    updatePostProcessBaseUrl: async (providerId, baseUrl) => {\n      const { setUpdating, refreshSettings } = get();\n      const updateKey = `post_process_base_url:${providerId}`;\n\n      setUpdating(updateKey, true);\n\n      try {\n        // Persist the new base URL first.\n        const urlResult = await commands.changePostProcessBaseUrlSetting(\n          providerId,\n          baseUrl,\n        );\n        if (urlResult.status === \"error\") {\n          console.error(\"Failed to persist base URL:\", urlResult.error);\n          return;\n        }\n\n        // Reset the stored model since the previous value is almost certainly\n        // invalid for the new endpoint (e.g. switching Custom from Groq to\n        // Cerebras). Only proceed if the reset succeeds.\n        const modelResult = await commands.changePostProcessModelSetting(\n          providerId,\n          \"\",\n        );\n        if (modelResult.status === \"error\") {\n          console.error(\"Failed to reset model setting:\", modelResult.error);\n          return;\n        }\n\n        // Clear cached model options only after both backend writes succeed.\n        set((state) => ({\n          postProcessModelOptions: {\n            ...state.postProcessModelOptions,\n            [providerId]: [],\n          },\n        }));\n\n        // Single refresh after both backend writes.\n        await refreshSettings();\n      } catch (error) {\n        console.error(\"Failed to update post-process base URL:\", error);\n      } finally {\n        setUpdating(updateKey, false);\n      }\n    },\n\n    updatePostProcessApiKey: async (providerId, apiKey) => {\n      // Clear cached models when API key changes - user should click refresh after\n      set((state) => ({\n        postProcessModelOptions: {\n          ...state.postProcessModelOptions,\n          [providerId]: [],\n        },\n      }));\n      return get().updatePostProcessSetting(\"api_key\", providerId, apiKey);\n    },\n\n    updatePostProcessModel: async (providerId, model) => {\n      return get().updatePostProcessSetting(\"model\", providerId, model);\n    },\n\n    fetchPostProcessModels: async (providerId) => {\n      const updateKey = `post_process_models_fetch:${providerId}`;\n      const { setUpdating, setPostProcessModelOptions } = get();\n\n      setUpdating(updateKey, true);\n\n      try {\n        // Call Tauri backend command instead of fetch\n        const result = await commands.fetchPostProcessModels(providerId);\n        if (result.status === \"ok\") {\n          setPostProcessModelOptions(providerId, result.data);\n          return result.data;\n        } else {\n          console.error(\"Failed to fetch models:\", result.error);\n          return [];\n        }\n      } catch (error) {\n        console.error(\"Failed to fetch models:\", error);\n        // Don't cache empty array on error - let user retry\n        return [];\n      } finally {\n        setUpdating(updateKey, false);\n      }\n    },\n\n    setPostProcessModelOptions: (providerId, models) =>\n      set((state) => ({\n        postProcessModelOptions: {\n          ...state.postProcessModelOptions,\n          [providerId]: models,\n        },\n      })),\n\n    // Load default settings from Rust\n    loadDefaultSettings: async () => {\n      try {\n        const result = await commands.getDefaultSettings();\n        if (result.status === \"ok\") {\n          set({ defaultSettings: result.data });\n        } else {\n          console.error(\"Failed to load default settings:\", result.error);\n        }\n      } catch (error) {\n        console.error(\"Failed to load default settings:\", error);\n      }\n    },\n\n    // Initialize everything\n    initialize: async () => {\n      const { refreshSettings, checkCustomSounds, loadDefaultSettings } = get();\n\n      // Note: Audio devices are NOT refreshed here. The frontend (App.tsx)\n      // is responsible for calling refreshAudioDevices/refreshOutputDevices\n      // after onboarding completes. This avoids triggering permission dialogs\n      // on macOS before the user is ready.\n      await Promise.all([\n        loadDefaultSettings(),\n        refreshSettings(),\n        checkCustomSounds(),\n      ]);\n\n      // Re-fetch settings when the backend changes them (e.g. language\n      // reset during model switch). The backend is the source of truth.\n      listen(\"model-state-changed\", () => {\n        get().refreshSettings();\n      });\n    },\n  })),\n);\n"
  },
  {
    "path": "src/utils/dateFormat.ts",
    "content": "/**\n * Format a date string or timestamp to a localized date and time string\n * @param timestamp - Unix timestamp in seconds (as string)\n * @param locale - BCP 47 language tag (e.g., 'en', 'es', 'fr')\n * @returns Formatted date string\n */\nexport const formatDateTime = (timestamp: string, locale: string): string => {\n  try {\n    // Convert Unix timestamp (seconds) to milliseconds\n    const timestampMs = parseInt(timestamp, 10) * 1000;\n    const date = new Date(timestampMs);\n\n    // Check if date is valid\n    if (isNaN(date.getTime())) {\n      return timestamp; // Return original if invalid\n    }\n\n    return new Intl.DateTimeFormat(locale, {\n      year: \"numeric\",\n      month: \"long\",\n      day: \"numeric\",\n      hour: \"2-digit\",\n      minute: \"2-digit\",\n    }).format(date);\n  } catch (error) {\n    console.error(\"Failed to format date:\", error);\n    return timestamp; // Fallback to original timestamp\n  }\n};\n\n/**\n * Format a date string or timestamp to a localized date string (no time)\n * @param timestamp - Unix timestamp in seconds (as string)\n * @param locale - BCP 47 language tag (e.g., 'en', 'es', 'fr')\n * @returns Formatted date string\n */\nexport const formatDate = (timestamp: string, locale: string): string => {\n  try {\n    // Convert Unix timestamp (seconds) to milliseconds\n    const timestampMs = parseInt(timestamp, 10) * 1000;\n    const date = new Date(timestampMs);\n\n    // Check if date is valid\n    if (isNaN(date.getTime())) {\n      return timestamp; // Return original if invalid\n    }\n\n    return new Intl.DateTimeFormat(locale, {\n      year: \"numeric\",\n      month: \"long\",\n      day: \"numeric\",\n    }).format(date);\n  } catch (error) {\n    console.error(\"Failed to format date:\", error);\n    return timestamp; // Fallback to original timestamp\n  }\n};\n\n/**\n * Format a date string or timestamp to a relative time string (e.g., \"2 hours ago\")\n * @param timestamp - Unix timestamp in seconds (as string)\n * @param locale - BCP 47 language tag (e.g., 'en', 'es', 'fr')\n * @returns Relative time string\n */\nexport const formatRelativeTime = (\n  timestamp: string,\n  locale: string,\n): string => {\n  try {\n    // Convert Unix timestamp (seconds) to milliseconds\n    const timestampMs = parseInt(timestamp, 10) * 1000;\n    const date = new Date(timestampMs);\n    const now = new Date();\n\n    // Check if date is valid\n    if (isNaN(date.getTime())) {\n      return timestamp; // Return original if invalid\n    }\n\n    const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000);\n\n    // Use Intl.RelativeTimeFormat for proper localization\n    const rtf = new Intl.RelativeTimeFormat(locale, { numeric: \"auto\" });\n\n    // Less than a minute\n    if (diffInSeconds < 60) {\n      return rtf.format(-diffInSeconds, \"second\");\n    }\n\n    // Less than an hour\n    const diffInMinutes = Math.floor(diffInSeconds / 60);\n    if (diffInMinutes < 60) {\n      return rtf.format(-diffInMinutes, \"minute\");\n    }\n\n    // Less than a day\n    const diffInHours = Math.floor(diffInMinutes / 60);\n    if (diffInHours < 24) {\n      return rtf.format(-diffInHours, \"hour\");\n    }\n\n    // Less than a week\n    const diffInDays = Math.floor(diffInHours / 24);\n    if (diffInDays < 7) {\n      return rtf.format(-diffInDays, \"day\");\n    }\n\n    // Less than a month (30 days)\n    if (diffInDays < 30) {\n      const diffInWeeks = Math.floor(diffInDays / 7);\n      return rtf.format(-diffInWeeks, \"week\");\n    }\n\n    // Less than a year\n    if (diffInDays < 365) {\n      const diffInMonths = Math.floor(diffInDays / 30);\n      return rtf.format(-diffInMonths, \"month\");\n    }\n\n    // More than a year\n    const diffInYears = Math.floor(diffInDays / 365);\n    return rtf.format(-diffInYears, \"year\");\n  } catch (error) {\n    console.error(\"Failed to format relative time:\", error);\n    return formatDateTime(timestamp, locale); // Fallback to absolute time\n  }\n};\n"
  },
  {
    "path": "src/vite-env.d.ts",
    "content": "/// <reference types=\"vite/client\" />\n"
  },
  {
    "path": "src-tauri/.gitignore",
    "content": "# Generated by Cargo\n# will have compiled files and executables\n/target/\n\n# Generated by Tauri\n# will have schema files for capabilities auto-completion\n/gen/schemas\n"
  },
  {
    "path": "src-tauri/Cargo.toml",
    "content": "[package]\nname = \"handy\"\nversion = \"0.7.12\"\ndescription = \"Handy\"\nauthors = [\"cjpais\"]\nedition = \"2021\"\nlicense = \"MIT\"\ndefault-run = \"handy\"\n\n[profile.dev]\nincremental = true # Compile your binary in smaller steps.\n\n# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html\n\n[lib]\n# The `_lib` suffix may seem redundant but it is necessary\n# to make the lib name unique and wouldn't conflict with the bin name.\n# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519\nname = \"handy_app_lib\"\ncrate-type = [\"staticlib\", \"cdylib\", \"rlib\"]\n\n# [[bin]]\n# name = \"cli\"\n# path = \"src/audio_toolkit/bin/cli.rs\"\n\n[build-dependencies]\ntauri-build = { version = \"2\", features = [] }\nserde_json = \"1\"\nserde = { version = \"1\", features = [\"derive\"] }\n\n[dependencies]\nonce_cell = \"1\"\ntauri = { version = \"2.10.2\", features = [\n  \"protocol-asset\",\n  \"macos-private-api\",\n  \"tray-icon\",\n  'image-png',\n] }\ntauri-plugin-log = \"2.7.1\"\ntauri-plugin-opener = \"2.5.2\"\ntauri-plugin-store = \"2.4.1\"\ntauri-plugin-os = \"2.3.2\"\ntauri-plugin-clipboard-manager = \"2.3.2\"\ntauri-plugin-macos-permissions = \"2.3.0\"\ntauri-plugin-process = \"2.3.1\"\nrusqlite_migration = \"2.3\"\ntauri-plugin-fs = \"2.4.4\"\nserde = { version = \"1\", features = [\"derive\"] }\nserde_json = \"1\"\nrdev = { git = \"https://github.com/rustdesk-org/rdev\" }\ncpal = \"0.16.0\"\nanyhow = \"1.0.95\"\nrubato = \"0.16.2\"\nhound = \"3.5.1\"\nlog = \"0.4.25\"\nenv_filter = \"0.1.0\"\ntokio = \"1.43.0\"\nvad-rs = { git = \"https://github.com/cjpais/vad-rs\", default-features = false }\nenigo = \"0.6.1\"\nrodio = { git = \"https://github.com/cjpais/rodio.git\" }\nreqwest = { version = \"0.12\", features = [\"json\", \"stream\"] }\nfutures-util = \"0.3\"\nrustfft = \"6.4.0\"\nstrsim = \"0.11.0\"\nnatural = \"0.5.0\"\nregex = \"1\"\nchrono = \"0.4\"\nrusqlite = { version = \"0.37\", features = [\"bundled\"] }\ntar = \"0.4.44\"\nflate2 = \"1.0\"\ntranscribe-rs = { version = \"0.3.2\", features = [\"whisper-cpp\", \"onnx\"] }\nhandy-keys = \"0.2.4\"\nferrous-opencc = \"0.2.3\"\nclap = { version = \"4\", features = [\"derive\"] }\nspecta = \"=2.0.0-rc.22\"\nspecta-typescript = \"0.0.9\"\ntauri-specta = { version = \"=2.0.0-rc.21\", features = [\"derive\", \"typescript\"] }\ntauri-plugin-dialog = \"2.6\"\n\n[target.'cfg(unix)'.dependencies]\nsignal-hook = \"0.3\"\n\n[target.'cfg(not(any(target_os = \"android\", target_os = \"ios\")))'.dependencies]\ntauri-plugin-autostart = \"2.5.1\"\ntauri-plugin-global-shortcut = \"2.3.1\"\ntauri-plugin-single-instance = \"2.3.2\"\ntauri-plugin-updater = \"2.10.0\"\n\n[target.'cfg(windows)'.dependencies]\ntranscribe-rs = { version = \"0.3.2\", features = [\"whisper-vulkan\", \"ort-directml\"] }\nwindows = { version = \"0.61.3\", features = [\n  \"Win32_Media_Audio_Endpoints\",\n  \"Win32_System_Com_StructuredStorage\",\n  \"Win32_System_Variant\",\n  \"Win32_Foundation\",\n  \"Win32_UI_WindowsAndMessaging\",\n] }\nwinreg = \"0.55\"\n\n[target.'cfg(target_os = \"macos\")'.dependencies]\ntauri-nspanel = { git = \"https://github.com/ahkohd/tauri-nspanel\", branch = \"v2.1\" }\ntranscribe-rs = { version = \"0.3.2\", features = [\"whisper-metal\"] }\n\n[target.'cfg(target_os = \"linux\")'.dependencies]\ngtk-layer-shell = { version = \"0.8\", features = [\"v0_6\"] }\ngtk = \"0.18\"\ntranscribe-rs = { version = \"0.3.2\", features = [\"whisper-vulkan\"] }\n\n[patch.crates-io]\ntauri-runtime = { git = \"https://github.com/cjpais/tauri.git\", branch = \"handy-2.10.2\" }\ntauri-runtime-wry = { git = \"https://github.com/cjpais/tauri.git\", branch = \"handy-2.10.2\" }\ntauri-utils = { git = \"https://github.com/cjpais/tauri.git\", branch = \"handy-2.10.2\" }\n\n[dev-dependencies]\ntempfile = \"3\"\n\n[profile.release]\nlto = true\ncodegen-units = 1\nstrip = true\npanic = \"unwind\"\n"
  },
  {
    "path": "src-tauri/Entitlements.plist",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\">\n  <dict>\n    <key>com.apple.security.device.microphone</key>\n    <true/>\n    <key>com.apple.security.device.audio-input</key>\n    <true/>\n  </dict>\n</plist>"
  },
  {
    "path": "src-tauri/Info.plist",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\">\n<dict>\n  <key>NSMicrophoneUsageDescription</key>\n  <string>Request microphone access to transcribe audio locally</string>\n</dict>\n</plist>"
  },
  {
    "path": "src-tauri/build.rs",
    "content": "fn main() {\n    #[cfg(all(target_os = \"macos\", target_arch = \"aarch64\"))]\n    build_apple_intelligence_bridge();\n\n    generate_tray_translations();\n\n    tauri_build::build()\n}\n\n/// Generate tray menu translations from frontend locale files.\n///\n/// Source of truth: src/i18n/locales/*/translation.json\n/// The English \"tray\" section defines the struct fields.\nfn generate_tray_translations() {\n    use std::collections::BTreeMap;\n    use std::fs;\n    use std::path::Path;\n\n    let out_dir = std::env::var(\"OUT_DIR\").unwrap();\n    let locales_dir = Path::new(\"../src/i18n/locales\");\n\n    println!(\"cargo:rerun-if-changed=../src/i18n/locales\");\n\n    // Collect all locale translations\n    let mut translations: BTreeMap<String, serde_json::Value> = BTreeMap::new();\n\n    for entry in fs::read_dir(locales_dir).unwrap().flatten() {\n        let path = entry.path();\n        if !path.is_dir() {\n            continue;\n        }\n\n        let lang = path.file_name().unwrap().to_str().unwrap().to_string();\n        let json_path = path.join(\"translation.json\");\n\n        println!(\"cargo:rerun-if-changed={}\", json_path.display());\n\n        let content = fs::read_to_string(&json_path).unwrap();\n        let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();\n\n        if let Some(tray) = parsed.get(\"tray\").cloned() {\n            translations.insert(lang, tray);\n        }\n    }\n\n    // English defines the schema\n    let english = translations.get(\"en\").unwrap().as_object().unwrap();\n    let fields: Vec<_> = english\n        .keys()\n        .map(|k| (camel_to_snake(k), k.clone()))\n        .collect();\n\n    // Generate code\n    let mut out = String::from(\n        \"// Auto-generated from src/i18n/locales/*/translation.json - do not edit\\n\\n\",\n    );\n\n    // Struct\n    out.push_str(\"#[derive(Debug, Clone)]\\npub struct TrayStrings {\\n\");\n    for (rust_field, _) in &fields {\n        out.push_str(&format!(\"    pub {rust_field}: String,\\n\"));\n    }\n    out.push_str(\"}\\n\\n\");\n\n    // Static map\n    out.push_str(\n        \"pub static TRANSLATIONS: Lazy<HashMap<&'static str, TrayStrings>> = Lazy::new(|| {\\n\",\n    );\n    out.push_str(\"    let mut m = HashMap::new();\\n\");\n\n    for (lang, tray) in &translations {\n        out.push_str(&format!(\"    m.insert(\\\"{lang}\\\", TrayStrings {{\\n\"));\n        for (rust_field, json_key) in &fields {\n            let val = tray.get(json_key).and_then(|v| v.as_str()).unwrap_or(\"\");\n            out.push_str(&format!(\n                \"        {rust_field}: \\\"{}\\\".to_string(),\\n\",\n                escape_string(val)\n            ));\n        }\n        out.push_str(\"    });\\n\");\n    }\n\n    out.push_str(\"    m\\n});\\n\");\n\n    fs::write(Path::new(&out_dir).join(\"tray_translations.rs\"), out).unwrap();\n\n    println!(\n        \"cargo:warning=Generated tray translations: {} languages, {} fields\",\n        translations.len(),\n        fields.len()\n    );\n}\n\nfn camel_to_snake(s: &str) -> String {\n    s.chars()\n        .enumerate()\n        .fold(String::new(), |mut acc, (i, c)| {\n            if c.is_uppercase() && i > 0 {\n                acc.push('_');\n            }\n            acc.push(c.to_lowercase().next().unwrap());\n            acc\n        })\n}\n\nfn escape_string(s: &str) -> String {\n    s.replace('\\\\', \"\\\\\\\\\")\n        .replace('\"', \"\\\\\\\"\")\n        .replace('\\n', \"\\\\n\")\n        .replace('\\r', \"\\\\r\")\n        .replace('\\t', \"\\\\t\")\n}\n\n#[cfg(all(target_os = \"macos\", target_arch = \"aarch64\"))]\nfn build_apple_intelligence_bridge() {\n    use std::env;\n    use std::path::{Path, PathBuf};\n    use std::process::Command;\n\n    const REAL_SWIFT_FILE: &str = \"swift/apple_intelligence.swift\";\n    const STUB_SWIFT_FILE: &str = \"swift/apple_intelligence_stub.swift\";\n    const BRIDGE_HEADER: &str = \"swift/apple_intelligence_bridge.h\";\n\n    println!(\"cargo:rerun-if-changed={REAL_SWIFT_FILE}\");\n    println!(\"cargo:rerun-if-changed={STUB_SWIFT_FILE}\");\n    println!(\"cargo:rerun-if-changed={BRIDGE_HEADER}\");\n\n    let out_dir = PathBuf::from(env::var(\"OUT_DIR\").expect(\"OUT_DIR not set\"));\n    let object_path = out_dir.join(\"apple_intelligence.o\");\n    let static_lib_path = out_dir.join(\"libapple_intelligence.a\");\n\n    let sdk_path = String::from_utf8(\n        Command::new(\"xcrun\")\n            .args([\"--sdk\", \"macosx\", \"--show-sdk-path\"])\n            .output()\n            .expect(\"Failed to locate macOS SDK\")\n            .stdout,\n    )\n    .expect(\"SDK path is not valid UTF-8\")\n    .trim()\n    .to_string();\n\n    // Check if the SDK supports FoundationModels (required for Apple Intelligence)\n    let framework_path =\n        Path::new(&sdk_path).join(\"System/Library/Frameworks/FoundationModels.framework\");\n    let has_foundation_models = framework_path.exists();\n\n    let source_file = if has_foundation_models {\n        println!(\"cargo:warning=Building with Apple Intelligence support.\");\n        REAL_SWIFT_FILE\n    } else {\n        println!(\"cargo:warning=Apple Intelligence SDK not found. Building with stubs.\");\n        STUB_SWIFT_FILE\n    };\n\n    if !Path::new(source_file).exists() {\n        panic!(\"Source file {} is missing!\", source_file);\n    }\n\n    let swiftc_path = String::from_utf8(\n        Command::new(\"xcrun\")\n            .args([\"--find\", \"swiftc\"])\n            .output()\n            .expect(\"Failed to locate swiftc\")\n            .stdout,\n    )\n    .expect(\"swiftc path is not valid UTF-8\")\n    .trim()\n    .to_string();\n\n    let toolchain_swift_lib = Path::new(&swiftc_path)\n        .parent()\n        .and_then(|p| p.parent())\n        .map(|root| root.join(\"lib/swift/macosx\"))\n        .expect(\"Unable to determine Swift toolchain lib directory\");\n    let sdk_swift_lib = Path::new(&sdk_path).join(\"usr/lib/swift\");\n\n    // Use macOS 11.0 as deployment target for compatibility\n    // The @available(macOS 26.0, *) checks in Swift handle runtime availability\n    // Weak linking for FoundationModels is handled via cargo:rustc-link-arg below\n    let status = Command::new(\"xcrun\")\n        .args([\n            \"swiftc\",\n            \"-target\",\n            \"arm64-apple-macosx11.0\",\n            \"-sdk\",\n            &sdk_path,\n            \"-O\",\n            \"-import-objc-header\",\n            BRIDGE_HEADER,\n            \"-c\",\n            source_file,\n            \"-o\",\n            object_path\n                .to_str()\n                .expect(\"Failed to convert object path to string\"),\n        ])\n        .status()\n        .expect(\"Failed to invoke swiftc for Apple Intelligence bridge\");\n\n    if !status.success() {\n        panic!(\"swiftc failed to compile {source_file}\");\n    }\n\n    let status = Command::new(\"libtool\")\n        .args([\n            \"-static\",\n            \"-o\",\n            static_lib_path\n                .to_str()\n                .expect(\"Failed to convert static lib path to string\"),\n            object_path\n                .to_str()\n                .expect(\"Failed to convert object path to string\"),\n        ])\n        .status()\n        .expect(\"Failed to create static library for Apple Intelligence bridge\");\n\n    if !status.success() {\n        panic!(\"libtool failed for Apple Intelligence bridge\");\n    }\n\n    println!(\"cargo:rustc-link-search=native={}\", out_dir.display());\n    println!(\"cargo:rustc-link-lib=static=apple_intelligence\");\n    println!(\n        \"cargo:rustc-link-search=native={}\",\n        toolchain_swift_lib.display()\n    );\n    println!(\"cargo:rustc-link-search=native={}\", sdk_swift_lib.display());\n    println!(\"cargo:rustc-link-lib=framework=Foundation\");\n\n    if has_foundation_models {\n        // Use weak linking so the app can launch on systems without FoundationModels\n        println!(\"cargo:rustc-link-arg=-weak_framework\");\n        println!(\"cargo:rustc-link-arg=FoundationModels\");\n    }\n\n    println!(\"cargo:rustc-link-arg=-Wl,-rpath,/usr/lib/swift\");\n}\n"
  },
  {
    "path": "src-tauri/capabilities/default.json",
    "content": "{\n  \"$schema\": \"../gen/schemas/desktop-schema.json\",\n  \"identifier\": \"default\",\n  \"description\": \"Capabilities for the app\",\n  \"windows\": [\"main\", \"recording_overlay\"],\n  \"permissions\": [\n    \"core:default\",\n    \"opener:default\",\n    \"store:default\",\n    \"updater:default\",\n    \"process:default\",\n    \"dialog:default\",\n    \"global-shortcut:allow-is-registered\",\n    \"global-shortcut:allow-register\",\n    \"global-shortcut:allow-unregister\",\n    \"global-shortcut:allow-unregister-all\",\n    \"macos-permissions:default\",\n    \"fs:read-files\",\n    \"fs:allow-resource-read-recursive\",\n    {\n      \"identifier\": \"fs:scope\",\n      \"allow\": [{ \"path\": \"$APPDATA\" }, { \"path\": \"$APPDATA/**/*\" }]\n    }\n  ]\n}\n"
  },
  {
    "path": "src-tauri/capabilities/desktop.json",
    "content": "{\n  \"identifier\": \"desktop-capability\",\n  \"platforms\": [\"macOS\", \"windows\", \"linux\"],\n  \"windows\": [\"main\"],\n  \"permissions\": [\n    \"autostart:default\",\n    \"global-shortcut:default\",\n    \"autostart:default\",\n    \"autostart:default\",\n    \"updater:default\"\n  ]\n}\n"
  },
  {
    "path": "src-tauri/gen/apple/PrivacyInfo.xcprivacy",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\">\n  <dict>\n    <key>NSPrivacyAccessedAPITypes</key>\n    <array>\n      <dict>\n        <key>NSPrivacyAccessedAPIType</key>\n        <string>NSPrivacyAccessedAPICategoryFileTimestamp</string>\n        <key>NSPrivacyAccessedAPITypeReasons</key>\n        <array>\n          <string>C617.1</string>\n        </array>\n      </dict>\n    </array>\n  </dict>\n</plist>\n"
  },
  {
    "path": "src-tauri/nsis/installer.nsi",
    "content": "; Custom NSIS template for Handy with portable mode support.\n; Based on tauri-apps/tauri@tauri-v2.9.1 crates/tauri-bundler/src/bundle/windows/nsis/installer.nsi\n; Portable changes are marked with \"; --- PORTABLE MODE ---\" comments.\n;\n; When upgrading Tauri, diff this file against the new upstream template and\n; merge changes while preserving the portable sections.\n\nUnicode true\nManifestDPIAware true\n; Add in `dpiAwareness` `PerMonitorV2` to manifest for Windows 10 1607+ (note this should not affect lower versions since they should be able to ignore this and pick up `dpiAware` `true` set by `ManifestDPIAware true`)\n; Currently undocumented on NSIS's website but is in the Docs folder of source tree, see\n; https://github.com/kichik/nsis/blob/5fc0b87b819a9eec006df4967d08e522ddd651c9/Docs/src/attributes.but#L286-L300\n; https://github.com/tauri-apps/tauri/pull/10106\nManifestDPIAwareness PerMonitorV2\n\n!if \"{{compression}}\" == \"none\"\n  SetCompress off\n!else\n  ; Set the compression algorithm. We default to LZMA.\n  SetCompressor /SOLID \"{{compression}}\"\n!endif\n\n!include MUI2.nsh\n!include FileFunc.nsh\n!include x64.nsh\n!include WordFunc.nsh\n!include \"utils.nsh\"\n!include \"FileAssociation.nsh\"\n!include \"Win\\COM.nsh\"\n!include \"Win\\Propkey.nsh\"\n!include \"StrFunc.nsh\"\n${StrCase}\n${StrLoc}\n\n{{#if installer_hooks}}\n!include \"{{installer_hooks}}\"\n{{/if}}\n\n!define WEBVIEW2APPGUID \"{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}\"\n\n!define MANUFACTURER \"{{manufacturer}}\"\n!define PRODUCTNAME \"{{product_name}}\"\n!define VERSION \"{{version}}\"\n!define VERSIONWITHBUILD \"{{version_with_build}}\"\n!define HOMEPAGE \"{{homepage}}\"\n!define INSTALLMODE \"{{install_mode}}\"\n!define LICENSE \"{{license}}\"\n!define INSTALLERICON \"{{installer_icon}}\"\n!define SIDEBARIMAGE \"{{sidebar_image}}\"\n!define HEADERIMAGE \"{{header_image}}\"\n!define MAINBINARYNAME \"{{main_binary_name}}\"\n!define MAINBINARYSRCPATH \"{{main_binary_path}}\"\n!define BUNDLEID \"{{bundle_id}}\"\n!define COPYRIGHT \"{{copyright}}\"\n!define OUTFILE \"{{out_file}}\"\n!define ARCH \"{{arch}}\"\n!define ADDITIONALPLUGINSPATH \"{{additional_plugins_path}}\"\n!define ALLOWDOWNGRADES \"{{allow_downgrades}}\"\n!define DISPLAYLANGUAGESELECTOR \"{{display_language_selector}}\"\n!define INSTALLWEBVIEW2MODE \"{{install_webview2_mode}}\"\n!define WEBVIEW2INSTALLERARGS \"{{webview2_installer_args}}\"\n!define WEBVIEW2BOOTSTRAPPERPATH \"{{webview2_bootstrapper_path}}\"\n!define WEBVIEW2INSTALLERPATH \"{{webview2_installer_path}}\"\n!define MINIMUMWEBVIEW2VERSION \"{{minimum_webview2_version}}\"\n!define UNINSTKEY \"Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\${PRODUCTNAME}\"\n!define MANUKEY \"Software\\${MANUFACTURER}\"\n!define MANUPRODUCTKEY \"${MANUKEY}\\${PRODUCTNAME}\"\n!define UNINSTALLERSIGNCOMMAND \"{{uninstaller_sign_cmd}}\"\n!define ESTIMATEDSIZE \"{{estimated_size}}\"\n!define STARTMENUFOLDER \"{{start_menu_folder}}\"\n\nVar PassiveMode\nVar UpdateMode\nVar NoShortcutMode\nVar WixMode\nVar OldMainBinaryName\n\n; --- PORTABLE MODE ---\nVar PortableMode\n\nName \"${PRODUCTNAME}\"\nBrandingText \"${COPYRIGHT}\"\nOutFile \"${OUTFILE}\"\n\n; We don't actually use this value as default install path,\n; it's just for nsis to append the product name folder in the directory selector\n; https://nsis.sourceforge.io/Reference/InstallDir\n!define PLACEHOLDER_INSTALL_DIR \"placeholder\\${PRODUCTNAME}\"\nInstallDir \"${PLACEHOLDER_INSTALL_DIR}\"\n\nVIProductVersion \"${VERSIONWITHBUILD}\"\nVIAddVersionKey \"ProductName\" \"${PRODUCTNAME}\"\nVIAddVersionKey \"FileDescription\" \"${PRODUCTNAME}\"\nVIAddVersionKey \"LegalCopyright\" \"${COPYRIGHT}\"\nVIAddVersionKey \"FileVersion\" \"${VERSION}\"\nVIAddVersionKey \"ProductVersion\" \"${VERSION}\"\n\n# additional plugins\n!addplugindir \"${ADDITIONALPLUGINSPATH}\"\n\n; Uninstaller signing command\n!if \"${UNINSTALLERSIGNCOMMAND}\" != \"\"\n  !uninstfinalize '${UNINSTALLERSIGNCOMMAND}'\n!endif\n\n; Handle install mode, `perUser`, `perMachine` or `both`\n!if \"${INSTALLMODE}\" == \"perMachine\"\n  RequestExecutionLevel admin\n!endif\n\n!if \"${INSTALLMODE}\" == \"currentUser\"\n  RequestExecutionLevel user\n!endif\n\n!if \"${INSTALLMODE}\" == \"both\"\n  !define MULTIUSER_MUI\n  !define MULTIUSER_INSTALLMODE_INSTDIR \"${PRODUCTNAME}\"\n  !define MULTIUSER_INSTALLMODE_COMMANDLINE\n  !if \"${ARCH}\" == \"x64\"\n    !define MULTIUSER_USE_PROGRAMFILES64\n  !else if \"${ARCH}\" == \"arm64\"\n    !define MULTIUSER_USE_PROGRAMFILES64\n  !endif\n  !define MULTIUSER_INSTALLMODE_DEFAULT_REGISTRY_KEY \"${UNINSTKEY}\"\n  !define MULTIUSER_INSTALLMODE_DEFAULT_REGISTRY_VALUENAME \"CurrentUser\"\n  !define MULTIUSER_INSTALLMODEPAGE_SHOWUSERNAME\n  !define MULTIUSER_INSTALLMODE_FUNCTION RestorePreviousInstallLocation\n  !define MULTIUSER_EXECUTIONLEVEL Highest\n  !include MultiUser.nsh\n!endif\n\n; Installer icon\n!if \"${INSTALLERICON}\" != \"\"\n  !define MUI_ICON \"${INSTALLERICON}\"\n!endif\n\n; Installer sidebar image\n!if \"${SIDEBARIMAGE}\" != \"\"\n  !define MUI_WELCOMEFINISHPAGE_BITMAP \"${SIDEBARIMAGE}\"\n!endif\n\n; Installer header image\n!if \"${HEADERIMAGE}\" != \"\"\n  !define MUI_HEADERIMAGE\n  !define MUI_HEADERIMAGE_BITMAP  \"${HEADERIMAGE}\"\n!endif\n\n; Define registry key to store installer language\n!define MUI_LANGDLL_REGISTRY_ROOT \"HKCU\"\n!define MUI_LANGDLL_REGISTRY_KEY \"${MANUPRODUCTKEY}\"\n!define MUI_LANGDLL_REGISTRY_VALUENAME \"Installer Language\"\n\n; Installer pages, must be ordered as they appear\n; 1. Welcome Page\n!define MUI_PAGE_CUSTOMFUNCTION_PRE SkipIfPassive\n!insertmacro MUI_PAGE_WELCOME\n\n; 2. License Page (if defined)\n!if \"${LICENSE}\" != \"\"\n  !define MUI_PAGE_CUSTOMFUNCTION_PRE SkipIfPassive\n  !insertmacro MUI_PAGE_LICENSE \"${LICENSE}\"\n!endif\n\n; 3. Install mode (if it is set to `both`)\n!if \"${INSTALLMODE}\" == \"both\"\n  !define MUI_PAGE_CUSTOMFUNCTION_PRE SkipIfPassive\n  !insertmacro MULTIUSER_PAGE_INSTALLMODE\n!endif\n\n; --- PORTABLE MODE --- 4. Install type selection page (Normal vs Portable)\nVar InstallTypeRadioNormal\nVar InstallTypeRadioPortable\nPage custom PageInstallType PageLeaveInstallType\n\nFunction PageInstallType\n  ; Skip for passive/silent/update modes — portable flag is handled via /PORTABLE\n  ${If} $PassiveMode = 1\n  ${OrIf} ${Silent}\n  ${OrIf} $UpdateMode = 1\n    Abort\n  ${EndIf}\n\n  !insertmacro MUI_HEADER_TEXT \"Choose Install Type\" \"Select how you want to install ${PRODUCTNAME}.\"\n\n  nsDialogs::Create 1018\n  Pop $0\n  ${If} $0 == error\n    Abort\n  ${EndIf}\n\n  ${NSD_CreateLabel} 0 0 100% 24u \"Choose whether to perform a normal installation or a portable installation.\"\n  Pop $0\n\n  ${NSD_CreateRadioButton} 30u 35u -30u 12u \"Normal Installation (recommended)\"\n  Pop $InstallTypeRadioNormal\n\n  ${NSD_CreateLabel} 44u 49u -44u 20u \"Installs to your system with Start Menu shortcuts, uninstaller, and auto-update support.\"\n  Pop $0\n\n  ${NSD_CreateRadioButton} 30u 75u -30u 12u \"Portable Installation\"\n  Pop $InstallTypeRadioPortable\n\n  ${NSD_CreateLabel} 44u 89u -44u 20u \"Self-contained folder with no registry changes, shortcuts, or uninstaller. Data stored next to the app.\"\n  Pop $0\n\n  ; Pre-select based on current state\n  ${If} $PortableMode = 1\n    ${NSD_Check} $InstallTypeRadioPortable\n  ${Else}\n    ${NSD_Check} $InstallTypeRadioNormal\n  ${EndIf}\n\n  nsDialogs::Show\nFunctionEnd\n\nFunction PageLeaveInstallType\n  ${NSD_GetState} $InstallTypeRadioPortable $0\n  ${If} $0 = ${BST_CHECKED}\n    StrCpy $PortableMode 1\n    ; --- PORTABLE MODE --- Switch default directory to Desktop\\Handy for portable\n    ${If} $INSTDIR == \"${PLACEHOLDER_INSTALL_DIR}\"\n    ${OrIf} $INSTDIR == \"$LOCALAPPDATA\\${PRODUCTNAME}\"\n      StrCpy $INSTDIR \"$DESKTOP\\${PRODUCTNAME}\"\n    ${EndIf}\n  ${Else}\n    StrCpy $PortableMode 0\n    ; Restore normal default if user switched back from portable\n    ${If} $INSTDIR == \"$DESKTOP\\${PRODUCTNAME}\"\n      StrCpy $INSTDIR \"$LOCALAPPDATA\\${PRODUCTNAME}\"\n    ${EndIf}\n  ${EndIf}\nFunctionEnd\n; --- END PORTABLE MODE ---\n\n; 5. (was 4) Custom page to ask user if he wants to reinstall/uninstall\n;    only if a previous installation was detected\nVar ReinstallPageCheck\nPage custom PageReinstall PageLeaveReinstall\nFunction PageReinstall\n  ; --- PORTABLE MODE --- Skip reinstall page for portable installs\n  ${If} $PortableMode = 1\n    Abort\n  ${EndIf}\n\n  ; Uninstall previous WiX installation if exists.\n  ;\n  ; A WiX installer stores the installation info in registry\n  ; using a UUID and so we have to loop through all keys under\n  ; `HKLM\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall`\n  ; and check if `DisplayName` and `Publisher` keys match ${PRODUCTNAME} and ${MANUFACTURER}\n  ;\n  ; This has a potential issue that there maybe another installation that matches\n  ; our ${PRODUCTNAME} and ${MANUFACTURER} but wasn't installed by our WiX installer,\n  ; however, this should be fine since the user will have to confirm the uninstallation\n  ; and they can chose to abort it if doesn't make sense.\n  StrCpy $0 0\n  wix_loop:\n    EnumRegKey $1 HKLM \"SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\" $0\n    StrCmp $1 \"\" wix_loop_done ; Exit loop if there is no more keys to loop on\n    IntOp $0 $0 + 1\n    ReadRegStr $R0 HKLM \"SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\$1\" \"DisplayName\"\n    ReadRegStr $R1 HKLM \"SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\$1\" \"Publisher\"\n    StrCmp \"$R0$R1\" \"${PRODUCTNAME}${MANUFACTURER}\" 0 wix_loop\n    ReadRegStr $R0 HKLM \"SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\$1\" \"UninstallString\"\n    ${StrCase} $R1 $R0 \"L\"\n    ${StrLoc} $R0 $R1 \"msiexec\" \">\"\n    StrCmp $R0 0 0 wix_loop_done\n    StrCpy $WixMode 1\n    StrCpy $R6 \"SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\$1\"\n    Goto compare_version\n  wix_loop_done:\n\n  ; Check if there is an existing installation, if not, abort the reinstall page\n  ReadRegStr $R0 SHCTX \"${UNINSTKEY}\" \"\"\n  ReadRegStr $R1 SHCTX \"${UNINSTKEY}\" \"UninstallString\"\n  ${IfThen} \"$R0$R1\" == \"\" ${|} Abort ${|}\n\n  ; Compare this installar version with the existing installation\n  ; and modify the messages presented to the user accordingly\n  compare_version:\n  StrCpy $R4 \"$(older)\"\n  ${If} $WixMode = 1\n    ReadRegStr $R0 HKLM \"$R6\" \"DisplayVersion\"\n  ${Else}\n    ReadRegStr $R0 SHCTX \"${UNINSTKEY}\" \"DisplayVersion\"\n  ${EndIf}\n  ${IfThen} $R0 == \"\" ${|} StrCpy $R4 \"$(unknown)\" ${|}\n\n  nsis_tauri_utils::SemverCompare \"${VERSION}\" $R0\n  Pop $R0\n  ; Reinstalling the same version\n  ${If} $R0 = 0\n    StrCpy $R1 \"$(alreadyInstalledLong)\"\n    StrCpy $R2 \"$(addOrReinstall)\"\n    StrCpy $R3 \"$(uninstallApp)\"\n    !insertmacro MUI_HEADER_TEXT \"$(alreadyInstalled)\" \"$(chooseMaintenanceOption)\"\n  ; Upgrading\n  ${ElseIf} $R0 = 1\n    StrCpy $R1 \"$(olderOrUnknownVersionInstalled)\"\n    StrCpy $R2 \"$(uninstallBeforeInstalling)\"\n    StrCpy $R3 \"$(dontUninstall)\"\n    !insertmacro MUI_HEADER_TEXT \"$(alreadyInstalled)\" \"$(choowHowToInstall)\"\n  ; Downgrading\n  ${ElseIf} $R0 = -1\n    StrCpy $R1 \"$(newerVersionInstalled)\"\n    StrCpy $R2 \"$(uninstallBeforeInstalling)\"\n    !if \"${ALLOWDOWNGRADES}\" == \"true\"\n      StrCpy $R3 \"$(dontUninstall)\"\n    !else\n      StrCpy $R3 \"$(dontUninstallDowngrade)\"\n    !endif\n    !insertmacro MUI_HEADER_TEXT \"$(alreadyInstalled)\" \"$(choowHowToInstall)\"\n  ${Else}\n    Abort\n  ${EndIf}\n\n  ; Skip showing the page if passive\n  ;\n  ; Note that we don't call this earlier at the begining\n  ; of this function because we need to populate some variables\n  ; related to current installed version if detected and whether\n  ; we are downgrading or not.\n  ${If} $PassiveMode = 1\n    Call PageLeaveReinstall\n  ${Else}\n    nsDialogs::Create 1018\n    Pop $R4\n    ${IfThen} $(^RTL) = 1 ${|} nsDialogs::SetRTL $(^RTL) ${|}\n\n    ${NSD_CreateLabel} 0 0 100% 24u $R1\n    Pop $R1\n\n    ${NSD_CreateRadioButton} 30u 50u -30u 8u $R2\n    Pop $R2\n    ${NSD_OnClick} $R2 PageReinstallUpdateSelection\n\n    ${NSD_CreateRadioButton} 30u 70u -30u 8u $R3\n    Pop $R3\n    ; Disable this radio button if downgrading and downgrades are disabled\n    !if \"${ALLOWDOWNGRADES}\" == \"false\"\n      ${IfThen} $R0 = -1 ${|} EnableWindow $R3 0 ${|}\n    !endif\n    ${NSD_OnClick} $R3 PageReinstallUpdateSelection\n\n    ; Check the first radio button if this the first time\n    ; we enter this page or if the second button wasn't\n    ; selected the last time we were on this page\n    ${If} $ReinstallPageCheck <> 2\n      SendMessage $R2 ${BM_SETCHECK} ${BST_CHECKED} 0\n    ${Else}\n      SendMessage $R3 ${BM_SETCHECK} ${BST_CHECKED} 0\n    ${EndIf}\n\n    ${NSD_SetFocus} $R2\n    nsDialogs::Show\n  ${EndIf}\nFunctionEnd\nFunction PageReinstallUpdateSelection\n  ${NSD_GetState} $R2 $R1\n  ${If} $R1 == ${BST_CHECKED}\n    StrCpy $ReinstallPageCheck 1\n  ${Else}\n    StrCpy $ReinstallPageCheck 2\n  ${EndIf}\nFunctionEnd\nFunction PageLeaveReinstall\n  ${NSD_GetState} $R2 $R1\n\n  ; If migrating from Wix, always uninstall\n  ${If} $WixMode = 1\n    Goto reinst_uninstall\n  ${EndIf}\n\n  ; In update mode, always proceeds without uninstalling\n  ${If} $UpdateMode = 1\n    Goto reinst_done\n  ${EndIf}\n\n  ; $R0 holds whether same(0)/upgrading(1)/downgrading(-1) version\n  ; $R1 holds the radio buttons state:\n  ;   1 => first choice was selected\n  ;   0 => second choice was selected\n  ${If} $R0 = 0 ; Same version, proceed\n    ${If} $R1 = 1              ; User chose to add/reinstall\n      Goto reinst_done\n    ${Else}                    ; User chose to uninstall\n      Goto reinst_uninstall\n    ${EndIf}\n  ${ElseIf} $R0 = 1 ; Upgrading\n    ${If} $R1 = 1              ; User chose to uninstall\n      Goto reinst_uninstall\n    ${Else}\n      Goto reinst_done         ; User chose NOT to uninstall\n    ${EndIf}\n  ${ElseIf} $R0 = -1 ; Downgrading\n    ${If} $R1 = 1              ; User chose to uninstall\n      Goto reinst_uninstall\n    ${Else}\n      Goto reinst_done         ; User chose NOT to uninstall\n    ${EndIf}\n  ${EndIf}\n\n  reinst_uninstall:\n    HideWindow\n    ClearErrors\n\n    ${If} $WixMode = 1\n      ReadRegStr $R1 HKLM \"$R6\" \"UninstallString\"\n      ExecWait '$R1' $0\n    ${Else}\n      ReadRegStr $4 SHCTX \"${MANUPRODUCTKEY}\" \"\"\n      ReadRegStr $R1 SHCTX \"${UNINSTKEY}\" \"UninstallString\"\n      ${IfThen} $UpdateMode = 1 ${|} StrCpy $R1 \"$R1 /UPDATE\" ${|} ; append /UPDATE\n      ${IfThen} $PassiveMode = 1 ${|} StrCpy $R1 \"$R1 /P\" ${|} ; append /P\n      StrCpy $R1 \"$R1 _?=$4\" ; append uninstall directory\n      ExecWait '$R1' $0\n    ${EndIf}\n\n    BringToFront\n\n    ${IfThen} ${Errors} ${|} StrCpy $0 2 ${|} ; ExecWait failed, set fake exit code\n\n    ${If} $0 <> 0\n    ${OrIf} ${FileExists} \"$INSTDIR\\${MAINBINARYNAME}.exe\"\n      ; User cancelled wix uninstaller? return to select un/reinstall page\n      ${If} $WixMode = 1\n      ${AndIf} $0 = 1602\n        Abort\n      ${EndIf}\n\n      ; User cancelled NSIS uninstaller? return to select un/reinstall page\n      ${If} $0 = 1\n        Abort\n      ${EndIf}\n\n      ; Other erros? show generic error message and return to select un/reinstall page\n      MessageBox MB_ICONEXCLAMATION \"$(unableToUninstall)\"\n      Abort\n    ${EndIf}\n  reinst_done:\nFunctionEnd\n\n; 5. Choose install directory page\n!define MUI_PAGE_CUSTOMFUNCTION_PRE SkipIfPassive\n!insertmacro MUI_PAGE_DIRECTORY\n\n; 6. Start menu shortcut page\nVar AppStartMenuFolder\n!if \"${STARTMENUFOLDER}\" != \"\"\n  ; --- PORTABLE MODE --- Also skip start menu page for portable installs\n  !define MUI_PAGE_CUSTOMFUNCTION_PRE SkipIfPassiveOrPortable\n  !define MUI_STARTMENUPAGE_DEFAULTFOLDER \"${STARTMENUFOLDER}\"\n!else\n  !define MUI_PAGE_CUSTOMFUNCTION_PRE Skip\n!endif\n!insertmacro MUI_PAGE_STARTMENU Application $AppStartMenuFolder\n\n; 7. Installation page\n!insertmacro MUI_PAGE_INSTFILES\n\n; 8. Finish page\n;\n; Don't auto jump to finish page after installation page,\n; because the installation page has useful info that can be used debug any issues with the installer.\n!define MUI_FINISHPAGE_NOAUTOCLOSE\n; Use show readme button in the finish page as a button create a desktop shortcut\n!define MUI_FINISHPAGE_SHOWREADME\n!define MUI_FINISHPAGE_SHOWREADME_TEXT \"$(createDesktop)\"\n!define MUI_FINISHPAGE_SHOWREADME_FUNCTION CreateOrUpdateDesktopShortcut\n; Show run app after installation.\n!define MUI_FINISHPAGE_RUN\n!define MUI_FINISHPAGE_RUN_FUNCTION RunMainBinary\n!define MUI_PAGE_CUSTOMFUNCTION_PRE SkipIfPassive\n!insertmacro MUI_PAGE_FINISH\n\nFunction RunMainBinary\n  nsis_tauri_utils::RunAsUser \"$INSTDIR\\${MAINBINARYNAME}.exe\" \"\"\nFunctionEnd\n\n; Uninstaller Pages\n; 1. Confirm uninstall page\nVar DeleteAppDataCheckbox\nVar DeleteAppDataCheckboxState\n!define /ifndef WS_EX_LAYOUTRTL         0x00400000\n!define MUI_PAGE_CUSTOMFUNCTION_SHOW un.ConfirmShow\nFunction un.ConfirmShow ; Add add a `Delete app data` check box\n  ; $1 inner dialog HWND\n  ; $2 window DPI\n  ; $3 style\n  ; $4 x\n  ; $5 y\n  ; $6 width\n  ; $7 height\n  FindWindow $1 \"#32770\" \"\" $HWNDPARENT ; Find inner dialog\n  System::Call \"user32::GetDpiForWindow(p r1) i .r2\"\n  ${If} $(^RTL) = 1\n    StrCpy $3 \"${__NSD_CheckBox_EXSTYLE} | ${WS_EX_LAYOUTRTL}\"\n    IntOp $4 50 * $2\n  ${Else}\n    StrCpy $3 \"${__NSD_CheckBox_EXSTYLE}\"\n    IntOp $4 0 * $2\n  ${EndIf}\n  IntOp $5 100 * $2\n  IntOp $6 400 * $2\n  IntOp $7 25 * $2\n  IntOp $4 $4 / 96\n  IntOp $5 $5 / 96\n  IntOp $6 $6 / 96\n  IntOp $7 $7 / 96\n  System::Call 'user32::CreateWindowEx(i r3, w \"${__NSD_CheckBox_CLASS}\", w \"$(deleteAppData)\", i ${__NSD_CheckBox_STYLE}, i r4, i r5, i r6, i r7, p r1, i0, i0, i0) i .s'\n  Pop $DeleteAppDataCheckbox\n  SendMessage $HWNDPARENT ${WM_GETFONT} 0 0 $1\n  SendMessage $DeleteAppDataCheckbox ${WM_SETFONT} $1 1\nFunctionEnd\n!define MUI_PAGE_CUSTOMFUNCTION_LEAVE un.ConfirmLeave\nFunction un.ConfirmLeave\n  SendMessage $DeleteAppDataCheckbox ${BM_GETCHECK} 0 0 $DeleteAppDataCheckboxState\nFunctionEnd\n!define MUI_PAGE_CUSTOMFUNCTION_PRE un.SkipIfPassive\n!insertmacro MUI_UNPAGE_CONFIRM\n\n; 2. Uninstalling Page\n!insertmacro MUI_UNPAGE_INSTFILES\n\n;Languages\n{{#each languages}}\n!insertmacro MUI_LANGUAGE \"{{this}}\"\n{{/each}}\n!insertmacro MUI_RESERVEFILE_LANGDLL\n{{#each language_files}}\n  !include \"{{this}}\"\n{{/each}}\n\nFunction .onInit\n  ${GetOptions} $CMDLINE \"/P\" $PassiveMode\n  ${IfNot} ${Errors}\n    StrCpy $PassiveMode 1\n  ${EndIf}\n\n  ${GetOptions} $CMDLINE \"/NS\" $NoShortcutMode\n  ${IfNot} ${Errors}\n    StrCpy $NoShortcutMode 1\n  ${EndIf}\n\n  ${GetOptions} $CMDLINE \"/UPDATE\" $UpdateMode\n  ${IfNot} ${Errors}\n    StrCpy $UpdateMode 1\n  ${EndIf}\n\n  ; --- PORTABLE MODE --- Parse /PORTABLE flag for silent/passive installs\n  ${GetOptions} $CMDLINE \"/PORTABLE\" $PortableMode\n  ${IfNot} ${Errors}\n    StrCpy $PortableMode 1\n  ${EndIf}\n\n  !if \"${DISPLAYLANGUAGESELECTOR}\" == \"true\"\n    !insertmacro MUI_LANGDLL_DISPLAY\n  !endif\n\n  !insertmacro SetContext\n\n  ${If} $INSTDIR == \"${PLACEHOLDER_INSTALL_DIR}\"\n    ; Set default install location\n    !if \"${INSTALLMODE}\" == \"perMachine\"\n      ${If} ${RunningX64}\n        !if \"${ARCH}\" == \"x64\"\n          StrCpy $INSTDIR \"$PROGRAMFILES64\\${PRODUCTNAME}\"\n        !else if \"${ARCH}\" == \"arm64\"\n          StrCpy $INSTDIR \"$PROGRAMFILES64\\${PRODUCTNAME}\"\n        !else\n          StrCpy $INSTDIR \"$PROGRAMFILES\\${PRODUCTNAME}\"\n        !endif\n      ${Else}\n        StrCpy $INSTDIR \"$PROGRAMFILES\\${PRODUCTNAME}\"\n      ${EndIf}\n    !else if \"${INSTALLMODE}\" == \"currentUser\"\n      StrCpy $INSTDIR \"$LOCALAPPDATA\\${PRODUCTNAME}\"\n    !endif\n\n    ; --- PORTABLE MODE --- Override default dir for silent/passive portable installs\n    ${If} $PortableMode = 1\n      StrCpy $INSTDIR \"$DESKTOP\\${PRODUCTNAME}\"\n    ${Else}\n      Call RestorePreviousInstallLocation\n    ${EndIf}\n  ${EndIf}\n\n\n  ; --- PORTABLE MODE --- Auto-detect portable mode during updates.\n  ; If the target directory already has a portable marker file, preserve\n  ; portable mode so the Tauri updater works without needing /PORTABLE.\n  ${If} $PortableMode <> 1\n  ${AndIf} ${FileExists} \"$INSTDIR\\portable\"\n    StrCpy $PortableMode 1\n  ${EndIf}\n\n  !if \"${INSTALLMODE}\" == \"both\"\n    !insertmacro MULTIUSER_INIT\n  !endif\nFunctionEnd\n\n\nSection EarlyChecks\n  ; Abort silent installer if downgrades is disabled\n  !if \"${ALLOWDOWNGRADES}\" == \"false\"\n  ${If} ${Silent}\n    ; If downgrading\n    ${If} $R0 = -1\n      System::Call 'kernel32::AttachConsole(i -1)i.r0'\n      ${If} $0 <> 0\n        System::Call 'kernel32::GetStdHandle(i -11)i.r0'\n        System::call 'kernel32::SetConsoleTextAttribute(i r0, i 0x0004)' ; set red color\n        FileWrite $0 \"$(silentDowngrades)\"\n      ${EndIf}\n      Abort\n    ${EndIf}\n  ${EndIf}\n  !endif\n\nSectionEnd\n\nSection WebView2\n  ; Check if Webview2 is already installed and skip this section\n  ${If} ${RunningX64}\n    ReadRegStr $4 HKLM \"SOFTWARE\\WOW6432Node\\Microsoft\\EdgeUpdate\\Clients\\${WEBVIEW2APPGUID}\" \"pv\"\n  ${Else}\n    ReadRegStr $4 HKLM \"SOFTWARE\\Microsoft\\EdgeUpdate\\Clients\\${WEBVIEW2APPGUID}\" \"pv\"\n  ${EndIf}\n  ${If} $4 == \"\"\n    ReadRegStr $4 HKCU \"SOFTWARE\\Microsoft\\EdgeUpdate\\Clients\\${WEBVIEW2APPGUID}\" \"pv\"\n  ${EndIf}\n\n  ${If} $4 == \"\"\n    ; Webview2 installation\n    ;\n    ; Skip if updating\n    ${If} $UpdateMode <> 1\n      !if \"${INSTALLWEBVIEW2MODE}\" == \"downloadBootstrapper\"\n        Delete \"$TEMP\\MicrosoftEdgeWebview2Setup.exe\"\n        DetailPrint \"$(webview2Downloading)\"\n        NSISdl::download \"https://go.microsoft.com/fwlink/p/?LinkId=2124703\" \"$TEMP\\MicrosoftEdgeWebview2Setup.exe\"\n        Pop $0\n        ${If} $0 == \"success\"\n          DetailPrint \"$(webview2DownloadSuccess)\"\n        ${Else}\n          DetailPrint \"$(webview2DownloadError)\"\n          Abort \"$(webview2AbortError)\"\n        ${EndIf}\n        StrCpy $6 \"$TEMP\\MicrosoftEdgeWebview2Setup.exe\"\n        Goto install_webview2\n      !endif\n\n      !if \"${INSTALLWEBVIEW2MODE}\" == \"embedBootstrapper\"\n        Delete \"$TEMP\\MicrosoftEdgeWebview2Setup.exe\"\n        File \"/oname=$TEMP\\MicrosoftEdgeWebview2Setup.exe\" \"${WEBVIEW2BOOTSTRAPPERPATH}\"\n        DetailPrint \"$(installingWebview2)\"\n        StrCpy $6 \"$TEMP\\MicrosoftEdgeWebview2Setup.exe\"\n        Goto install_webview2\n      !endif\n\n      !if \"${INSTALLWEBVIEW2MODE}\" == \"offlineInstaller\"\n        Delete \"$TEMP\\MicrosoftEdgeWebView2RuntimeInstaller.exe\"\n        File \"/oname=$TEMP\\MicrosoftEdgeWebView2RuntimeInstaller.exe\" \"${WEBVIEW2INSTALLERPATH}\"\n        DetailPrint \"$(installingWebview2)\"\n        StrCpy $6 \"$TEMP\\MicrosoftEdgeWebView2RuntimeInstaller.exe\"\n        Goto install_webview2\n      !endif\n\n      Goto webview2_done\n\n      install_webview2:\n        DetailPrint \"$(installingWebview2)\"\n        ; $6 holds the path to the webview2 installer\n        ExecWait \"$6 ${WEBVIEW2INSTALLERARGS} /install\" $1\n        ${If} $1 = 0\n          DetailPrint \"$(webview2InstallSuccess)\"\n        ${Else}\n          DetailPrint \"$(webview2InstallError)\"\n          Abort \"$(webview2AbortError)\"\n        ${EndIf}\n      webview2_done:\n    ${EndIf}\n  ${Else}\n    !if \"${MINIMUMWEBVIEW2VERSION}\" != \"\"\n      ${VersionCompare} \"${MINIMUMWEBVIEW2VERSION}\" \"$4\" $R0\n      ${If} $R0 = 1\n        update_webview:\n          DetailPrint \"$(installingWebview2)\"\n          ${If} ${RunningX64}\n            ReadRegStr $R1 HKLM \"SOFTWARE\\WOW6432Node\\Microsoft\\EdgeUpdate\" \"path\"\n          ${Else}\n            ReadRegStr $R1 HKLM \"SOFTWARE\\Microsoft\\EdgeUpdate\" \"path\"\n          ${EndIf}\n          ${If} $R1 == \"\"\n            ReadRegStr $R1 HKCU \"SOFTWARE\\Microsoft\\EdgeUpdate\" \"path\"\n          ${EndIf}\n          ${If} $R1 != \"\"\n            ; Chromium updater docs: https://source.chromium.org/chromium/chromium/src/+/main:docs/updater/user_manual.md\n            ; Modified from \"HKEY_LOCAL_MACHINE\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\Microsoft EdgeWebView\\ModifyPath\"\n            ExecWait `\"$R1\" /install appguid=${WEBVIEW2APPGUID}&needsadmin=true` $1\n            ${If} $1 = 0\n              DetailPrint \"$(webview2InstallSuccess)\"\n            ${Else}\n              MessageBox MB_ICONEXCLAMATION|MB_ABORTRETRYIGNORE \"$(webview2InstallError)\" IDIGNORE ignore IDRETRY update_webview\n              Quit\n              ignore:\n            ${EndIf}\n          ${EndIf}\n      ${EndIf}\n    !endif\n  ${EndIf}\nSectionEnd\n\nSection Install\n  SetOutPath $INSTDIR\n\n  !ifmacrodef NSIS_HOOK_PREINSTALL\n    !insertmacro NSIS_HOOK_PREINSTALL\n  !endif\n\n  !insertmacro CheckIfAppIsRunning \"${MAINBINARYNAME}.exe\" \"${PRODUCTNAME}\"\n\n  ; Copy main executable\n  File \"${MAINBINARYSRCPATH}\"\n\n  ; Copy resources\n  {{#each resources_dirs}}\n    CreateDirectory \"$INSTDIR\\\\{{this}}\"\n  {{/each}}\n  {{#each resources}}\n    File /a \"/oname={{this.[1]}}\" \"{{no-escape @key}}\"\n  {{/each}}\n\n  ; Copy external binaries\n  {{#each binaries}}\n    File /a \"/oname={{this}}\" \"{{no-escape @key}}\"\n  {{/each}}\n\n  ; --- PORTABLE MODE --- Skip file associations and deep links for portable installs\n  ${If} $PortableMode <> 1\n    ; Create file associations\n    {{#each file_associations as |association| ~}}\n      {{#each association.ext as |ext| ~}}\n         !insertmacro APP_ASSOCIATE \"{{ext}}\" \"{{or association.name ext}}\" \"{{association-description association.description ext}}\" \"$INSTDIR\\${MAINBINARYNAME}.exe,0\" \"Open with ${PRODUCTNAME}\" \"$INSTDIR\\${MAINBINARYNAME}.exe $\\\"%1$\\\"\"\n      {{/each}}\n    {{/each}}\n\n    ; Register deep links\n    {{#each deep_link_protocols as |protocol| ~}}\n      WriteRegStr SHCTX \"Software\\Classes\\\\{{protocol}}\" \"URL Protocol\" \"\"\n      WriteRegStr SHCTX \"Software\\Classes\\\\{{protocol}}\" \"\" \"URL:${BUNDLEID} protocol\"\n      WriteRegStr SHCTX \"Software\\Classes\\\\{{protocol}}\\DefaultIcon\" \"\" \"$\\\"$INSTDIR\\${MAINBINARYNAME}.exe$\\\",0\"\n      WriteRegStr SHCTX \"Software\\Classes\\\\{{protocol}}\\shell\\open\\command\" \"\" \"$\\\"$INSTDIR\\${MAINBINARYNAME}.exe$\\\" $\\\"%1$\\\"\"\n    {{/each}}\n  ${EndIf}\n\n  ; --- PORTABLE MODE --- Create portable marker and Data directory\n  ${If} $PortableMode = 1\n    FileOpen $0 \"$INSTDIR\\portable\" w\n    FileClose $0\n    CreateDirectory \"$INSTDIR\\Data\"\n    DetailPrint \"Portable mode: created marker file and Data directory.\"\n  ${EndIf}\n\n  ; --- PORTABLE MODE --- Skip uninstaller, registry, and shortcuts for portable installs\n  ${If} $PortableMode <> 1\n    ; Create uninstaller\n    WriteUninstaller \"$INSTDIR\\uninstall.exe\"\n\n    ; Save $INSTDIR in registry for future installations\n    WriteRegStr SHCTX \"${MANUPRODUCTKEY}\" \"\" $INSTDIR\n\n    !if \"${INSTALLMODE}\" == \"both\"\n      ; Save install mode to be selected by default for the next installation such as updating\n      ; or when uninstalling\n      WriteRegStr SHCTX \"${UNINSTKEY}\" $MultiUser.InstallMode 1\n    !endif\n\n    ; Remove old main binary if it doesn't match new main binary name\n    ReadRegStr $OldMainBinaryName SHCTX \"${UNINSTKEY}\" \"MainBinaryName\"\n    ${If} $OldMainBinaryName != \"\"\n    ${AndIf} $OldMainBinaryName != \"${MAINBINARYNAME}.exe\"\n      Delete \"$INSTDIR\\$OldMainBinaryName\"\n    ${EndIf}\n\n    ; Save current MAINBINARYNAME for future updates\n    WriteRegStr SHCTX \"${UNINSTKEY}\" \"MainBinaryName\" \"${MAINBINARYNAME}.exe\"\n\n    ; Registry information for add/remove programs\n    WriteRegStr SHCTX \"${UNINSTKEY}\" \"DisplayName\" \"${PRODUCTNAME}\"\n    WriteRegStr SHCTX \"${UNINSTKEY}\" \"DisplayIcon\" \"$\\\"$INSTDIR\\${MAINBINARYNAME}.exe$\\\"\"\n    WriteRegStr SHCTX \"${UNINSTKEY}\" \"DisplayVersion\" \"${VERSION}\"\n    WriteRegStr SHCTX \"${UNINSTKEY}\" \"Publisher\" \"${MANUFACTURER}\"\n    WriteRegStr SHCTX \"${UNINSTKEY}\" \"InstallLocation\" \"$\\\"$INSTDIR$\\\"\"\n    WriteRegStr SHCTX \"${UNINSTKEY}\" \"UninstallString\" \"$\\\"$INSTDIR\\uninstall.exe$\\\"\"\n    WriteRegDWORD SHCTX \"${UNINSTKEY}\" \"NoModify\" \"1\"\n    WriteRegDWORD SHCTX \"${UNINSTKEY}\" \"NoRepair\" \"1\"\n\n    ${GetSize} \"$INSTDIR\" \"/M=uninstall.exe /S=0K /G=0\" $0 $1 $2\n    IntOp $0 $0 + ${ESTIMATEDSIZE}\n    IntFmt $0 \"0x%08X\" $0\n    WriteRegDWORD SHCTX \"${UNINSTKEY}\" \"EstimatedSize\" \"$0\"\n\n    !if \"${HOMEPAGE}\" != \"\"\n      WriteRegStr SHCTX \"${UNINSTKEY}\" \"URLInfoAbout\" \"${HOMEPAGE}\"\n      WriteRegStr SHCTX \"${UNINSTKEY}\" \"URLUpdateInfo\" \"${HOMEPAGE}\"\n      WriteRegStr SHCTX \"${UNINSTKEY}\" \"HelpLink\" \"${HOMEPAGE}\"\n    !endif\n\n    ; Create start menu shortcut\n    !insertmacro MUI_STARTMENU_WRITE_BEGIN Application\n      Call CreateOrUpdateStartMenuShortcut\n    !insertmacro MUI_STARTMENU_WRITE_END\n\n    ; Create desktop shortcut for silent and passive installers\n    ; because finish page will be skipped\n    ${If} $PassiveMode = 1\n    ${OrIf} ${Silent}\n      Call CreateOrUpdateDesktopShortcut\n    ${EndIf}\n  ${EndIf} ; --- END PORTABLE MODE guard ---\n\n  !ifmacrodef NSIS_HOOK_POSTINSTALL\n    !insertmacro NSIS_HOOK_POSTINSTALL\n  !endif\n\n  ; Auto close this page for passive mode\n  ${If} $PassiveMode = 1\n    SetAutoClose true\n  ${EndIf}\nSectionEnd\n\nFunction .onInstSuccess\n  ; Check for `/R` flag only in silent and passive installers because\n  ; GUI installer has a toggle for the user to (re)start the app\n  ${If} $PassiveMode = 1\n  ${OrIf} ${Silent}\n    ${GetOptions} $CMDLINE \"/R\" $R0\n    ${IfNot} ${Errors}\n      ${GetOptions} $CMDLINE \"/ARGS\" $R0\n      nsis_tauri_utils::RunAsUser \"$INSTDIR\\${MAINBINARYNAME}.exe\" \"$R0\"\n    ${EndIf}\n  ${EndIf}\nFunctionEnd\n\nFunction un.onInit\n  !insertmacro SetContext\n\n  !if \"${INSTALLMODE}\" == \"both\"\n    !insertmacro MULTIUSER_UNINIT\n  !endif\n\n  !insertmacro MUI_UNGETLANGUAGE\n\n  ${GetOptions} $CMDLINE \"/P\" $PassiveMode\n  ${IfNot} ${Errors}\n    StrCpy $PassiveMode 1\n  ${EndIf}\n\n  ${GetOptions} $CMDLINE \"/UPDATE\" $UpdateMode\n  ${IfNot} ${Errors}\n    StrCpy $UpdateMode 1\n  ${EndIf}\nFunctionEnd\n\nSection Uninstall\n\n  !ifmacrodef NSIS_HOOK_PREUNINSTALL\n    !insertmacro NSIS_HOOK_PREUNINSTALL\n  !endif\n\n  !insertmacro CheckIfAppIsRunning \"${MAINBINARYNAME}.exe\" \"${PRODUCTNAME}\"\n\n  ; Delete the app directory and its content from disk\n  ; Copy main executable\n  Delete \"$INSTDIR\\${MAINBINARYNAME}.exe\"\n\n  ; Delete resources\n  {{#each resources}}\n    Delete \"$INSTDIR\\\\{{this.[1]}}\"\n  {{/each}}\n\n  ; Delete external binaries\n  {{#each binaries}}\n    Delete \"$INSTDIR\\\\{{this}}\"\n  {{/each}}\n\n  ; Delete app associations\n  {{#each file_associations as |association| ~}}\n    {{#each association.ext as |ext| ~}}\n      !insertmacro APP_UNASSOCIATE \"{{ext}}\" \"{{or association.name ext}}\"\n    {{/each}}\n  {{/each}}\n\n  ; Delete deep links\n  {{#each deep_link_protocols as |protocol| ~}}\n    ReadRegStr $R7 SHCTX \"Software\\Classes\\\\{{protocol}}\\shell\\open\\command\" \"\"\n    ${If} $R7 == \"$\\\"$INSTDIR\\${MAINBINARYNAME}.exe$\\\" $\\\"%1$\\\"\"\n      DeleteRegKey SHCTX \"Software\\Classes\\\\{{protocol}}\"\n    ${EndIf}\n  {{/each}}\n\n\n  ; Delete uninstaller\n  Delete \"$INSTDIR\\uninstall.exe\"\n\n  {{#each resources_ancestors}}\n  RMDir /REBOOTOK \"$INSTDIR\\\\{{this}}\"\n  {{/each}}\n  RMDir \"$INSTDIR\"\n\n  ; Remove shortcuts if not updating\n  ${If} $UpdateMode <> 1\n    !insertmacro DeleteAppUserModelId\n\n    ; Remove start menu shortcut\n    !insertmacro MUI_STARTMENU_GETFOLDER Application $AppStartMenuFolder\n    !insertmacro IsShortcutTarget \"$SMPROGRAMS\\$AppStartMenuFolder\\${PRODUCTNAME}.lnk\" \"$INSTDIR\\${MAINBINARYNAME}.exe\"\n    Pop $0\n    ${If} $0 = 1\n      !insertmacro UnpinShortcut \"$SMPROGRAMS\\$AppStartMenuFolder\\${PRODUCTNAME}.lnk\"\n      Delete \"$SMPROGRAMS\\$AppStartMenuFolder\\${PRODUCTNAME}.lnk\"\n      RMDir \"$SMPROGRAMS\\$AppStartMenuFolder\"\n    ${EndIf}\n    !insertmacro IsShortcutTarget \"$SMPROGRAMS\\${PRODUCTNAME}.lnk\" \"$INSTDIR\\${MAINBINARYNAME}.exe\"\n    Pop $0\n    ${If} $0 = 1\n      !insertmacro UnpinShortcut \"$SMPROGRAMS\\${PRODUCTNAME}.lnk\"\n      Delete \"$SMPROGRAMS\\${PRODUCTNAME}.lnk\"\n    ${EndIf}\n\n    ; Remove desktop shortcuts\n    !insertmacro IsShortcutTarget \"$DESKTOP\\${PRODUCTNAME}.lnk\" \"$INSTDIR\\${MAINBINARYNAME}.exe\"\n    Pop $0\n    ${If} $0 = 1\n      !insertmacro UnpinShortcut \"$DESKTOP\\${PRODUCTNAME}.lnk\"\n      Delete \"$DESKTOP\\${PRODUCTNAME}.lnk\"\n    ${EndIf}\n  ${EndIf}\n\n  ; Remove registry information for add/remove programs\n  !if \"${INSTALLMODE}\" == \"both\"\n    DeleteRegKey SHCTX \"${UNINSTKEY}\"\n  !else if \"${INSTALLMODE}\" == \"perMachine\"\n    DeleteRegKey HKLM \"${UNINSTKEY}\"\n  !else\n    DeleteRegKey HKCU \"${UNINSTKEY}\"\n  !endif\n\n  ; Removes the Autostart entry for ${PRODUCTNAME} from the HKCU Run key if it exists.\n  ; This ensures the program does not launch automatically after uninstallation if it exists.\n  ; If it doesn't exist, it does nothing.\n  ; We do this when not updating (to preserve the registry value on updates)\n  ${If} $UpdateMode <> 1\n    DeleteRegValue HKCU \"Software\\Microsoft\\Windows\\CurrentVersion\\Run\" \"${PRODUCTNAME}\"\n  ${EndIf}\n\n  ; Delete app data if the checkbox is selected\n  ; and if not updating\n  ${If} $DeleteAppDataCheckboxState = 1\n  ${AndIf} $UpdateMode <> 1\n    ; Clear the install location $INSTDIR from registry\n    DeleteRegKey SHCTX \"${MANUPRODUCTKEY}\"\n    DeleteRegKey /ifempty SHCTX \"${MANUKEY}\"\n\n    ; Clear the install language from registry\n    DeleteRegValue HKCU \"${MANUPRODUCTKEY}\" \"Installer Language\"\n    DeleteRegKey /ifempty HKCU \"${MANUPRODUCTKEY}\"\n    DeleteRegKey /ifempty HKCU \"${MANUKEY}\"\n\n    SetShellVarContext current\n    RmDir /r \"$APPDATA\\${BUNDLEID}\"\n    RmDir /r \"$LOCALAPPDATA\\${BUNDLEID}\"\n  ${EndIf}\n\n  !ifmacrodef NSIS_HOOK_POSTUNINSTALL\n    !insertmacro NSIS_HOOK_POSTUNINSTALL\n  !endif\n\n  ; Auto close if passive mode or updating\n  ${If} $PassiveMode = 1\n  ${OrIf} $UpdateMode = 1\n    SetAutoClose true\n  ${EndIf}\nSectionEnd\n\nFunction RestorePreviousInstallLocation\n  ReadRegStr $4 SHCTX \"${MANUPRODUCTKEY}\" \"\"\n  StrCmp $4 \"\" +2 0\n    StrCpy $INSTDIR $4\nFunctionEnd\n\nFunction Skip\n  Abort\nFunctionEnd\n\nFunction SkipIfPassive\n  ${IfThen} $PassiveMode = 1  ${|} Abort ${|}\nFunctionEnd\n\n; --- PORTABLE MODE ---\nFunction SkipIfPassiveOrPortable\n  ${IfThen} $PassiveMode = 1  ${|} Abort ${|}\n  ${IfThen} $PortableMode = 1  ${|} Abort ${|}\nFunctionEnd\nFunction un.SkipIfPassive\n  ${IfThen} $PassiveMode = 1  ${|} Abort ${|}\nFunctionEnd\n\nFunction CreateOrUpdateStartMenuShortcut\n  ; We used to use product name as MAINBINARYNAME\n  ; migrate old shortcuts to target the new MAINBINARYNAME\n  StrCpy $R0 0\n\n  !insertmacro IsShortcutTarget \"$SMPROGRAMS\\$AppStartMenuFolder\\${PRODUCTNAME}.lnk\" \"$INSTDIR\\$OldMainBinaryName\"\n  Pop $0\n  ${If} $0 = 1\n    !insertmacro SetShortcutTarget \"$SMPROGRAMS\\$AppStartMenuFolder\\${PRODUCTNAME}.lnk\" \"$INSTDIR\\${MAINBINARYNAME}.exe\"\n    StrCpy $R0 1\n  ${EndIf}\n\n  !insertmacro IsShortcutTarget \"$SMPROGRAMS\\${PRODUCTNAME}.lnk\" \"$INSTDIR\\$OldMainBinaryName\"\n  Pop $0\n  ${If} $0 = 1\n    !insertmacro SetShortcutTarget \"$SMPROGRAMS\\${PRODUCTNAME}.lnk\" \"$INSTDIR\\${MAINBINARYNAME}.exe\"\n    StrCpy $R0 1\n  ${EndIf}\n\n  ${If} $R0 = 1\n    Return\n  ${EndIf}\n\n  ; Skip creating shortcut if in update mode or no shortcut mode\n  ; but always create if migrating from wix\n  ${If} $WixMode = 0\n    ${If} $UpdateMode = 1\n    ${OrIf} $NoShortcutMode = 1\n      Return\n    ${EndIf}\n  ${EndIf}\n\n  !if \"${STARTMENUFOLDER}\" != \"\"\n    CreateDirectory \"$SMPROGRAMS\\$AppStartMenuFolder\"\n    CreateShortcut \"$SMPROGRAMS\\$AppStartMenuFolder\\${PRODUCTNAME}.lnk\" \"$INSTDIR\\${MAINBINARYNAME}.exe\"\n    !insertmacro SetLnkAppUserModelId \"$SMPROGRAMS\\$AppStartMenuFolder\\${PRODUCTNAME}.lnk\"\n  !else\n    CreateShortcut \"$SMPROGRAMS\\${PRODUCTNAME}.lnk\" \"$INSTDIR\\${MAINBINARYNAME}.exe\"\n    !insertmacro SetLnkAppUserModelId \"$SMPROGRAMS\\${PRODUCTNAME}.lnk\"\n  !endif\nFunctionEnd\n\nFunction CreateOrUpdateDesktopShortcut\n  ; --- PORTABLE MODE --- No desktop shortcut for portable installs\n  ${If} $PortableMode = 1\n    Return\n  ${EndIf}\n\n  ; We used to use product name as MAINBINARYNAME\n  ; migrate old shortcuts to target the new MAINBINARYNAME\n  !insertmacro IsShortcutTarget \"$DESKTOP\\${PRODUCTNAME}.lnk\" \"$INSTDIR\\$OldMainBinaryName\"\n  Pop $0\n  ${If} $0 = 1\n    !insertmacro SetShortcutTarget \"$DESKTOP\\${PRODUCTNAME}.lnk\" \"$INSTDIR\\${MAINBINARYNAME}.exe\"\n    Return\n  ${EndIf}\n\n  ; Skip creating shortcut if in update mode or no shortcut mode\n  ; but always create if migrating from wix\n  ${If} $WixMode = 0\n    ${If} $UpdateMode = 1\n    ${OrIf} $NoShortcutMode = 1\n      Return\n    ${EndIf}\n  ${EndIf}\n\n  CreateShortcut \"$DESKTOP\\${PRODUCTNAME}.lnk\" \"$INSTDIR\\${MAINBINARYNAME}.exe\"\n  !insertmacro SetLnkAppUserModelId \"$DESKTOP\\${PRODUCTNAME}.lnk\"\nFunctionEnd\n"
  },
  {
    "path": "src-tauri/resources/default_settings.json",
    "content": "{\n  \"bindings\": {\n    \"transcribe\": {\n      \"id\": \"transcribe\",\n      \"name\": \"Transcribe Keyboard Shortcut\",\n      \"description\": \"Converts your speech into text.\",\n      \"default_binding\": \"Platform-specific: ctrl+space (Windows/Linux), alt+space (macOS)\"\n    }\n  },\n  \"push_to_talk\": true,\n  \"selected_language\": \"auto\"\n}\n"
  },
  {
    "path": "src-tauri/resources/models/gigaam_vocab.txt",
    "content": "<unk> 0\n▁ 1\n. 2\nе 3\nа 4\nс 5\nи 6\n, 7\nо 8\nт 9\nн 10\nм 11\nу 12\nй 13\nл 14\nя 15\nв 16\nд 17\nз 18\nк 19\nно 20\n▁с 21\nы 22\nг 23\n▁в 24\nб 25\nр 26\nп 27\nто 28\nть 29\nра 30\n▁по 31\nка 32\nш 33\nни 34\nли 35\nна 36\nго 37\nх 38\nро 39\nва 40\n▁на 41\nю 42\nко 43\nль 44\nте 45\n? 46\nч 47\nж 48\nво 49\nла 50\nре 51\nда 52\n▁и 53\nло 54\nст 55\n- 56\nё 57\n▁не 58\nле 59\nри 60\nде 61\nта 62\nны 63\n▁В 64\n▁С 65\nь 66\nки 67\nер 68\n▁о 69\nви 70\nти 71\nма 72\n▁за 73\n▁А 74\n▁Т 75\n▁у 76\nже 77\nэ 78\n▁М 79\nц 80\nди 81\nне 82\nру 83\nче 84\nф 85\nве 86\n▁Д 87\nбо 88\n▁К 89\nщ 90\n▁О 91\nми 92\n▁что 93\n▁« 94\n» 95\nся 96\n▁По 97\n▁про 98\ne 99\na 100\nку 101\nну 102\n▁это 103\nмо 104\nжи 105\n▁ко 106\n▁П 107\n▁И 108\nча 109\nму 110\n0 111\nты 112\nста 113\nсь 114\n▁как 115\no 116\n▁мо 117\ni 118\nдо 119\nля 120\n▁до 121\n▁от 122\nУ 123\nБ 124\nры 125\nчи 126\nци 127\n▁бы 128\n▁Включи 129\nпа 130\nключ 131\nпо 132\nду 133\n▁при 134\n— 135\nЛ 136\nn 137\nР 138\nсто 139\nr 140\n▁так 141\nсти 142\nГ 143\n▁На 144\nН 145\n▁об 146\n▁мне 147\nl 148\nЯ 149\nt 150\n1 151\n▁За 152\ns 153\nЭ 154\nЧ 155\nЕ 156\n▁есть 157\nень 158\n▁Ну 159\n2 160\n▁Сбер 161\nвер 162\n▁вот 163\nение 164\nсмотр 165\nВ 166\n▁раз 167\nФ 168\n▁пере 169\nешь 170\n▁тебя 171\nu 172\n3 173\n5 174\nd 175\ny 176\nХ 177\n4 178\nЗ 179\nS 180\nС 181\nh 182\nc 183\nm 184\n9 185\n: 186\n8 187\n6 188\n7 189\nM 190\nB 191\nП 192\nD 193\nT 194\n! 195\nk 196\ng 197\nО 198\nC 199\nШ 200\nМ 201\nA 202\np 203\nЮ 204\nP 205\nТ 206\nК 207\nА 208\nL 209\nb 210\nД 211\nъ 212\nH 213\n% 214\nF 215\nv 216\nV 217\nR 218\nO 219\nI 220\nИ 221\nN 222\nЖ 223\n\" 224\nK 225\nG 226\nЦ 227\nf 228\nw 229\nE 230\n₽ 231\nW 232\nJ 233\nx 234\nz 235\n' 236\nU 237\nY 238\n& 239\nZ 240\nX 241\n+ 242\n/ 243\nЩ 244\n; 245\nj 246\nЙ 247\nq 248\nQ 249\n° 250\nЁ 251\nЫ 252\n€ 253\n$ 254\n« 255\n<blk> 256\n"
  },
  {
    "path": "src-tauri/rustfmt.toml",
    "content": "edition = \"2021\"\n"
  },
  {
    "path": "src-tauri/src/actions.rs",
    "content": "#[cfg(all(target_os = \"macos\", target_arch = \"aarch64\"))]\nuse crate::apple_intelligence;\nuse crate::audio_feedback::{play_feedback_sound, play_feedback_sound_blocking, SoundType};\nuse crate::audio_toolkit::is_microphone_access_denied;\nuse crate::managers::audio::AudioRecordingManager;\nuse crate::managers::history::HistoryManager;\nuse crate::managers::transcription::TranscriptionManager;\nuse crate::settings::{get_settings, AppSettings, APPLE_INTELLIGENCE_PROVIDER_ID};\nuse crate::shortcut;\nuse crate::tray::{change_tray_icon, TrayIconState};\nuse crate::utils::{\n    self, show_processing_overlay, show_recording_overlay, show_transcribing_overlay,\n};\nuse crate::TranscriptionCoordinator;\nuse ferrous_opencc::{config::BuiltinConfig, OpenCC};\nuse log::{debug, error, warn};\nuse once_cell::sync::Lazy;\nuse std::collections::HashMap;\nuse std::sync::Arc;\nuse std::time::Instant;\nuse tauri::Manager;\nuse tauri::{AppHandle, Emitter};\n\n#[derive(Clone, serde::Serialize)]\nstruct RecordingErrorEvent {\n    error_type: String,\n    detail: Option<String>,\n}\n\n/// Drop guard that notifies the [`TranscriptionCoordinator`] when the\n/// transcription pipeline finishes — whether it completes normally or panics.\nstruct FinishGuard(AppHandle);\nimpl Drop for FinishGuard {\n    fn drop(&mut self) {\n        if let Some(c) = self.0.try_state::<TranscriptionCoordinator>() {\n            c.notify_processing_finished();\n        }\n    }\n}\n\n// Shortcut Action Trait\npub trait ShortcutAction: Send + Sync {\n    fn start(&self, app: &AppHandle, binding_id: &str, shortcut_str: &str);\n    fn stop(&self, app: &AppHandle, binding_id: &str, shortcut_str: &str);\n}\n\n// Transcribe Action\nstruct TranscribeAction {\n    post_process: bool,\n}\n\n/// Field name for structured output JSON schema\nconst TRANSCRIPTION_FIELD: &str = \"transcription\";\n\n/// Strip invisible Unicode characters that some LLMs may insert\nfn strip_invisible_chars(s: &str) -> String {\n    s.replace(['\\u{200B}', '\\u{200C}', '\\u{200D}', '\\u{FEFF}'], \"\")\n}\n\n/// Build a system prompt from the user's prompt template.\n/// Removes `${output}` placeholder since the transcription is sent as the user message.\nfn build_system_prompt(prompt_template: &str) -> String {\n    prompt_template.replace(\"${output}\", \"\").trim().to_string()\n}\n\nasync fn post_process_transcription(settings: &AppSettings, transcription: &str) -> Option<String> {\n    let provider = match settings.active_post_process_provider().cloned() {\n        Some(provider) => provider,\n        None => {\n            debug!(\"Post-processing enabled but no provider is selected\");\n            return None;\n        }\n    };\n\n    let model = settings\n        .post_process_models\n        .get(&provider.id)\n        .cloned()\n        .unwrap_or_default();\n\n    if model.trim().is_empty() {\n        debug!(\n            \"Post-processing skipped because provider '{}' has no model configured\",\n            provider.id\n        );\n        return None;\n    }\n\n    let selected_prompt_id = match &settings.post_process_selected_prompt_id {\n        Some(id) => id.clone(),\n        None => {\n            debug!(\"Post-processing skipped because no prompt is selected\");\n            return None;\n        }\n    };\n\n    let prompt = match settings\n        .post_process_prompts\n        .iter()\n        .find(|prompt| prompt.id == selected_prompt_id)\n    {\n        Some(prompt) => prompt.prompt.clone(),\n        None => {\n            debug!(\n                \"Post-processing skipped because prompt '{}' was not found\",\n                selected_prompt_id\n            );\n            return None;\n        }\n    };\n\n    if prompt.trim().is_empty() {\n        debug!(\"Post-processing skipped because the selected prompt is empty\");\n        return None;\n    }\n\n    debug!(\n        \"Starting LLM post-processing with provider '{}' (model: {})\",\n        provider.id, model\n    );\n\n    let api_key = settings\n        .post_process_api_keys\n        .get(&provider.id)\n        .cloned()\n        .unwrap_or_default();\n\n    if provider.supports_structured_output {\n        debug!(\"Using structured outputs for provider '{}'\", provider.id);\n\n        let system_prompt = build_system_prompt(&prompt);\n        let user_content = transcription.to_string();\n\n        // Handle Apple Intelligence separately since it uses native Swift APIs\n        if provider.id == APPLE_INTELLIGENCE_PROVIDER_ID {\n            #[cfg(all(target_os = \"macos\", target_arch = \"aarch64\"))]\n            {\n                if !apple_intelligence::check_apple_intelligence_availability() {\n                    debug!(\n                        \"Apple Intelligence selected but not currently available on this device\"\n                    );\n                    return None;\n                }\n\n                let token_limit = model.trim().parse::<i32>().unwrap_or(0);\n                return match apple_intelligence::process_text_with_system_prompt(\n                    &system_prompt,\n                    &user_content,\n                    token_limit,\n                ) {\n                    Ok(result) => {\n                        if result.trim().is_empty() {\n                            debug!(\"Apple Intelligence returned an empty response\");\n                            None\n                        } else {\n                            let result = strip_invisible_chars(&result);\n                            debug!(\n                                \"Apple Intelligence post-processing succeeded. Output length: {} chars\",\n                                result.len()\n                            );\n                            Some(result)\n                        }\n                    }\n                    Err(err) => {\n                        error!(\"Apple Intelligence post-processing failed: {}\", err);\n                        None\n                    }\n                };\n            }\n\n            #[cfg(not(all(target_os = \"macos\", target_arch = \"aarch64\")))]\n            {\n                debug!(\"Apple Intelligence provider selected on unsupported platform\");\n                return None;\n            }\n        }\n\n        // Define JSON schema for transcription output\n        let json_schema = serde_json::json!({\n            \"type\": \"object\",\n            \"properties\": {\n                (TRANSCRIPTION_FIELD): {\n                    \"type\": \"string\",\n                    \"description\": \"The cleaned and processed transcription text\"\n                }\n            },\n            \"required\": [TRANSCRIPTION_FIELD],\n            \"additionalProperties\": false\n        });\n\n        match crate::llm_client::send_chat_completion_with_schema(\n            &provider,\n            api_key.clone(),\n            &model,\n            user_content,\n            Some(system_prompt),\n            Some(json_schema),\n        )\n        .await\n        {\n            Ok(Some(content)) => {\n                // Parse the JSON response to extract the transcription field\n                match serde_json::from_str::<serde_json::Value>(&content) {\n                    Ok(json) => {\n                        if let Some(transcription_value) =\n                            json.get(TRANSCRIPTION_FIELD).and_then(|t| t.as_str())\n                        {\n                            let result = strip_invisible_chars(transcription_value);\n                            debug!(\n                                \"Structured output post-processing succeeded for provider '{}'. Output length: {} chars\",\n                                provider.id,\n                                result.len()\n                            );\n                            return Some(result);\n                        } else {\n                            error!(\"Structured output response missing 'transcription' field\");\n                            return Some(strip_invisible_chars(&content));\n                        }\n                    }\n                    Err(e) => {\n                        error!(\n                            \"Failed to parse structured output JSON: {}. Returning raw content.\",\n                            e\n                        );\n                        return Some(strip_invisible_chars(&content));\n                    }\n                }\n            }\n            Ok(None) => {\n                error!(\"LLM API response has no content\");\n                return None;\n            }\n            Err(e) => {\n                warn!(\n                    \"Structured output failed for provider '{}': {}. Falling back to legacy mode.\",\n                    provider.id, e\n                );\n                // Fall through to legacy mode below\n            }\n        }\n    }\n\n    // Legacy mode: Replace ${output} variable in the prompt with the actual text\n    let processed_prompt = prompt.replace(\"${output}\", transcription);\n    debug!(\"Processed prompt length: {} chars\", processed_prompt.len());\n\n    match crate::llm_client::send_chat_completion(&provider, api_key, &model, processed_prompt)\n        .await\n    {\n        Ok(Some(content)) => {\n            let content = strip_invisible_chars(&content);\n            debug!(\n                \"LLM post-processing succeeded for provider '{}'. Output length: {} chars\",\n                provider.id,\n                content.len()\n            );\n            Some(content)\n        }\n        Ok(None) => {\n            error!(\"LLM API response has no content\");\n            None\n        }\n        Err(e) => {\n            error!(\n                \"LLM post-processing failed for provider '{}': {}. Falling back to original transcription.\",\n                provider.id,\n                e\n            );\n            None\n        }\n    }\n}\n\nasync fn maybe_convert_chinese_variant(\n    settings: &AppSettings,\n    transcription: &str,\n) -> Option<String> {\n    // Check if language is set to Simplified or Traditional Chinese\n    let is_simplified = settings.selected_language == \"zh-Hans\";\n    let is_traditional = settings.selected_language == \"zh-Hant\";\n\n    if !is_simplified && !is_traditional {\n        debug!(\"selected_language is not Simplified or Traditional Chinese; skipping translation\");\n        return None;\n    }\n\n    debug!(\n        \"Starting Chinese translation using OpenCC for language: {}\",\n        settings.selected_language\n    );\n\n    // Use OpenCC to convert based on selected language\n    let config = if is_simplified {\n        // Convert Traditional Chinese to Simplified Chinese\n        BuiltinConfig::Tw2sp\n    } else {\n        // Convert Simplified Chinese to Traditional Chinese\n        BuiltinConfig::S2twp\n    };\n\n    match OpenCC::from_config(config) {\n        Ok(converter) => {\n            let converted = converter.convert(transcription);\n            debug!(\n                \"OpenCC translation completed. Input length: {}, Output length: {}\",\n                transcription.len(),\n                converted.len()\n            );\n            Some(converted)\n        }\n        Err(e) => {\n            error!(\"Failed to initialize OpenCC converter: {}. Falling back to original transcription.\", e);\n            None\n        }\n    }\n}\n\nimpl ShortcutAction for TranscribeAction {\n    fn start(&self, app: &AppHandle, binding_id: &str, _shortcut_str: &str) {\n        let start_time = Instant::now();\n        debug!(\"TranscribeAction::start called for binding: {}\", binding_id);\n\n        // Load model in the background\n        let tm = app.state::<Arc<TranscriptionManager>>();\n        tm.initiate_model_load();\n\n        let binding_id = binding_id.to_string();\n        change_tray_icon(app, TrayIconState::Recording);\n        show_recording_overlay(app);\n\n        let rm = app.state::<Arc<AudioRecordingManager>>();\n\n        // Get the microphone mode to determine audio feedback timing\n        let settings = get_settings(app);\n        let is_always_on = settings.always_on_microphone;\n        debug!(\"Microphone mode - always_on: {}\", is_always_on);\n\n        let mut recording_error: Option<String> = None;\n        if is_always_on {\n            // Always-on mode: Play audio feedback immediately, then apply mute after sound finishes\n            debug!(\"Always-on mode: Playing audio feedback immediately\");\n            let rm_clone = Arc::clone(&rm);\n            let app_clone = app.clone();\n            // The blocking helper exits immediately if audio feedback is disabled,\n            // so we can always reuse this thread to ensure mute happens right after playback.\n            std::thread::spawn(move || {\n                play_feedback_sound_blocking(&app_clone, SoundType::Start);\n                rm_clone.apply_mute();\n            });\n\n            if let Err(e) = rm.try_start_recording(&binding_id) {\n                debug!(\"Recording failed: {}\", e);\n                recording_error = Some(e);\n            }\n        } else {\n            // On-demand mode: Start recording first, then play audio feedback, then apply mute\n            // This allows the microphone to be activated before playing the sound\n            debug!(\"On-demand mode: Starting recording first, then audio feedback\");\n            let recording_start_time = Instant::now();\n            match rm.try_start_recording(&binding_id) {\n                Ok(()) => {\n                    debug!(\"Recording started in {:?}\", recording_start_time.elapsed());\n                    // Small delay to ensure microphone stream is active\n                    let app_clone = app.clone();\n                    let rm_clone = Arc::clone(&rm);\n                    std::thread::spawn(move || {\n                        std::thread::sleep(std::time::Duration::from_millis(100));\n                        debug!(\"Handling delayed audio feedback/mute sequence\");\n                        // Helper handles disabled audio feedback by returning early, so we reuse it\n                        // to keep mute sequencing consistent in every mode.\n                        play_feedback_sound_blocking(&app_clone, SoundType::Start);\n                        rm_clone.apply_mute();\n                    });\n                }\n                Err(e) => {\n                    debug!(\"Failed to start recording: {}\", e);\n                    recording_error = Some(e);\n                }\n            }\n        }\n\n        if recording_error.is_none() {\n            // Dynamically register the cancel shortcut in a separate task to avoid deadlock\n            shortcut::register_cancel_shortcut(app);\n        } else {\n            // Starting failed (for example due to blocked microphone permissions).\n            // Revert UI state so we don't stay stuck in the recording overlay.\n            utils::hide_recording_overlay(app);\n            change_tray_icon(app, TrayIconState::Idle);\n            if let Some(err) = recording_error {\n                let error_type = if is_microphone_access_denied(&err) {\n                    \"microphone_permission_denied\"\n                } else {\n                    \"unknown\"\n                };\n                let _ = app.emit(\n                    \"recording-error\",\n                    RecordingErrorEvent {\n                        error_type: error_type.to_string(),\n                        detail: Some(err),\n                    },\n                );\n            }\n        }\n\n        debug!(\n            \"TranscribeAction::start completed in {:?}\",\n            start_time.elapsed()\n        );\n    }\n\n    fn stop(&self, app: &AppHandle, binding_id: &str, _shortcut_str: &str) {\n        // Unregister the cancel shortcut when transcription stops\n        shortcut::unregister_cancel_shortcut(app);\n\n        let stop_time = Instant::now();\n        debug!(\"TranscribeAction::stop called for binding: {}\", binding_id);\n\n        let ah = app.clone();\n        let rm = Arc::clone(&app.state::<Arc<AudioRecordingManager>>());\n        let tm = Arc::clone(&app.state::<Arc<TranscriptionManager>>());\n        let hm = Arc::clone(&app.state::<Arc<HistoryManager>>());\n\n        change_tray_icon(app, TrayIconState::Transcribing);\n        show_transcribing_overlay(app);\n\n        // Unmute before playing audio feedback so the stop sound is audible\n        rm.remove_mute();\n\n        // Play audio feedback for recording stop\n        play_feedback_sound(app, SoundType::Stop);\n\n        let binding_id = binding_id.to_string(); // Clone binding_id for the async task\n        let post_process = self.post_process;\n\n        tauri::async_runtime::spawn(async move {\n            let _guard = FinishGuard(ah.clone());\n            let binding_id = binding_id.clone(); // Clone for the inner async task\n            debug!(\n                \"Starting async transcription task for binding: {}\",\n                binding_id\n            );\n\n            let stop_recording_time = Instant::now();\n            if let Some(samples) = rm.stop_recording(&binding_id) {\n                debug!(\n                    \"Recording stopped and samples retrieved in {:?}, sample count: {}\",\n                    stop_recording_time.elapsed(),\n                    samples.len()\n                );\n\n                let transcription_time = Instant::now();\n                let samples_clone = samples.clone(); // Clone for history saving\n                match tm.transcribe(samples) {\n                    Ok(transcription) => {\n                        debug!(\n                            \"Transcription completed in {:?}: '{}'\",\n                            transcription_time.elapsed(),\n                            transcription\n                        );\n                        if !transcription.is_empty() {\n                            let settings = get_settings(&ah);\n                            let mut final_text = transcription.clone();\n                            let mut post_processed_text: Option<String> = None;\n                            let mut post_process_prompt: Option<String> = None;\n\n                            // First, check if Chinese variant conversion is needed\n                            if let Some(converted_text) =\n                                maybe_convert_chinese_variant(&settings, &transcription).await\n                            {\n                                final_text = converted_text;\n                            }\n\n                            // Then apply LLM post-processing if this is the post-process hotkey\n                            // Uses final_text which may already have Chinese conversion applied\n                            if post_process {\n                                show_processing_overlay(&ah);\n                            }\n                            let processed = if post_process {\n                                post_process_transcription(&settings, &final_text).await\n                            } else {\n                                None\n                            };\n                            if let Some(processed_text) = processed {\n                                post_processed_text = Some(processed_text.clone());\n                                final_text = processed_text;\n\n                                // Get the prompt that was used\n                                if let Some(prompt_id) = &settings.post_process_selected_prompt_id {\n                                    if let Some(prompt) = settings\n                                        .post_process_prompts\n                                        .iter()\n                                        .find(|p| &p.id == prompt_id)\n                                    {\n                                        post_process_prompt = Some(prompt.prompt.clone());\n                                    }\n                                }\n                            } else if final_text != transcription {\n                                // Chinese conversion was applied but no LLM post-processing\n                                post_processed_text = Some(final_text.clone());\n                            }\n\n                            // Save to history with post-processed text and prompt\n                            let hm_clone = Arc::clone(&hm);\n                            let transcription_for_history = transcription.clone();\n                            tauri::async_runtime::spawn(async move {\n                                if let Err(e) = hm_clone\n                                    .save_transcription(\n                                        samples_clone,\n                                        transcription_for_history,\n                                        post_processed_text,\n                                        post_process_prompt,\n                                    )\n                                    .await\n                                {\n                                    error!(\"Failed to save transcription to history: {}\", e);\n                                }\n                            });\n\n                            // Paste the final text (either processed or original)\n                            let ah_clone = ah.clone();\n                            let paste_time = Instant::now();\n                            ah.run_on_main_thread(move || {\n                                match utils::paste(final_text, ah_clone.clone()) {\n                                    Ok(()) => debug!(\n                                        \"Text pasted successfully in {:?}\",\n                                        paste_time.elapsed()\n                                    ),\n                                    Err(e) => error!(\"Failed to paste transcription: {}\", e),\n                                }\n                                // Hide the overlay after transcription is complete\n                                utils::hide_recording_overlay(&ah_clone);\n                                change_tray_icon(&ah_clone, TrayIconState::Idle);\n                            })\n                            .unwrap_or_else(|e| {\n                                error!(\"Failed to run paste on main thread: {:?}\", e);\n                                utils::hide_recording_overlay(&ah);\n                                change_tray_icon(&ah, TrayIconState::Idle);\n                            });\n                        } else {\n                            utils::hide_recording_overlay(&ah);\n                            change_tray_icon(&ah, TrayIconState::Idle);\n                        }\n                    }\n                    Err(err) => {\n                        debug!(\"Global Shortcut Transcription error: {}\", err);\n                        utils::hide_recording_overlay(&ah);\n                        change_tray_icon(&ah, TrayIconState::Idle);\n                    }\n                }\n            } else {\n                debug!(\"No samples retrieved from recording stop\");\n                utils::hide_recording_overlay(&ah);\n                change_tray_icon(&ah, TrayIconState::Idle);\n            }\n        });\n\n        debug!(\n            \"TranscribeAction::stop completed in {:?}\",\n            stop_time.elapsed()\n        );\n    }\n}\n\n// Cancel Action\nstruct CancelAction;\n\nimpl ShortcutAction for CancelAction {\n    fn start(&self, app: &AppHandle, _binding_id: &str, _shortcut_str: &str) {\n        utils::cancel_current_operation(app);\n    }\n\n    fn stop(&self, _app: &AppHandle, _binding_id: &str, _shortcut_str: &str) {\n        // Nothing to do on stop for cancel\n    }\n}\n\n// Test Action\nstruct TestAction;\n\nimpl ShortcutAction for TestAction {\n    fn start(&self, app: &AppHandle, binding_id: &str, shortcut_str: &str) {\n        log::info!(\n            \"Shortcut ID '{}': Started - {} (App: {})\", // Changed \"Pressed\" to \"Started\" for consistency\n            binding_id,\n            shortcut_str,\n            app.package_info().name\n        );\n    }\n\n    fn stop(&self, app: &AppHandle, binding_id: &str, shortcut_str: &str) {\n        log::info!(\n            \"Shortcut ID '{}': Stopped - {} (App: {})\", // Changed \"Released\" to \"Stopped\" for consistency\n            binding_id,\n            shortcut_str,\n            app.package_info().name\n        );\n    }\n}\n\n// Static Action Map\npub static ACTION_MAP: Lazy<HashMap<String, Arc<dyn ShortcutAction>>> = Lazy::new(|| {\n    let mut map = HashMap::new();\n    map.insert(\n        \"transcribe\".to_string(),\n        Arc::new(TranscribeAction {\n            post_process: false,\n        }) as Arc<dyn ShortcutAction>,\n    );\n    map.insert(\n        \"transcribe_with_post_process\".to_string(),\n        Arc::new(TranscribeAction { post_process: true }) as Arc<dyn ShortcutAction>,\n    );\n    map.insert(\n        \"cancel\".to_string(),\n        Arc::new(CancelAction) as Arc<dyn ShortcutAction>,\n    );\n    map.insert(\n        \"test\".to_string(),\n        Arc::new(TestAction) as Arc<dyn ShortcutAction>,\n    );\n    map\n});\n"
  },
  {
    "path": "src-tauri/src/apple_intelligence.rs",
    "content": "use std::ffi::{CStr, CString};\nuse std::os::raw::{c_char, c_int};\n\n// Define the response structure from Swift\n#[repr(C)]\npub struct AppleLLMResponse {\n    pub response: *mut c_char,\n    pub success: c_int,\n    pub error_message: *mut c_char,\n}\n\n// Link to the Swift functions\nextern \"C\" {\n    pub fn is_apple_intelligence_available() -> c_int;\n    pub fn free_apple_llm_response(response: *mut AppleLLMResponse);\n}\n\n// Safe wrapper functions\npub fn check_apple_intelligence_availability() -> bool {\n    unsafe { is_apple_intelligence_available() == 1 }\n}\n\n// Link to the Swift function for system prompt support\nextern \"C\" {\n    pub fn process_text_with_system_prompt_apple(\n        system_prompt: *const c_char,\n        user_content: *const c_char,\n        max_tokens: i32,\n    ) -> *mut AppleLLMResponse;\n}\n\n/// Process text with Apple Intelligence using separate system prompt and user content\npub fn process_text_with_system_prompt(\n    system_prompt: &str,\n    user_content: &str,\n    max_tokens: i32,\n) -> Result<String, String> {\n    let system_cstr = CString::new(system_prompt).map_err(|e| e.to_string())?;\n    let user_cstr = CString::new(user_content).map_err(|e| e.to_string())?;\n\n    let response_ptr = unsafe {\n        process_text_with_system_prompt_apple(system_cstr.as_ptr(), user_cstr.as_ptr(), max_tokens)\n    };\n\n    if response_ptr.is_null() {\n        return Err(\"Null response from Apple LLM\".to_string());\n    }\n\n    let response = unsafe { &*response_ptr };\n\n    let result = if response.success == 1 {\n        if response.response.is_null() {\n            Ok(String::new())\n        } else {\n            let c_str = unsafe { CStr::from_ptr(response.response) };\n            let rust_str = c_str.to_string_lossy().into_owned();\n            Ok(rust_str)\n        }\n    } else {\n        let error_c_str = if !response.error_message.is_null() {\n            unsafe { CStr::from_ptr(response.error_message) }\n        } else {\n            CStr::from_bytes_with_nul(b\"Unknown error\\0\").unwrap()\n        };\n        let error_msg = error_c_str.to_string_lossy().into_owned();\n        Err(error_msg)\n    };\n\n    // Clean up the response\n    unsafe { free_apple_llm_response(response_ptr) };\n\n    result\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn test_availability() {\n        let available = check_apple_intelligence_availability();\n        println!(\"Apple Intelligence available: {}\", available);\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/audio_feedback.rs",
    "content": "use crate::settings::SoundTheme;\nuse crate::settings::{self, AppSettings};\nuse cpal::traits::{DeviceTrait, HostTrait};\nuse log::{debug, error, warn};\nuse rodio::OutputStreamBuilder;\nuse std::fs::File;\nuse std::io::BufReader;\nuse std::path::{Path, PathBuf};\nuse std::thread;\nuse tauri::{AppHandle, Manager};\n\npub enum SoundType {\n    Start,\n    Stop,\n}\n\nfn resolve_sound_path(\n    app: &AppHandle,\n    settings: &AppSettings,\n    sound_type: SoundType,\n) -> Option<PathBuf> {\n    let sound_file = get_sound_path(settings, sound_type);\n    let base_dir = get_sound_base_dir(settings);\n    match base_dir {\n        tauri::path::BaseDirectory::AppData => {\n            crate::portable::resolve_app_data(app, &sound_file).ok()\n        }\n        _ => app.path().resolve(&sound_file, base_dir).ok(),\n    }\n}\n\nfn get_sound_path(settings: &AppSettings, sound_type: SoundType) -> String {\n    match (settings.sound_theme, sound_type) {\n        (SoundTheme::Custom, SoundType::Start) => \"custom_start.wav\".to_string(),\n        (SoundTheme::Custom, SoundType::Stop) => \"custom_stop.wav\".to_string(),\n        (_, SoundType::Start) => settings.sound_theme.to_start_path(),\n        (_, SoundType::Stop) => settings.sound_theme.to_stop_path(),\n    }\n}\n\nfn get_sound_base_dir(settings: &AppSettings) -> tauri::path::BaseDirectory {\n    match settings.sound_theme {\n        SoundTheme::Custom => tauri::path::BaseDirectory::AppData,\n        _ => tauri::path::BaseDirectory::Resource,\n    }\n}\n\npub fn play_feedback_sound(app: &AppHandle, sound_type: SoundType) {\n    let settings = settings::get_settings(app);\n    if !settings.audio_feedback {\n        return;\n    }\n    if let Some(path) = resolve_sound_path(app, &settings, sound_type) {\n        play_sound_async(app, path);\n    }\n}\n\npub fn play_feedback_sound_blocking(app: &AppHandle, sound_type: SoundType) {\n    let settings = settings::get_settings(app);\n    if !settings.audio_feedback {\n        return;\n    }\n    if let Some(path) = resolve_sound_path(app, &settings, sound_type) {\n        play_sound_blocking(app, &path);\n    }\n}\n\npub fn play_test_sound(app: &AppHandle, sound_type: SoundType) {\n    let settings = settings::get_settings(app);\n    if let Some(path) = resolve_sound_path(app, &settings, sound_type) {\n        play_sound_blocking(app, &path);\n    }\n}\n\nfn play_sound_async(app: &AppHandle, path: PathBuf) {\n    let app_handle = app.clone();\n    thread::spawn(move || {\n        if let Err(e) = play_sound_at_path(&app_handle, path.as_path()) {\n            error!(\"Failed to play sound '{}': {}\", path.display(), e);\n        }\n    });\n}\n\nfn play_sound_blocking(app: &AppHandle, path: &Path) {\n    if let Err(e) = play_sound_at_path(app, path) {\n        error!(\"Failed to play sound '{}': {}\", path.display(), e);\n    }\n}\n\nfn play_sound_at_path(app: &AppHandle, path: &Path) -> Result<(), Box<dyn std::error::Error>> {\n    let settings = settings::get_settings(app);\n    let volume = settings.audio_feedback_volume;\n    let selected_device = settings.selected_output_device.clone();\n    play_audio_file(path, selected_device, volume)\n}\n\nfn play_audio_file(\n    path: &std::path::Path,\n    selected_device: Option<String>,\n    volume: f32,\n) -> Result<(), Box<dyn std::error::Error>> {\n    let stream_builder = if let Some(device_name) = selected_device {\n        if device_name == \"Default\" {\n            debug!(\"Using default device\");\n            OutputStreamBuilder::from_default_device()?\n        } else {\n            let host = crate::audio_toolkit::get_cpal_host();\n            let devices = host.output_devices()?;\n\n            let mut found_device = None;\n            for device in devices {\n                if device.name()? == device_name {\n                    found_device = Some(device);\n                    break;\n                }\n            }\n\n            match found_device {\n                Some(device) => OutputStreamBuilder::from_device(device)?,\n                None => {\n                    warn!(\"Device '{}' not found, using default device\", device_name);\n                    OutputStreamBuilder::from_default_device()?\n                }\n            }\n        }\n    } else {\n        debug!(\"Using default device\");\n        OutputStreamBuilder::from_default_device()?\n    };\n\n    let stream_handle = stream_builder.open_stream()?;\n    let mixer = stream_handle.mixer();\n\n    let file = File::open(path)?;\n    let buf_reader = BufReader::new(file);\n\n    let sink = rodio::play(mixer, buf_reader)?;\n    sink.set_volume(volume);\n    sink.sleep_until_end();\n\n    Ok(())\n}\n"
  },
  {
    "path": "src-tauri/src/audio_toolkit/audio/device.rs",
    "content": "use cpal::traits::{DeviceTrait, HostTrait};\n\npub struct CpalDeviceInfo {\n    pub index: String,\n    pub name: String,\n    pub is_default: bool,\n    pub device: cpal::Device,\n}\n\npub fn list_input_devices() -> Result<Vec<CpalDeviceInfo>, Box<dyn std::error::Error>> {\n    let host = crate::audio_toolkit::get_cpal_host();\n    let default_name = host.default_input_device().and_then(|d| d.name().ok());\n\n    let mut out = Vec::<CpalDeviceInfo>::new();\n\n    for (index, device) in host.input_devices()?.enumerate() {\n        let name = device.name().unwrap_or_else(|_| \"Unknown\".into());\n\n        let is_default = Some(name.clone()) == default_name;\n\n        out.push(CpalDeviceInfo {\n            index: index.to_string(),\n            name,\n            is_default,\n            device,\n        });\n    }\n\n    Ok(out)\n}\n\npub fn list_output_devices() -> Result<Vec<CpalDeviceInfo>, Box<dyn std::error::Error>> {\n    let host = crate::audio_toolkit::get_cpal_host();\n    let default_name = host.default_output_device().and_then(|d| d.name().ok());\n\n    let mut out = Vec::<CpalDeviceInfo>::new();\n\n    for (index, device) in host.output_devices()?.enumerate() {\n        let name = device.name().unwrap_or_else(|_| \"Unknown\".into());\n\n        let is_default = Some(name.clone()) == default_name;\n\n        out.push(CpalDeviceInfo {\n            index: index.to_string(),\n            name,\n            is_default,\n            device,\n        });\n    }\n\n    Ok(out)\n}\n"
  },
  {
    "path": "src-tauri/src/audio_toolkit/audio/mod.rs",
    "content": "// Re-export all audio components\nmod device;\nmod recorder;\nmod resampler;\nmod utils;\nmod visualizer;\n\npub use device::{list_input_devices, list_output_devices, CpalDeviceInfo};\npub use recorder::{is_microphone_access_denied, AudioRecorder};\npub use resampler::FrameResampler;\npub use utils::save_wav_file;\npub use visualizer::AudioVisualiser;\n"
  },
  {
    "path": "src-tauri/src/audio_toolkit/audio/recorder.rs",
    "content": "use std::{\n    io::Error,\n    sync::{\n        atomic::{AtomicBool, Ordering},\n        mpsc, Arc, Mutex,\n    },\n    time::Duration,\n};\n\nuse cpal::{\n    traits::{DeviceTrait, HostTrait, StreamTrait},\n    Device, Sample, SizedSample,\n};\n\nuse crate::audio_toolkit::{\n    audio::{AudioVisualiser, FrameResampler},\n    constants,\n    vad::{self, VadFrame},\n    VoiceActivityDetector,\n};\n\nenum Cmd {\n    Start,\n    Stop(mpsc::Sender<Vec<f32>>),\n    Shutdown,\n}\n\nenum AudioChunk {\n    Samples(Vec<f32>),\n    EndOfStream,\n}\n\npub struct AudioRecorder {\n    device: Option<Device>,\n    cmd_tx: Option<mpsc::Sender<Cmd>>,\n    worker_handle: Option<std::thread::JoinHandle<()>>,\n    vad: Option<Arc<Mutex<Box<dyn vad::VoiceActivityDetector>>>>,\n    level_cb: Option<Arc<dyn Fn(Vec<f32>) + Send + Sync + 'static>>,\n}\n\nimpl AudioRecorder {\n    pub fn new() -> Result<Self, Box<dyn std::error::Error>> {\n        Ok(AudioRecorder {\n            device: None,\n            cmd_tx: None,\n            worker_handle: None,\n            vad: None,\n            level_cb: None,\n        })\n    }\n\n    pub fn with_vad(mut self, vad: Box<dyn VoiceActivityDetector>) -> Self {\n        self.vad = Some(Arc::new(Mutex::new(vad)));\n        self\n    }\n\n    pub fn with_level_callback<F>(mut self, cb: F) -> Self\n    where\n        F: Fn(Vec<f32>) + Send + Sync + 'static,\n    {\n        self.level_cb = Some(Arc::new(cb));\n        self\n    }\n\n    pub fn open(&mut self, device: Option<Device>) -> Result<(), Box<dyn std::error::Error>> {\n        if self.worker_handle.is_some() {\n            return Ok(()); // already open\n        }\n\n        let (sample_tx, sample_rx) = mpsc::channel::<AudioChunk>();\n        let (cmd_tx, cmd_rx) = mpsc::channel::<Cmd>();\n        let (init_tx, init_rx) = mpsc::sync_channel::<Result<(), String>>(1);\n\n        let host = crate::audio_toolkit::get_cpal_host();\n        let device = match device {\n            Some(dev) => dev,\n            None => host\n                .default_input_device()\n                .ok_or_else(|| Error::new(std::io::ErrorKind::NotFound, \"No input device found\"))?,\n        };\n\n        let thread_device = device.clone();\n        let vad = self.vad.clone();\n        // Move the optional level callback into the worker thread\n        let level_cb = self.level_cb.clone();\n\n        let worker = std::thread::spawn(move || {\n            let stop_flag = Arc::new(AtomicBool::new(false));\n            let stop_flag_for_stream = stop_flag.clone();\n            let init_result = (|| -> Result<(cpal::Stream, u32), String> {\n                let config = AudioRecorder::get_preferred_config(&thread_device)\n                    .map_err(|e| format!(\"Failed to fetch preferred config: {e}\"))?;\n\n                let sample_rate = config.sample_rate().0;\n                let channels = config.channels() as usize;\n\n                log::info!(\n                    \"Using device: {:?}\\nSample rate: {}\\nChannels: {}\\nFormat: {:?}\",\n                    thread_device.name(),\n                    sample_rate,\n                    channels,\n                    config.sample_format()\n                );\n\n                let stream = match config.sample_format() {\n                    cpal::SampleFormat::U8 => AudioRecorder::build_stream::<u8>(\n                        &thread_device,\n                        &config,\n                        sample_tx,\n                        channels,\n                        stop_flag_for_stream,\n                    )\n                    .map_err(|e| format!(\"Failed to build input stream: {e}\"))?,\n                    cpal::SampleFormat::I8 => AudioRecorder::build_stream::<i8>(\n                        &thread_device,\n                        &config,\n                        sample_tx,\n                        channels,\n                        stop_flag_for_stream,\n                    )\n                    .map_err(|e| format!(\"Failed to build input stream: {e}\"))?,\n                    cpal::SampleFormat::I16 => AudioRecorder::build_stream::<i16>(\n                        &thread_device,\n                        &config,\n                        sample_tx,\n                        channels,\n                        stop_flag_for_stream,\n                    )\n                    .map_err(|e| format!(\"Failed to build input stream: {e}\"))?,\n                    cpal::SampleFormat::I32 => AudioRecorder::build_stream::<i32>(\n                        &thread_device,\n                        &config,\n                        sample_tx,\n                        channels,\n                        stop_flag_for_stream,\n                    )\n                    .map_err(|e| format!(\"Failed to build input stream: {e}\"))?,\n                    cpal::SampleFormat::F32 => AudioRecorder::build_stream::<f32>(\n                        &thread_device,\n                        &config,\n                        sample_tx,\n                        channels,\n                        stop_flag_for_stream,\n                    )\n                    .map_err(|e| format!(\"Failed to build input stream: {e}\"))?,\n                    sample_format => {\n                        return Err(format!(\"Unsupported sample format: {sample_format:?}\"));\n                    }\n                };\n\n                stream\n                    .play()\n                    .map_err(|e| format!(\"Failed to start microphone stream: {e}\"))?;\n\n                Ok((stream, sample_rate))\n            })();\n\n            match init_result {\n                Ok((stream, sample_rate)) => {\n                    let _ = init_tx.send(Ok(()));\n                    // Keep the stream alive while we process samples.\n                    run_consumer(sample_rate, vad, sample_rx, cmd_rx, level_cb, stop_flag);\n                    drop(stream);\n                }\n                Err(error_message) => {\n                    log::error!(\"{error_message}\");\n                    let _ = init_tx.send(Err(error_message));\n                }\n            }\n        });\n\n        match init_rx.recv() {\n            Ok(Ok(())) => {\n                self.device = Some(device);\n                self.cmd_tx = Some(cmd_tx);\n                self.worker_handle = Some(worker);\n                Ok(())\n            }\n            Ok(Err(error_message)) => {\n                let _ = worker.join();\n                let kind = if is_microphone_access_denied(&error_message) {\n                    std::io::ErrorKind::PermissionDenied\n                } else {\n                    std::io::ErrorKind::Other\n                };\n                Err(Box::new(Error::new(kind, error_message)))\n            }\n            Err(recv_error) => {\n                let _ = worker.join();\n                Err(Box::new(Error::new(\n                    std::io::ErrorKind::Other,\n                    format!(\"Failed to initialize microphone worker: {recv_error}\"),\n                )))\n            }\n        }\n    }\n\n    pub fn start(&self) -> Result<(), Box<dyn std::error::Error>> {\n        if let Some(tx) = &self.cmd_tx {\n            tx.send(Cmd::Start)?;\n        }\n        Ok(())\n    }\n\n    pub fn stop(&self) -> Result<Vec<f32>, Box<dyn std::error::Error>> {\n        let (resp_tx, resp_rx) = mpsc::channel();\n        if let Some(tx) = &self.cmd_tx {\n            tx.send(Cmd::Stop(resp_tx))?;\n        }\n        Ok(resp_rx.recv()?) // wait for the samples\n    }\n\n    pub fn close(&mut self) -> Result<(), Box<dyn std::error::Error>> {\n        if let Some(tx) = self.cmd_tx.take() {\n            let _ = tx.send(Cmd::Shutdown);\n        }\n        if let Some(h) = self.worker_handle.take() {\n            let _ = h.join();\n        }\n        self.device = None;\n        Ok(())\n    }\n\n    fn build_stream<T>(\n        device: &cpal::Device,\n        config: &cpal::SupportedStreamConfig,\n        sample_tx: mpsc::Sender<AudioChunk>,\n        channels: usize,\n        stop_flag: Arc<AtomicBool>,\n    ) -> Result<cpal::Stream, cpal::BuildStreamError>\n    where\n        T: Sample + SizedSample + Send + 'static,\n        f32: cpal::FromSample<T>,\n    {\n        let mut output_buffer = Vec::new();\n        let mut eos_sent = false;\n\n        let stream_cb = move |data: &[T], _: &cpal::InputCallbackInfo| {\n            if stop_flag.load(Ordering::Relaxed) {\n                if !eos_sent {\n                    let _ = sample_tx.send(AudioChunk::EndOfStream);\n                    eos_sent = true;\n                }\n                return;\n            }\n            eos_sent = false;\n\n            output_buffer.clear();\n\n            if channels == 1 {\n                output_buffer.extend(data.iter().map(|&sample| sample.to_sample::<f32>()));\n            } else {\n                let frame_count = data.len() / channels;\n                output_buffer.reserve(frame_count);\n\n                for frame in data.chunks_exact(channels) {\n                    let mono_sample = frame\n                        .iter()\n                        .map(|&sample| sample.to_sample::<f32>())\n                        .sum::<f32>()\n                        / channels as f32;\n                    output_buffer.push(mono_sample);\n                }\n            }\n\n            if sample_tx\n                .send(AudioChunk::Samples(output_buffer.clone()))\n                .is_err()\n            {\n                log::error!(\"Failed to send samples\");\n            }\n        };\n\n        device.build_input_stream(\n            &config.clone().into(),\n            stream_cb,\n            |err| log::error!(\"Stream error: {}\", err),\n            None,\n        )\n    }\n\n    fn get_preferred_config(\n        device: &cpal::Device,\n    ) -> Result<cpal::SupportedStreamConfig, Box<dyn std::error::Error>> {\n        // Use the device's native/default sample rate and let the FrameResampler\n        // in run_consumer() downsample to 16kHz. This avoids forcing hardware into\n        // a non-native rate which can cause issues on some devices (Bluetooth\n        // codecs, certain ALSA drivers, etc.).\n        let default_config = device.default_input_config()?;\n        let target_rate = default_config.sample_rate();\n\n        // Try to find the best sample format at the device's default rate\n        let supported_configs = match device.supported_input_configs() {\n            Ok(configs) => configs,\n            Err(e) => {\n                log::warn!(\"Could not enumerate input configs ({e}), using device default\");\n                return Ok(default_config);\n            }\n        };\n        let mut best_config: Option<cpal::SupportedStreamConfigRange> = None;\n\n        for config_range in supported_configs {\n            if config_range.min_sample_rate() <= target_rate\n                && config_range.max_sample_rate() >= target_rate\n            {\n                match best_config {\n                    None => best_config = Some(config_range),\n                    Some(ref current) => {\n                        // Prioritize F32 > I16 > I32 > others\n                        let score = |fmt: cpal::SampleFormat| match fmt {\n                            cpal::SampleFormat::F32 => 4,\n                            cpal::SampleFormat::I16 => 3,\n                            cpal::SampleFormat::I32 => 2,\n                            _ => 1,\n                        };\n\n                        if score(config_range.sample_format()) > score(current.sample_format()) {\n                            best_config = Some(config_range);\n                        }\n                    }\n                }\n            }\n        }\n\n        if let Some(config) = best_config {\n            return Ok(config.with_sample_rate(target_rate));\n        }\n\n        // Fall back to device default if no config matched (exotic/virtual devices)\n        log::warn!(\n            \"No supported config matched device default rate {:?}, using default config\",\n            target_rate\n        );\n        Ok(default_config)\n    }\n}\n\npub fn is_microphone_access_denied(error_message: &str) -> bool {\n    let normalized = error_message.to_lowercase();\n    normalized.contains(\"access is denied\")\n        || normalized.contains(\"permission denied\")\n        || normalized.contains(\"0x80070005\")\n}\n\n#[cfg(test)]\nmod tests {\n    use super::is_microphone_access_denied;\n\n    #[test]\n    fn detects_access_is_denied() {\n        assert!(is_microphone_access_denied(\"Access is denied\"));\n    }\n\n    #[test]\n    fn detects_permission_denied() {\n        assert!(is_microphone_access_denied(\"permission denied\"));\n    }\n\n    #[test]\n    fn detects_windows_error_code() {\n        assert!(is_microphone_access_denied(\"WASAPI error: 0x80070005\"));\n    }\n\n    #[test]\n    fn does_not_match_unrelated_errors() {\n        assert!(!is_microphone_access_denied(\"device not found\"));\n    }\n}\n\nfn run_consumer(\n    in_sample_rate: u32,\n    vad: Option<Arc<Mutex<Box<dyn vad::VoiceActivityDetector>>>>,\n    sample_rx: mpsc::Receiver<AudioChunk>,\n    cmd_rx: mpsc::Receiver<Cmd>,\n    level_cb: Option<Arc<dyn Fn(Vec<f32>) + Send + Sync + 'static>>,\n    stop_flag: Arc<AtomicBool>,\n) {\n    let mut frame_resampler = FrameResampler::new(\n        in_sample_rate as usize,\n        constants::WHISPER_SAMPLE_RATE as usize,\n        Duration::from_millis(30),\n    );\n\n    let mut processed_samples = Vec::<f32>::new();\n    let mut recording = false;\n\n    // ---------- spectrum visualisation setup ---------------------------- //\n    const BUCKETS: usize = 16;\n    const WINDOW_SIZE: usize = 512;\n    let mut visualizer = AudioVisualiser::new(\n        in_sample_rate,\n        WINDOW_SIZE,\n        BUCKETS,\n        400.0,  // vocal_min_hz\n        4000.0, // vocal_max_hz\n    );\n\n    fn handle_frame(\n        samples: &[f32],\n        recording: bool,\n        vad: &Option<Arc<Mutex<Box<dyn vad::VoiceActivityDetector>>>>,\n        out_buf: &mut Vec<f32>,\n    ) {\n        if !recording {\n            return;\n        }\n\n        if let Some(vad_arc) = vad {\n            let mut det = vad_arc.lock().unwrap();\n            match det.push_frame(samples).unwrap_or(VadFrame::Speech(samples)) {\n                VadFrame::Speech(buf) => out_buf.extend_from_slice(buf),\n                VadFrame::Noise => {}\n            }\n        } else {\n            out_buf.extend_from_slice(samples);\n        }\n    }\n\n    loop {\n        let chunk = match sample_rx.recv() {\n            Ok(c) => c,\n            Err(_) => break, // stream closed\n        };\n\n        let raw = match chunk {\n            AudioChunk::Samples(s) => s,\n            AudioChunk::EndOfStream => continue,\n        };\n\n        // ---------- spectrum processing ---------------------------------- //\n        if let Some(buckets) = visualizer.feed(&raw) {\n            if let Some(cb) = &level_cb {\n                cb(buckets);\n            }\n        }\n\n        // ---------- existing pipeline ------------------------------------ //\n        frame_resampler.push(&raw, &mut |frame: &[f32]| {\n            handle_frame(frame, recording, &vad, &mut processed_samples)\n        });\n\n        // non-blocking check for a command\n        while let Ok(cmd) = cmd_rx.try_recv() {\n            match cmd {\n                Cmd::Start => {\n                    stop_flag.store(false, Ordering::Relaxed);\n                    processed_samples.clear();\n                    recording = true;\n                    visualizer.reset();\n                    if let Some(v) = &vad {\n                        v.lock().unwrap().reset();\n                    }\n                }\n                Cmd::Stop(reply_tx) => {\n                    recording = false;\n                    stop_flag.store(true, Ordering::Relaxed);\n\n                    // Drain all remaining audio until the producer confirms end-of-stream.\n                    // The cpal callback sees the stop flag, sends EndOfStream, and goes\n                    // silent — guaranteeing every captured sample is in the channel\n                    // ahead of the sentinel.\n                    loop {\n                        match sample_rx.recv_timeout(Duration::from_secs(2)) {\n                            Ok(AudioChunk::Samples(remaining)) => {\n                                frame_resampler.push(&remaining, &mut |frame: &[f32]| {\n                                    handle_frame(frame, true, &vad, &mut processed_samples)\n                                });\n                            }\n                            Ok(AudioChunk::EndOfStream) => break,\n                            Err(_) => {\n                                log::warn!(\"Timed out waiting for EndOfStream from audio callback\");\n                                break;\n                            }\n                        }\n                    }\n\n                    frame_resampler.finish(&mut |frame: &[f32]| {\n                        handle_frame(frame, true, &vad, &mut processed_samples)\n                    });\n\n                    let _ = reply_tx.send(std::mem::take(&mut processed_samples));\n\n                    // Resume the audio callback so the consumer loop can continue\n                    // receiving chunks (important for always-on microphone mode).\n                    stop_flag.store(false, Ordering::Relaxed);\n                }\n                Cmd::Shutdown => {\n                    stop_flag.store(true, Ordering::Relaxed);\n                    return;\n                }\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/audio_toolkit/audio/resampler.rs",
    "content": "use rubato::{FftFixedIn, Resampler};\nuse std::time::Duration;\n\n// Make this a constant you can tweak\nconst RESAMPLER_CHUNK_SIZE: usize = 1024;\n\npub struct FrameResampler {\n    resampler: Option<FftFixedIn<f32>>,\n    chunk_in: usize,\n    in_buf: Vec<f32>,\n    frame_samples: usize,\n    pending: Vec<f32>,\n}\n\nimpl FrameResampler {\n    pub fn new(in_hz: usize, out_hz: usize, frame_dur: Duration) -> Self {\n        let frame_samples = ((out_hz as f64 * frame_dur.as_secs_f64()).round()) as usize;\n        assert!(frame_samples > 0, \"frame duration too short\");\n\n        // Use fixed chunk size instead of GCD-based\n        let chunk_in = RESAMPLER_CHUNK_SIZE;\n\n        let resampler = (in_hz != out_hz).then(|| {\n            FftFixedIn::<f32>::new(in_hz, out_hz, chunk_in, 1, 1)\n                .expect(\"Failed to create resampler\")\n        });\n\n        Self {\n            resampler,\n            chunk_in,\n            in_buf: Vec::with_capacity(chunk_in),\n            frame_samples,\n            pending: Vec::with_capacity(frame_samples),\n        }\n    }\n\n    pub fn push(&mut self, mut src: &[f32], mut emit: impl FnMut(&[f32])) {\n        if self.resampler.is_none() {\n            self.emit_frames(src, &mut emit);\n            return;\n        }\n\n        while !src.is_empty() {\n            let space = self.chunk_in - self.in_buf.len();\n            let take = space.min(src.len());\n            self.in_buf.extend_from_slice(&src[..take]);\n            src = &src[take..];\n\n            if self.in_buf.len() == self.chunk_in {\n                // let start = std::time::Instant::now();\n                if let Ok(out) = self\n                    .resampler\n                    .as_mut()\n                    .unwrap()\n                    .process(&[&self.in_buf[..]], None)\n                {\n                    // let duration = start.elapsed();\n                    // log::debug!(\"Resampler took: {:?}\", duration);\n                    self.emit_frames(&out[0], &mut emit);\n                }\n                self.in_buf.clear();\n            }\n        }\n    }\n\n    pub fn finish(&mut self, mut emit: impl FnMut(&[f32])) {\n        // Process any remaining input samples\n        if let Some(ref mut resampler) = self.resampler {\n            if !self.in_buf.is_empty() {\n                // Pad with zeros to reach chunk size\n                self.in_buf.resize(self.chunk_in, 0.0);\n                if let Ok(out) = resampler.process(&[&self.in_buf[..]], None) {\n                    self.emit_frames(&out[0], &mut emit);\n                }\n            }\n        }\n\n        // Emit any remaining pending frame (padded with zeros)\n        if !self.pending.is_empty() {\n            self.pending.resize(self.frame_samples, 0.0);\n            emit(&self.pending);\n            self.pending.clear();\n        }\n    }\n\n    fn emit_frames(&mut self, mut data: &[f32], emit: &mut impl FnMut(&[f32])) {\n        while !data.is_empty() {\n            let space = self.frame_samples - self.pending.len();\n            let take = space.min(data.len());\n            self.pending.extend_from_slice(&data[..take]);\n            data = &data[take..];\n\n            if self.pending.len() == self.frame_samples {\n                emit(&self.pending);\n                self.pending.clear();\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/audio_toolkit/audio/utils.rs",
    "content": "use anyhow::Result;\nuse hound::{WavSpec, WavWriter};\nuse log::debug;\nuse std::path::Path;\n\n/// Save audio samples as a WAV file\npub async fn save_wav_file<P: AsRef<Path>>(file_path: P, samples: &[f32]) -> Result<()> {\n    let spec = WavSpec {\n        channels: 1,\n        sample_rate: 16000,\n        bits_per_sample: 16,\n        sample_format: hound::SampleFormat::Int,\n    };\n\n    let mut writer = WavWriter::create(file_path.as_ref(), spec)?;\n\n    // Convert f32 samples to i16 for WAV\n    for sample in samples {\n        let sample_i16 = (sample * i16::MAX as f32) as i16;\n        writer.write_sample(sample_i16)?;\n    }\n\n    writer.finalize()?;\n    debug!(\"Saved WAV file: {:?}\", file_path.as_ref());\n    Ok(())\n}\n"
  },
  {
    "path": "src-tauri/src/audio_toolkit/audio/visualizer.rs",
    "content": "use rustfft::{num_complex::Complex32, Fft, FftPlanner};\nuse std::sync::Arc;\n\nconst DB_MIN: f32 = -55.0;\nconst DB_MAX: f32 = -8.0;\nconst GAIN: f32 = 1.3;\nconst CURVE_POWER: f32 = 0.7;\n\npub struct AudioVisualiser {\n    fft: Arc<dyn Fft<f32>>,\n    window: Vec<f32>,\n    bucket_ranges: Vec<(usize, usize)>,\n    fft_input: Vec<Complex32>,\n    noise_floor: Vec<f32>,\n    buffer: Vec<f32>,\n    window_size: usize,\n    buckets: usize,\n}\n\nimpl AudioVisualiser {\n    pub fn new(\n        sample_rate: u32,\n        window_size: usize,\n        buckets: usize,\n        freq_min: f32,\n        freq_max: f32,\n    ) -> Self {\n        let mut planner = FftPlanner::<f32>::new();\n        let fft = planner.plan_fft_forward(window_size);\n\n        // Pre-compute Hann window\n        let window: Vec<f32> = (0..window_size)\n            .map(|i| {\n                0.5 * (1.0 - (2.0 * std::f32::consts::PI * i as f32 / window_size as f32).cos())\n            })\n            .collect();\n\n        // Pre-compute bucket frequency ranges\n        let nyquist = sample_rate as f32 / 2.0;\n        let freq_min = freq_min.min(nyquist);\n        let freq_max = freq_max.min(nyquist);\n\n        let mut bucket_ranges = Vec::with_capacity(buckets);\n\n        for b in 0..buckets {\n            // Use logarithmic spacing for better perceptual representation\n            let log_start = (b as f32 / buckets as f32).powi(2);\n            let log_end = ((b + 1) as f32 / buckets as f32).powi(2);\n\n            let start_hz = freq_min + (freq_max - freq_min) * log_start;\n            let end_hz = freq_min + (freq_max - freq_min) * log_end;\n\n            let start_bin = ((start_hz * window_size as f32) / sample_rate as f32) as usize;\n            let mut end_bin = ((end_hz * window_size as f32) / sample_rate as f32) as usize;\n\n            // Ensure each bucket has at least one bin\n            if end_bin <= start_bin {\n                end_bin = start_bin + 1;\n            }\n\n            // Clamp to valid range\n            let start_bin = start_bin.min(window_size / 2);\n            let end_bin = end_bin.min(window_size / 2);\n\n            bucket_ranges.push((start_bin, end_bin));\n        }\n\n        Self {\n            fft,\n            window,\n            bucket_ranges,\n            fft_input: vec![Complex32::new(0.0, 0.0); window_size],\n            noise_floor: vec![-40.0; buckets], // Initialize to reasonable noise floor\n            buffer: Vec::with_capacity(window_size * 2),\n            window_size,\n            buckets,\n        }\n    }\n\n    pub fn feed(&mut self, samples: &[f32]) -> Option<Vec<f32>> {\n        // Add new samples to buffer\n        self.buffer.extend_from_slice(samples);\n\n        // Only process if we have enough samples\n        if self.buffer.len() < self.window_size {\n            return None;\n        }\n\n        // Take the required window of samples\n        let window_samples = &self.buffer[..self.window_size];\n\n        // Remove DC component\n        let mean = window_samples.iter().sum::<f32>() / self.window_size as f32;\n\n        // Apply window function and prepare FFT input\n        for (i, &sample) in window_samples.iter().enumerate() {\n            let windowed_sample = (sample - mean) * self.window[i];\n            self.fft_input[i] = Complex32::new(windowed_sample, 0.0);\n        }\n\n        // Perform FFT\n        self.fft.process(&mut self.fft_input);\n\n        // Compute power spectrum and bucket levels\n        let mut buckets = vec![0.0; self.buckets];\n\n        for (bucket_idx, &(start_bin, end_bin)) in self.bucket_ranges.iter().enumerate() {\n            if start_bin >= end_bin || end_bin > self.fft_input.len() / 2 {\n                continue;\n            }\n\n            // Calculate average power in this frequency range\n            let mut power_sum = 0.0;\n            for bin_idx in start_bin..end_bin {\n                let magnitude = self.fft_input[bin_idx].norm();\n                power_sum += magnitude * magnitude;\n            }\n\n            let avg_power = power_sum / (end_bin - start_bin) as f32;\n\n            // Convert to dB with proper scaling\n            let db = if avg_power > 1e-12 {\n                20.0 * (avg_power.sqrt() / self.window_size as f32).log10()\n            } else {\n                -80.0 // Very low floor for zero power\n            };\n\n            // Only update noise floor when signal is quiet (below current floor + 10dB)\n            if db < self.noise_floor[bucket_idx] + 10.0 {\n                const NOISE_ALPHA: f32 = 0.001; // Very slow adaptation\n                self.noise_floor[bucket_idx] =\n                    NOISE_ALPHA * db + (1.0 - NOISE_ALPHA) * self.noise_floor[bucket_idx];\n            }\n\n            // Map configurable dB range to 0-1 with gain and curve shaping\n            let normalized = ((db - DB_MIN) / (DB_MAX - DB_MIN)).clamp(0.0, 1.0);\n            buckets[bucket_idx] = (normalized * GAIN).powf(CURVE_POWER).clamp(0.0, 1.0);\n        }\n\n        // Apply light smoothing to reduce jitter\n        for i in 1..buckets.len() - 1 {\n            buckets[i] = buckets[i] * 0.7 + buckets[i - 1] * 0.15 + buckets[i + 1] * 0.15;\n        }\n\n        // Clear processed samples from buffer\n        self.buffer.clear();\n\n        Some(buckets)\n    }\n\n    pub fn reset(&mut self) {\n        self.buffer.clear();\n        // Reset noise floor to initial values\n        self.noise_floor.fill(-40.0);\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/audio_toolkit/bin/cli.rs",
    "content": "use hound::WavWriter;\nuse std::io::{self, Write};\n\nuse handy_app_lib::audio_toolkit::{\n    audio::{list_input_devices, CpalDeviceInfo},\n    vad::SmoothedVad,\n    AudioRecorder, SileroVad,\n};\n\n#[derive(Debug, Clone, PartialEq)]\nenum RecorderMode {\n    AlwaysOn,\n    OnDemand,\n}\n\nimpl std::fmt::Display for RecorderMode {\n    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n        match self {\n            RecorderMode::AlwaysOn => write!(f, \"Always-On\"),\n            RecorderMode::OnDemand => write!(f, \"On-Demand\"),\n        }\n    }\n}\n\nstruct RecorderState {\n    recorder: AudioRecorder,\n    mode: RecorderMode,\n    is_recording: bool,\n    is_open: bool,\n    current_device_index: Option<usize>,\n    recording_index: u32,\n}\n\nimpl RecorderState {\n    fn new(recorder: AudioRecorder) -> Self {\n        Self {\n            recorder,\n            mode: RecorderMode::AlwaysOn,\n            is_recording: false,\n            is_open: false,\n            current_device_index: None,\n            recording_index: 1,\n        }\n    }\n\n    fn switch_mode(&mut self, new_mode: RecorderMode) -> Result<(), Box<dyn std::error::Error>> {\n        if self.mode == new_mode {\n            return Ok(());\n        }\n\n        // If we're currently recording, stop first\n        if self.is_recording {\n            println!(\"Stopping current recording to switch modes...\");\n            self.stop_recording()?;\n        }\n\n        // Close if open and switching to on-demand, or if switching from on-demand to always-on\n        if self.is_open {\n            match (&self.mode, &new_mode) {\n                (RecorderMode::AlwaysOn, RecorderMode::OnDemand) => {\n                    self.recorder.close()?;\n                    self.is_open = false;\n                    println!(\"Closed recorder for On-Demand mode\");\n                }\n                (RecorderMode::OnDemand, RecorderMode::AlwaysOn) => {\n                    // For switching from on-demand to always-on, we need to reopen\n                    // This will be handled when the user starts recording\n                }\n                _ => {}\n            }\n        }\n\n        self.mode = new_mode;\n        println!(\"Switched to {} mode\", self.mode);\n        Ok(())\n    }\n\n    fn start_recording(\n        &mut self,\n        device_index: Option<usize>,\n        devices: &[CpalDeviceInfo],\n    ) -> Result<(), Box<dyn std::error::Error>> {\n        if self.is_recording {\n            return Err(\"Already recording! Stop the current recording first.\".into());\n        }\n\n        let device = if let Some(idx) = device_index {\n            if idx >= devices.len() {\n                return Err(format!(\n                    \"Invalid device index: {}. Available devices: 0-{}\",\n                    idx,\n                    devices.len() - 1\n                )\n                .into());\n            }\n            Some(devices[idx].device.clone())\n        } else {\n            None\n        };\n\n        match self.mode {\n            RecorderMode::AlwaysOn => {\n                // In always-on mode, open once and keep open\n                if !self.is_open || self.current_device_index != device_index {\n                    if self.is_open {\n                        self.recorder.close()?;\n                    }\n                    self.recorder.open(device)?;\n                    self.is_open = true;\n                    self.current_device_index = device_index;\n                    println!(\"Opened recorder in Always-On mode\");\n                }\n                self.recorder.start()?;\n            }\n            RecorderMode::OnDemand => {\n                // In on-demand mode, open for each recording\n                if self.is_open {\n                    self.recorder.close()?;\n                }\n                self.recorder.open(device)?;\n                self.is_open = true;\n                self.current_device_index = device_index;\n                self.recorder.start()?;\n                println!(\"Opened and started recorder in On-Demand mode\");\n            }\n        }\n\n        self.is_recording = true;\n        println!(\n            \"Recording started with device: {}\",\n            device_index.map_or(\"default\".to_string(), |i| i.to_string())\n        );\n        Ok(())\n    }\n\n    fn stop_recording(&mut self) -> Result<Vec<f32>, Box<dyn std::error::Error>> {\n        if !self.is_recording {\n            return Err(\"No recording in progress.\".into());\n        }\n\n        let samples = self.recorder.stop()?;\n        self.is_recording = false;\n\n        match self.mode {\n            RecorderMode::AlwaysOn => {\n                // Keep the recorder open for next recording\n                println!(\"Recording stopped. Recorder remains open for next recording.\");\n            }\n            RecorderMode::OnDemand => {\n                // Close the recorder after each recording\n                self.recorder.close()?;\n                self.is_open = false;\n                self.current_device_index = None;\n                println!(\"Recording stopped and recorder closed.\");\n            }\n        }\n\n        Ok(samples)\n    }\n\n    fn close(&mut self) -> Result<(), Box<dyn std::error::Error>> {\n        if self.is_recording {\n            self.stop_recording()?;\n        }\n        if self.is_open {\n            self.recorder.close()?;\n            self.is_open = false;\n        }\n        Ok(())\n    }\n}\n\nfn main() -> Result<(), Box<dyn std::error::Error>> {\n    println!(\"Advanced Audio Recorder CLI\");\n    println!(\"=========================\");\n    print_help();\n\n    let silero = SileroVad::new(\"./resources/models/silero_vad_v4.onnx\", 0.5)?;\n    let smoothed_vad = SmoothedVad::new(Box::new(silero), 15, 15);\n    let recorder = AudioRecorder::new()?.with_vad(Box::new(smoothed_vad));\n    let mut state = RecorderState::new(recorder);\n\n    let mut devices = list_input_devices()?;\n    print_devices(&devices);\n\n    loop {\n        print!(\"[{}] > \", state.mode);\n        io::stdout().flush()?;\n\n        let mut input = String::new();\n        io::stdin().read_line(&mut input)?;\n        let parts: Vec<&str> = input.trim().split_whitespace().collect();\n\n        if parts.is_empty() {\n            continue;\n        }\n\n        let command = parts[0].to_lowercase();\n\n        match command.as_str() {\n            \"start\" | \"s\" => {\n                let device_index = if parts.len() > 1 {\n                    match parts[1].parse::<usize>() {\n                        Ok(idx) => Some(idx),\n                        Err(_) => {\n                            println!(\"Invalid device index format. Usage: start [device_index]\");\n                            continue;\n                        }\n                    }\n                } else {\n                    None\n                };\n\n                match state.start_recording(device_index, &devices) {\n                    Ok(_) => println!(\"Recording started successfully!\"),\n                    Err(e) => println!(\"Error starting recording: {}\", e),\n                }\n            }\n            \"stop\" => match state.stop_recording() {\n                Ok(samples) => {\n                    if !samples.is_empty() {\n                        let filename = format!(\"recording_{}.wav\", state.recording_index);\n                        match save_audio(&samples, &filename) {\n                            Ok(_) => {\n                                println!(\"Recording saved as: {}\", filename);\n                                state.recording_index += 1;\n                            }\n                            Err(e) => println!(\"Error saving recording: {}\", e),\n                        }\n                    } else {\n                        println!(\"No audio data captured.\");\n                    }\n                }\n                Err(e) => println!(\"Error stopping recording: {}\", e),\n            },\n            \"mode\" => {\n                if parts.len() > 1 {\n                    let new_mode = match parts[1].to_lowercase().as_str() {\n                        \"always\" | \"alwayson\" | \"always-on\" | \"a\" => RecorderMode::AlwaysOn,\n                        \"demand\" | \"ondemand\" | \"on-demand\" | \"d\" => RecorderMode::OnDemand,\n                        _ => {\n                            println!(\"Invalid mode. Use 'always' or 'demand'\");\n                            continue;\n                        }\n                    };\n                    match state.switch_mode(new_mode) {\n                        Ok(_) => {}\n                        Err(e) => println!(\"Error switching modes: {}\", e),\n                    }\n                } else {\n                    println!(\"Current mode: {}\", state.mode);\n                    println!(\"Usage: mode [always|demand]\");\n                }\n            }\n            \"devices\" | \"dev\" => {\n                devices = list_input_devices()?;\n                print_devices(&devices);\n            }\n            \"status\" => {\n                println!(\"Status:\");\n                println!(\"  Mode: {}\", state.mode);\n                println!(\n                    \"  Recording: {}\",\n                    if state.is_recording { \"Yes\" } else { \"No\" }\n                );\n                println!(\n                    \"  Recorder Open: {}\",\n                    if state.is_open { \"Yes\" } else { \"No\" }\n                );\n                println!(\n                    \"  Current Device: {}\",\n                    state\n                        .current_device_index\n                        .map_or(\"None\".to_string(), |i| i.to_string())\n                );\n                println!(\"  Next Recording: recording_{}.wav\", state.recording_index);\n            }\n            \"help\" | \"h\" => {\n                print_help();\n            }\n            \"quit\" | \"exit\" | \"q\" => {\n                println!(\"Shutting down...\");\n                match state.close() {\n                    Ok(_) => {\n                        if state.is_recording {\n                            println!(\n                                \"Final recording saved as: recording_{}.wav\",\n                                state.recording_index\n                            );\n                        }\n                    }\n                    Err(e) => println!(\"Error during shutdown: {}\", e),\n                }\n                println!(\"Goodbye!\");\n                break;\n            }\n            \"\" => {\n                // Empty input, continue\n            }\n            _ => {\n                println!(\n                    \"Unknown command: '{}'. Type 'help' for available commands.\",\n                    command\n                );\n            }\n        }\n    }\n\n    Ok(())\n}\n\nfn print_help() {\n    println!(\"Commands:\");\n    println!(\n        \"  start [device_index] | s [device_index]  - Start recording (optionally with device)\"\n    );\n    println!(\"  stop                                      - Stop recording and save\");\n    println!(\n        \"  mode [always|demand]                      - Switch recording mode or show current mode\"\n    );\n    println!(\"  devices | dev                             - List available audio devices\");\n    println!(\"  status                                    - Show current recorder status\");\n    println!(\"  help | h                                  - Show this help message\");\n    println!(\"  quit | exit | q                           - Exit the program\");\n    println!();\n    println!(\"Modes:\");\n    println!(\"  Always-On: Keeps recorder open for quick start/stop cycles\");\n    println!(\"  On-Demand: Opens/closes recorder for each recording session\");\n    println!();\n}\n\nfn print_devices(devices: &[CpalDeviceInfo]) {\n    println!(\"Available audio devices:\");\n    for (index, device) in devices.iter().enumerate() {\n        println!(\"  {}: {}\", index, device.name);\n    }\n    println!();\n}\n\nfn save_audio(samples: &[f32], filename: &str) -> Result<(), Box<dyn std::error::Error>> {\n    let spec = hound::WavSpec {\n        channels: 1,\n        sample_rate: 16000,\n        bits_per_sample: 16,\n        sample_format: hound::SampleFormat::Int,\n    };\n\n    let mut writer = WavWriter::create(filename, spec)?;\n\n    for &sample in samples {\n        let sample_i16 = (sample * i16::MAX as f32) as i16;\n        writer.write_sample(sample_i16)?;\n    }\n\n    writer.finalize()?;\n    Ok(())\n}\n"
  },
  {
    "path": "src-tauri/src/audio_toolkit/constants.rs",
    "content": "pub const WHISPER_SAMPLE_RATE: u32 = 16000;\n"
  },
  {
    "path": "src-tauri/src/audio_toolkit/mod.rs",
    "content": "pub mod audio;\npub mod constants;\npub mod text;\npub mod utils;\npub mod vad;\n\npub use audio::{\n    is_microphone_access_denied, list_input_devices, list_output_devices, save_wav_file,\n    AudioRecorder, CpalDeviceInfo,\n};\npub use text::{apply_custom_words, filter_transcription_output};\npub use utils::get_cpal_host;\npub use vad::{SileroVad, VoiceActivityDetector};\n"
  },
  {
    "path": "src-tauri/src/audio_toolkit/text.rs",
    "content": "use natural::phonetics::soundex;\nuse once_cell::sync::Lazy;\nuse regex::Regex;\nuse strsim::levenshtein;\n\n/// Builds an n-gram string by cleaning and concatenating words\n///\n/// Strips punctuation from each word, lowercases, and joins without spaces.\n/// This allows matching \"Charge B\" against \"ChargeBee\".\nfn build_ngram(words: &[&str]) -> String {\n    words\n        .iter()\n        .map(|w| {\n            w.trim_matches(|c: char| !c.is_alphanumeric())\n                .to_lowercase()\n        })\n        .collect::<Vec<_>>()\n        .concat()\n}\n\n/// Finds the best matching custom word for a candidate string\n///\n/// Uses Levenshtein distance and Soundex phonetic matching to find\n/// the best match above the given threshold.\n///\n/// # Arguments\n/// * `candidate` - The cleaned/lowercased candidate string to match\n/// * `custom_words` - Original custom words (for returning the replacement)\n/// * `custom_words_nospace` - Custom words with spaces removed, lowercased (for comparison)\n/// * `threshold` - Maximum similarity score to accept\n///\n/// # Returns\n/// The best matching custom word and its score, if any match was found\nfn find_best_match<'a>(\n    candidate: &str,\n    custom_words: &'a [String],\n    custom_words_nospace: &[String],\n    threshold: f64,\n) -> Option<(&'a String, f64)> {\n    if candidate.is_empty() || candidate.len() > 50 {\n        return None;\n    }\n\n    let mut best_match: Option<&String> = None;\n    let mut best_score = f64::MAX;\n\n    for (i, custom_word_nospace) in custom_words_nospace.iter().enumerate() {\n        // Skip if lengths are too different (optimization + prevents over-matching)\n        // Use percentage-based check: max 25% length difference (prevents n-grams from\n        // matching significantly shorter custom words, e.g., \"openaigpt\" vs \"openai\")\n        let len_diff = (candidate.len() as i32 - custom_word_nospace.len() as i32).abs() as f64;\n        let max_len = candidate.len().max(custom_word_nospace.len()) as f64;\n        let max_allowed_diff = (max_len * 0.25).max(2.0); // At least 2 chars difference allowed\n        if len_diff > max_allowed_diff {\n            continue;\n        }\n\n        // Calculate Levenshtein distance (normalized by length)\n        let levenshtein_dist = levenshtein(candidate, custom_word_nospace);\n        let max_len = candidate.len().max(custom_word_nospace.len()) as f64;\n        let levenshtein_score = if max_len > 0.0 {\n            levenshtein_dist as f64 / max_len\n        } else {\n            1.0\n        };\n\n        // Calculate phonetic similarity using Soundex\n        let phonetic_match = soundex(candidate, custom_word_nospace);\n\n        // Combine scores: favor phonetic matches, but also consider string similarity\n        let combined_score = if phonetic_match {\n            levenshtein_score * 0.3 // Give significant boost to phonetic matches\n        } else {\n            levenshtein_score\n        };\n\n        // Accept if the score is good enough (configurable threshold)\n        if combined_score < threshold && combined_score < best_score {\n            best_match = Some(&custom_words[i]);\n            best_score = combined_score;\n        }\n    }\n\n    best_match.map(|m| (m, best_score))\n}\n\n/// Applies custom word corrections to transcribed text using fuzzy matching\n///\n/// This function corrects words in the input text by finding the best matches\n/// from a list of custom words using a combination of:\n/// - Levenshtein distance for string similarity\n/// - Soundex phonetic matching for pronunciation similarity\n/// - N-gram matching for multi-word speech artifacts (e.g., \"Charge B\" -> \"ChargeBee\")\n///\n/// # Arguments\n/// * `text` - The input text to correct\n/// * `custom_words` - List of custom words to match against\n/// * `threshold` - Maximum similarity score to accept (0.0 = exact match, 1.0 = any match)\n///\n/// # Returns\n/// The corrected text with custom words applied\npub fn apply_custom_words(text: &str, custom_words: &[String], threshold: f64) -> String {\n    if custom_words.is_empty() {\n        return text.to_string();\n    }\n\n    // Pre-compute lowercase versions to avoid repeated allocations\n    let custom_words_lower: Vec<String> = custom_words.iter().map(|w| w.to_lowercase()).collect();\n\n    // Pre-compute versions with spaces removed for n-gram comparison\n    let custom_words_nospace: Vec<String> = custom_words_lower\n        .iter()\n        .map(|w| w.replace(' ', \"\"))\n        .collect();\n\n    let words: Vec<&str> = text.split_whitespace().collect();\n    let mut result = Vec::new();\n    let mut i = 0;\n\n    while i < words.len() {\n        let mut matched = false;\n\n        // Try n-grams from longest (3) to shortest (1) - greedy matching\n        for n in (1..=3).rev() {\n            if i + n > words.len() {\n                continue;\n            }\n\n            let ngram_words = &words[i..i + n];\n            let ngram = build_ngram(ngram_words);\n\n            if let Some((replacement, _score)) =\n                find_best_match(&ngram, custom_words, &custom_words_nospace, threshold)\n            {\n                // Extract punctuation from first and last words of the n-gram\n                let (prefix, _) = extract_punctuation(ngram_words[0]);\n                let (_, suffix) = extract_punctuation(ngram_words[n - 1]);\n\n                // Preserve case from first word\n                let corrected = preserve_case_pattern(ngram_words[0], replacement);\n\n                result.push(format!(\"{}{}{}\", prefix, corrected, suffix));\n                i += n;\n                matched = true;\n                break;\n            }\n        }\n\n        if !matched {\n            result.push(words[i].to_string());\n            i += 1;\n        }\n    }\n\n    result.join(\" \")\n}\n\n/// Preserves the case pattern of the original word when applying a replacement\nfn preserve_case_pattern(original: &str, replacement: &str) -> String {\n    if original.chars().all(|c| c.is_uppercase()) {\n        replacement.to_uppercase()\n    } else if original.chars().next().map_or(false, |c| c.is_uppercase()) {\n        let mut chars: Vec<char> = replacement.chars().collect();\n        if let Some(first_char) = chars.get_mut(0) {\n            *first_char = first_char.to_uppercase().next().unwrap_or(*first_char);\n        }\n        chars.into_iter().collect()\n    } else {\n        replacement.to_string()\n    }\n}\n\n/// Extracts punctuation prefix and suffix from a word\nfn extract_punctuation(word: &str) -> (&str, &str) {\n    let prefix_end = word.chars().take_while(|c| !c.is_alphanumeric()).count();\n    let suffix_start = word\n        .char_indices()\n        .rev()\n        .take_while(|(_, c)| !c.is_alphanumeric())\n        .count();\n\n    let prefix = if prefix_end > 0 {\n        &word[..prefix_end]\n    } else {\n        \"\"\n    };\n\n    let suffix = if suffix_start > 0 {\n        &word[word.len() - suffix_start..]\n    } else {\n        \"\"\n    };\n\n    (prefix, suffix)\n}\n\n/// Returns filler words appropriate for the given language code.\n///\n/// Some words like \"um\" and \"ha\" are real words in certain languages\n/// (e.g., Portuguese \"um\" = \"a/an\", Spanish \"ha\" = \"has\"), so we only\n/// include them as fillers for languages where they are truly fillers.\nfn get_filler_words_for_language(lang: &str) -> &'static [&'static str] {\n    let base_lang = lang.split(&['-', '_'][..]).next().unwrap_or(lang);\n\n    match base_lang {\n        \"en\" => &[\n            \"uh\", \"um\", \"uhm\", \"umm\", \"uhh\", \"uhhh\", \"ah\", \"hmm\", \"hm\", \"mmm\", \"mm\", \"mh\", \"eh\",\n            \"ehh\", \"ha\",\n        ],\n        \"es\" => &[\"ehm\", \"mmm\", \"hmm\", \"hm\"],\n        \"pt\" => &[\"ahm\", \"hmm\", \"mmm\", \"hm\"],\n        \"fr\" => &[\"euh\", \"hmm\", \"hm\", \"mmm\"],\n        \"de\" => &[\"äh\", \"ähm\", \"hmm\", \"hm\", \"mmm\"],\n        \"it\" => &[\"ehm\", \"hmm\", \"mmm\", \"hm\"],\n        \"cs\" => &[\"ehm\", \"hmm\", \"mmm\", \"hm\"],\n        \"pl\" => &[\"hmm\", \"mmm\", \"hm\"],\n        \"tr\" => &[\"hmm\", \"mmm\", \"hm\"],\n        \"ru\" => &[\"хм\", \"ммм\", \"hmm\", \"mmm\"],\n        \"uk\" => &[\"хм\", \"ммм\", \"hmm\", \"mmm\"],\n        \"ar\" => &[\"hmm\", \"mmm\"],\n        \"ja\" => &[\"hmm\", \"mmm\"],\n        \"ko\" => &[\"hmm\", \"mmm\"],\n        \"vi\" => &[\"hmm\", \"mmm\", \"hm\"],\n        \"zh\" => &[\"hmm\", \"mmm\"],\n        // Conservative universal fallback (no \"um\", \"eh\", \"ha\")\n        _ => &[\n            \"uh\", \"uhm\", \"umm\", \"uhh\", \"uhhh\", \"ah\", \"hmm\", \"hm\", \"mmm\", \"mm\", \"mh\", \"ehh\",\n        ],\n    }\n}\n\nstatic MULTI_SPACE_PATTERN: Lazy<Regex> = Lazy::new(|| Regex::new(r\"\\s{2,}\").unwrap());\n\n/// Collapses repeated 1-2 letter words (3+ repetitions) to a single instance.\n/// E.g., \"wh wh wh wh\" -> \"wh\", \"I I I I\" -> \"I\"\nfn collapse_stutters(text: &str) -> String {\n    let words: Vec<&str> = text.split_whitespace().collect();\n    if words.is_empty() {\n        return text.to_string();\n    }\n\n    let mut result: Vec<&str> = Vec::new();\n    let mut i = 0;\n\n    while i < words.len() {\n        let word = words[i];\n        let word_lower = word.to_lowercase();\n\n        // Only process 1-2 letter words\n        if word_lower.len() <= 2 && word_lower.chars().all(|c| c.is_alphabetic()) {\n            // Count consecutive repetitions (case-insensitive)\n            let mut count = 1;\n            while i + count < words.len() && words[i + count].to_lowercase() == word_lower {\n                count += 1;\n            }\n\n            // If 3+ repetitions, collapse to single instance\n            if count >= 3 {\n                result.push(word);\n                i += count;\n            } else {\n                result.push(word);\n                i += 1;\n            }\n        } else {\n            result.push(word);\n            i += 1;\n        }\n    }\n\n    result.join(\" \")\n}\n\n/// Filters transcription output by removing filler words and stutter artifacts.\n///\n/// This function cleans up raw transcription text by:\n/// 1. Removing filler words based on the app language (or custom list)\n/// 2. Collapsing repeated 1-2 letter stutters (e.g., \"wh wh wh\" -> \"wh\")\n/// 3. Cleaning up excess whitespace\n///\n/// # Arguments\n/// * `text` - The raw transcription text to filter\n/// * `lang` - The app language code (e.g., \"en\", \"pt-BR\") used to select filler words\n/// * `custom_filler_words` - Optional user-provided filler word list. `Some(vec)` overrides\n///   language defaults; `Some(empty vec)` disables filtering; `None` uses language defaults.\n///\n/// # Returns\n/// The filtered text with filler words and stutters removed\npub fn filter_transcription_output(\n    text: &str,\n    lang: &str,\n    custom_filler_words: &Option<Vec<String>>,\n) -> String {\n    let mut filtered = text.to_string();\n\n    // Build filler patterns from custom list or language defaults\n    let patterns: Vec<Regex> = match custom_filler_words {\n        Some(words) => words\n            .iter()\n            .filter_map(|word| Regex::new(&format!(r\"(?i)\\b{}\\b[,.]?\", regex::escape(word))).ok())\n            .collect(),\n        None => get_filler_words_for_language(lang)\n            .iter()\n            .map(|word| Regex::new(&format!(r\"(?i)\\b{}\\b[,.]?\", regex::escape(word))).unwrap())\n            .collect(),\n    };\n\n    // Remove filler words\n    for pattern in &patterns {\n        filtered = pattern.replace_all(&filtered, \"\").to_string();\n    }\n\n    // Collapse repeated 1-2 letter words (stutter artifacts like \"wh wh wh wh\")\n    filtered = collapse_stutters(&filtered);\n\n    // Clean up multiple spaces to single space\n    filtered = MULTI_SPACE_PATTERN.replace_all(&filtered, \" \").to_string();\n\n    // Trim leading/trailing whitespace\n    filtered.trim().to_string()\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn test_apply_custom_words_exact_match() {\n        let text = \"hello world\";\n        let custom_words = vec![\"Hello\".to_string(), \"World\".to_string()];\n        let result = apply_custom_words(text, &custom_words, 0.5);\n        assert_eq!(result, \"Hello World\");\n    }\n\n    #[test]\n    fn test_apply_custom_words_fuzzy_match() {\n        let text = \"helo wrold\";\n        let custom_words = vec![\"hello\".to_string(), \"world\".to_string()];\n        let result = apply_custom_words(text, &custom_words, 0.5);\n        assert_eq!(result, \"hello world\");\n    }\n\n    #[test]\n    fn test_preserve_case_pattern() {\n        assert_eq!(preserve_case_pattern(\"HELLO\", \"world\"), \"WORLD\");\n        assert_eq!(preserve_case_pattern(\"Hello\", \"world\"), \"World\");\n        assert_eq!(preserve_case_pattern(\"hello\", \"WORLD\"), \"WORLD\");\n    }\n\n    #[test]\n    fn test_extract_punctuation() {\n        assert_eq!(extract_punctuation(\"hello\"), (\"\", \"\"));\n        assert_eq!(extract_punctuation(\"!hello?\"), (\"!\", \"?\"));\n        assert_eq!(extract_punctuation(\"...hello...\"), (\"...\", \"...\"));\n    }\n\n    #[test]\n    fn test_empty_custom_words() {\n        let text = \"hello world\";\n        let custom_words = vec![];\n        let result = apply_custom_words(text, &custom_words, 0.5);\n        assert_eq!(result, \"hello world\");\n    }\n\n    #[test]\n    fn test_filter_filler_words() {\n        let text = \"So uhm I was thinking uh about this\";\n        let result = filter_transcription_output(text, \"en\", &None);\n        assert_eq!(result, \"So I was thinking about this\");\n    }\n\n    #[test]\n    fn test_filter_filler_words_case_insensitive() {\n        let text = \"UHM this is UH a test\";\n        let result = filter_transcription_output(text, \"en\", &None);\n        assert_eq!(result, \"this is a test\");\n    }\n\n    #[test]\n    fn test_filter_filler_words_with_punctuation() {\n        let text = \"Well, uhm, I think, uh. that's right\";\n        let result = filter_transcription_output(text, \"en\", &None);\n        assert_eq!(result, \"Well, I think, that's right\");\n    }\n\n    #[test]\n    fn test_filter_cleans_whitespace() {\n        let text = \"Hello    world   test\";\n        let result = filter_transcription_output(text, \"en\", &None);\n        assert_eq!(result, \"Hello world test\");\n    }\n\n    #[test]\n    fn test_filter_trims() {\n        let text = \"  Hello world  \";\n        let result = filter_transcription_output(text, \"en\", &None);\n        assert_eq!(result, \"Hello world\");\n    }\n\n    #[test]\n    fn test_filter_combined() {\n        let text = \"  Uhm, so I was, uh, thinking about this  \";\n        let result = filter_transcription_output(text, \"en\", &None);\n        assert_eq!(result, \"so I was, thinking about this\");\n    }\n\n    #[test]\n    fn test_filter_preserves_valid_text() {\n        let text = \"This is a completely normal sentence.\";\n        let result = filter_transcription_output(text, \"en\", &None);\n        assert_eq!(result, \"This is a completely normal sentence.\");\n    }\n\n    #[test]\n    fn test_filter_stutter_collapse() {\n        let text = \"w wh wh wh wh wh wh wh wh wh why\";\n        let result = filter_transcription_output(text, \"en\", &None);\n        assert_eq!(result, \"w wh why\");\n    }\n\n    #[test]\n    fn test_filter_stutter_short_words() {\n        let text = \"I I I I think so so so so\";\n        let result = filter_transcription_output(text, \"en\", &None);\n        assert_eq!(result, \"I think so\");\n    }\n\n    #[test]\n    fn test_filter_stutter_mixed_case() {\n        let text = \"No NO no NO no\";\n        let result = filter_transcription_output(text, \"en\", &None);\n        assert_eq!(result, \"No\");\n    }\n\n    #[test]\n    fn test_filter_stutter_preserves_two_repetitions() {\n        let text = \"no no is fine\";\n        let result = filter_transcription_output(text, \"en\", &None);\n        assert_eq!(result, \"no no is fine\");\n    }\n\n    #[test]\n    fn test_filter_english_removes_um() {\n        let text = \"um I think um this is good\";\n        let result = filter_transcription_output(text, \"en\", &None);\n        assert_eq!(result, \"I think this is good\");\n    }\n\n    #[test]\n    fn test_filter_portuguese_preserves_um() {\n        // \"um\" means \"a/an\" in Portuguese\n        let text = \"um gato bonito\";\n        let result = filter_transcription_output(text, \"pt\", &None);\n        assert_eq!(result, \"um gato bonito\");\n    }\n\n    #[test]\n    fn test_filter_spanish_preserves_ha() {\n        // \"ha\" means \"has\" in Spanish\n        let text = \"ha sido un buen día\";\n        let result = filter_transcription_output(text, \"es\", &None);\n        assert_eq!(result, \"ha sido un buen día\");\n    }\n\n    #[test]\n    fn test_filter_language_code_with_region() {\n        // \"pt-BR\" should normalize to \"pt\"\n        let text = \"um gato bonito\";\n        let result = filter_transcription_output(text, \"pt-BR\", &None);\n        assert_eq!(result, \"um gato bonito\");\n    }\n\n    #[test]\n    fn test_filter_custom_filler_words_override() {\n        let custom = Some(vec![\"okay\".to_string(), \"right\".to_string()]);\n        let text = \"okay so I think right this works\";\n        let result = filter_transcription_output(text, \"en\", &custom);\n        assert_eq!(result, \"so I think this works\");\n    }\n\n    #[test]\n    fn test_filter_custom_filler_words_empty_disables() {\n        let custom = Some(vec![]);\n        let text = \"So uhm I was thinking uh about this\";\n        let result = filter_transcription_output(text, \"en\", &custom);\n        // No filler words removed since custom list is empty\n        assert_eq!(result, \"So uhm I was thinking uh about this\");\n    }\n\n    #[test]\n    fn test_filter_unknown_language_uses_fallback() {\n        let text = \"uh I think uhm this works\";\n        let result = filter_transcription_output(text, \"xx\", &None);\n        assert_eq!(result, \"I think this works\");\n    }\n\n    #[test]\n    fn test_filter_fallback_does_not_remove_um() {\n        // Fallback (unknown language) should not remove \"um\" since it's a real word in some languages\n        let text = \"um I think this works\";\n        let result = filter_transcription_output(text, \"xx\", &None);\n        assert_eq!(result, \"um I think this works\");\n    }\n\n    #[test]\n    fn test_apply_custom_words_ngram_two_words() {\n        let text = \"il cui nome è Charge B, che permette\";\n        let custom_words = vec![\"ChargeBee\".to_string()];\n        let result = apply_custom_words(text, &custom_words, 0.5);\n        assert!(result.contains(\"ChargeBee,\"));\n        assert!(!result.contains(\"Charge B\"));\n    }\n\n    #[test]\n    fn test_apply_custom_words_ngram_three_words() {\n        let text = \"use Chat G P T for this\";\n        let custom_words = vec![\"ChatGPT\".to_string()];\n        let result = apply_custom_words(text, &custom_words, 0.5);\n        assert!(result.contains(\"ChatGPT\"));\n    }\n\n    #[test]\n    fn test_apply_custom_words_prefers_longer_ngram() {\n        let text = \"Open AI GPT model\";\n        let custom_words = vec![\"OpenAI\".to_string(), \"GPT\".to_string()];\n        let result = apply_custom_words(text, &custom_words, 0.5);\n        assert_eq!(result, \"OpenAI GPT model\");\n    }\n\n    #[test]\n    fn test_apply_custom_words_ngram_preserves_case() {\n        let text = \"CHARGE B is great\";\n        let custom_words = vec![\"ChargeBee\".to_string()];\n        let result = apply_custom_words(text, &custom_words, 0.5);\n        assert!(result.contains(\"CHARGEBEE\"));\n    }\n\n    #[test]\n    fn test_apply_custom_words_ngram_with_spaces_in_custom() {\n        // Custom word with space should also match against split words\n        let text = \"using Mac Book Pro\";\n        let custom_words = vec![\"MacBook Pro\".to_string()];\n        let result = apply_custom_words(text, &custom_words, 0.5);\n        assert!(result.contains(\"MacBook\"));\n    }\n\n    #[test]\n    fn test_apply_custom_words_trailing_number_not_doubled() {\n        // Verify that trailing non-alpha chars (like numbers) aren't double-counted\n        // between build_ngram stripping them and extract_punctuation capturing them\n        let text = \"use GPT4 for this\";\n        let custom_words = vec![\"GPT-4\".to_string()];\n        let result = apply_custom_words(text, &custom_words, 0.5);\n        // Should NOT produce \"GPT-44\" (double-counting the trailing 4)\n        assert!(\n            !result.contains(\"GPT-44\"),\n            \"got double-counted result: {}\",\n            result\n        );\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/audio_toolkit/utils.rs",
    "content": "/// Returns the appropriate CPAL host for the current platform.\n/// On Linux, uses ALSA host. On other platforms, uses the default host.\npub fn get_cpal_host() -> cpal::Host {\n    #[cfg(target_os = \"linux\")]\n    {\n        cpal::host_from_id(cpal::HostId::Alsa).unwrap_or_else(|_| cpal::default_host())\n    }\n    #[cfg(not(target_os = \"linux\"))]\n    {\n        cpal::default_host()\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/audio_toolkit/vad/mod.rs",
    "content": "use anyhow::Result;\n\npub enum VadFrame<'a> {\n    /// Speech – may aggregate several frames (prefill + current + hangover)\n    Speech(&'a [f32]),\n    /// Non-speech (silence, noise). Down-stream code can ignore it.\n    Noise,\n}\n\nimpl<'a> VadFrame<'a> {\n    #[inline]\n    pub fn is_speech(&self) -> bool {\n        matches!(self, VadFrame::Speech(_))\n    }\n}\n\npub trait VoiceActivityDetector: Send + Sync {\n    /// Primary streaming API: feed one 30-ms frame, get keep/drop decision.\n    fn push_frame<'a>(&'a mut self, frame: &'a [f32]) -> Result<VadFrame<'a>>;\n\n    fn is_voice(&mut self, frame: &[f32]) -> Result<bool> {\n        Ok(self.push_frame(frame)?.is_speech())\n    }\n\n    fn reset(&mut self) {}\n}\n\nmod silero;\nmod smoothed;\n\npub use silero::SileroVad;\npub use smoothed::SmoothedVad;\n"
  },
  {
    "path": "src-tauri/src/audio_toolkit/vad/silero.rs",
    "content": "use anyhow::Result;\nuse std::path::Path;\n\nuse vad_rs::Vad;\n\nuse super::{VadFrame, VoiceActivityDetector};\nuse crate::audio_toolkit::constants;\n\nconst SILERO_FRAME_MS: u32 = 30;\nconst SILERO_FRAME_SAMPLES: usize =\n    (constants::WHISPER_SAMPLE_RATE * SILERO_FRAME_MS / 1000) as usize;\n\npub struct SileroVad {\n    engine: Vad,\n    threshold: f32,\n}\n\nimpl SileroVad {\n    pub fn new<P: AsRef<Path>>(model_path: P, threshold: f32) -> Result<Self> {\n        if !(0.0..=1.0).contains(&threshold) {\n            anyhow::bail!(\"threshold must be between 0.0 and 1.0\");\n        }\n\n        Ok(Self {\n            engine: Vad::new(&model_path, constants::WHISPER_SAMPLE_RATE as usize)\n                .map_err(|e| anyhow::anyhow!(\"Failed to create VAD: {e}\"))?,\n            threshold,\n        })\n    }\n}\n\nimpl VoiceActivityDetector for SileroVad {\n    fn push_frame<'a>(&'a mut self, frame: &'a [f32]) -> Result<VadFrame<'a>> {\n        if frame.len() != SILERO_FRAME_SAMPLES {\n            anyhow::bail!(\n                \"expected {SILERO_FRAME_SAMPLES} samples, got {}\",\n                frame.len()\n            );\n        }\n\n        let result = self\n            .engine\n            .compute(frame)\n            .map_err(|e| anyhow::anyhow!(\"Silero VAD error: {e}\"))?;\n\n        if result.prob > self.threshold {\n            Ok(VadFrame::Speech(frame))\n        } else {\n            Ok(VadFrame::Noise)\n        }\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/audio_toolkit/vad/smoothed.rs",
    "content": "use super::{VadFrame, VoiceActivityDetector};\nuse anyhow::Result;\nuse std::collections::VecDeque;\n\npub struct SmoothedVad {\n    inner_vad: Box<dyn VoiceActivityDetector>,\n    prefill_frames: usize,\n    hangover_frames: usize,\n    onset_frames: usize,\n\n    frame_buffer: VecDeque<Vec<f32>>,\n    hangover_counter: usize,\n    onset_counter: usize,\n    in_speech: bool,\n\n    temp_out: Vec<f32>,\n}\n\nimpl SmoothedVad {\n    pub fn new(\n        inner_vad: Box<dyn VoiceActivityDetector>,\n        prefill_frames: usize,\n        hangover_frames: usize,\n        onset_frames: usize,\n    ) -> Self {\n        Self {\n            inner_vad,\n            prefill_frames,\n            hangover_frames,\n            onset_frames,\n            frame_buffer: VecDeque::new(),\n            hangover_counter: 0,\n            onset_counter: 0,\n            in_speech: false,\n            temp_out: Vec::new(),\n        }\n    }\n}\n\nimpl VoiceActivityDetector for SmoothedVad {\n    fn push_frame<'a>(&'a mut self, frame: &'a [f32]) -> Result<VadFrame<'a>> {\n        // 1. Buffer every incoming frame for possible pre-roll\n        self.frame_buffer.push_back(frame.to_vec());\n        while self.frame_buffer.len() > self.prefill_frames + 1 {\n            self.frame_buffer.pop_front();\n        }\n\n        // 2. Delegate to the wrapped boolean VAD\n        let is_voice = self.inner_vad.is_voice(frame)?;\n\n        match (self.in_speech, is_voice) {\n            // Potential start of speech - need to accumulate onset frames\n            (false, true) => {\n                self.onset_counter += 1;\n                if self.onset_counter >= self.onset_frames {\n                    // We have enough consecutive voice frames to trigger speech\n                    self.in_speech = true;\n                    self.hangover_counter = self.hangover_frames;\n                    self.onset_counter = 0; // Reset for next time\n\n                    // Collect prefill + current frame\n                    self.temp_out.clear();\n                    for buf in &self.frame_buffer {\n                        self.temp_out.extend(buf);\n                    }\n                    Ok(VadFrame::Speech(&self.temp_out))\n                } else {\n                    // Not enough frames yet, still silence\n                    Ok(VadFrame::Noise)\n                }\n            }\n\n            // Ongoing Speech\n            (true, true) => {\n                self.hangover_counter = self.hangover_frames;\n                Ok(VadFrame::Speech(frame))\n            }\n\n            // End of Speech or interruption during onset phase\n            (true, false) => {\n                if self.hangover_counter > 0 {\n                    self.hangover_counter -= 1;\n                    Ok(VadFrame::Speech(frame))\n                } else {\n                    self.in_speech = false;\n                    Ok(VadFrame::Noise)\n                }\n            }\n\n            // Silence or broken onset sequence\n            (false, false) => {\n                self.onset_counter = 0; // Reset onset counter on silence\n                Ok(VadFrame::Noise)\n            }\n        }\n    }\n\n    fn reset(&mut self) {\n        self.frame_buffer.clear();\n        self.hangover_counter = 0;\n        self.onset_counter = 0;\n        self.in_speech = false;\n        self.temp_out.clear();\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/cli.rs",
    "content": "use clap::Parser;\n\n#[derive(Parser, Debug, Clone, Default)]\n#[command(name = \"handy\", about = \"Handy - Speech to Text\")]\npub struct CliArgs {\n    /// Start with the main window hidden\n    #[arg(long)]\n    pub start_hidden: bool,\n\n    /// Disable the system tray icon\n    #[arg(long)]\n    pub no_tray: bool,\n\n    /// Toggle transcription on/off (sent to running instance)\n    #[arg(long)]\n    pub toggle_transcription: bool,\n\n    /// Toggle transcription with post-processing on/off (sent to running instance)\n    #[arg(long)]\n    pub toggle_post_process: bool,\n\n    /// Cancel the current operation (sent to running instance)\n    #[arg(long)]\n    pub cancel: bool,\n\n    /// Enable debug mode with verbose logging\n    #[arg(long)]\n    pub debug: bool,\n}\n"
  },
  {
    "path": "src-tauri/src/clipboard.rs",
    "content": "use crate::input::{self, EnigoState};\n#[cfg(target_os = \"linux\")]\nuse crate::settings::TypingTool;\nuse crate::settings::{get_settings, AutoSubmitKey, ClipboardHandling, PasteMethod};\nuse enigo::{Direction, Enigo, Key, Keyboard};\nuse log::info;\nuse std::process::Command;\nuse std::time::Duration;\nuse tauri::{AppHandle, Manager};\nuse tauri_plugin_clipboard_manager::ClipboardExt;\n\n#[cfg(target_os = \"linux\")]\nuse crate::utils::{is_kde_wayland, is_wayland};\n\n/// Pastes text using the clipboard: saves current content, writes text, sends paste keystroke, restores clipboard.\nfn paste_via_clipboard(\n    enigo: &mut Enigo,\n    text: &str,\n    app_handle: &AppHandle,\n    paste_method: &PasteMethod,\n    paste_delay_ms: u64,\n) -> Result<(), String> {\n    let clipboard = app_handle.clipboard();\n    let clipboard_content = clipboard.read_text().unwrap_or_default();\n\n    // Write text to clipboard first\n    // On Wayland, prefer wl-copy for better compatibility (especially with umlauts)\n    #[cfg(target_os = \"linux\")]\n    let write_result = if is_wayland() && is_wl_copy_available() {\n        info!(\"Using wl-copy for clipboard write on Wayland\");\n        write_clipboard_via_wl_copy(text)\n    } else {\n        clipboard\n            .write_text(text)\n            .map_err(|e| format!(\"Failed to write to clipboard: {}\", e))\n    };\n\n    #[cfg(not(target_os = \"linux\"))]\n    let write_result = clipboard\n        .write_text(text)\n        .map_err(|e| format!(\"Failed to write to clipboard: {}\", e));\n\n    write_result?;\n\n    std::thread::sleep(Duration::from_millis(paste_delay_ms));\n\n    // Send paste key combo\n    #[cfg(target_os = \"linux\")]\n    let key_combo_sent = try_send_key_combo_linux(paste_method)?;\n\n    #[cfg(not(target_os = \"linux\"))]\n    let key_combo_sent = false;\n\n    // Fall back to enigo if no native tool handled it\n    if !key_combo_sent {\n        match paste_method {\n            PasteMethod::CtrlV => input::send_paste_ctrl_v(enigo)?,\n            PasteMethod::CtrlShiftV => input::send_paste_ctrl_shift_v(enigo)?,\n            PasteMethod::ShiftInsert => input::send_paste_shift_insert(enigo)?,\n            _ => return Err(\"Invalid paste method for clipboard paste\".into()),\n        }\n    }\n\n    std::thread::sleep(std::time::Duration::from_millis(50));\n\n    // Restore original clipboard content\n    // On Wayland, prefer wl-copy for better compatibility\n    #[cfg(target_os = \"linux\")]\n    if is_wayland() && is_wl_copy_available() {\n        let _ = write_clipboard_via_wl_copy(&clipboard_content);\n    } else {\n        let _ = clipboard.write_text(&clipboard_content);\n    }\n\n    #[cfg(not(target_os = \"linux\"))]\n    let _ = clipboard.write_text(&clipboard_content);\n\n    Ok(())\n}\n\n/// Attempts to send a key combination using Linux-native tools.\n/// Returns `Ok(true)` if a native tool handled it, `Ok(false)` to fall back to enigo.\n#[cfg(target_os = \"linux\")]\nfn try_send_key_combo_linux(paste_method: &PasteMethod) -> Result<bool, String> {\n    if is_wayland() {\n        // Wayland: prefer wtype (but not on KDE), then dotool, then ydotool\n        // Note: wtype doesn't work on KDE (no zwp_virtual_keyboard_manager_v1 support)\n        if !is_kde_wayland() && is_wtype_available() {\n            info!(\"Using wtype for key combo\");\n            send_key_combo_via_wtype(paste_method)?;\n            return Ok(true);\n        }\n        if is_dotool_available() {\n            info!(\"Using dotool for key combo\");\n            send_key_combo_via_dotool(paste_method)?;\n            return Ok(true);\n        }\n        if is_ydotool_available() {\n            info!(\"Using ydotool for key combo\");\n            send_key_combo_via_ydotool(paste_method)?;\n            return Ok(true);\n        }\n    } else {\n        // X11: prefer xdotool, then ydotool\n        if is_xdotool_available() {\n            info!(\"Using xdotool for key combo\");\n            send_key_combo_via_xdotool(paste_method)?;\n            return Ok(true);\n        }\n        if is_ydotool_available() {\n            info!(\"Using ydotool for key combo\");\n            send_key_combo_via_ydotool(paste_method)?;\n            return Ok(true);\n        }\n    }\n\n    Ok(false)\n}\n\n/// Attempts to type text directly using Linux-native tools.\n/// Returns `Ok(true)` if a native tool handled it, `Ok(false)` to fall back to enigo.\n#[cfg(target_os = \"linux\")]\nfn try_direct_typing_linux(text: &str, preferred_tool: TypingTool) -> Result<bool, String> {\n    // If user specified a tool, try only that one\n    if preferred_tool != TypingTool::Auto {\n        return match preferred_tool {\n            TypingTool::Wtype if is_wtype_available() => {\n                info!(\"Using user-specified wtype\");\n                type_text_via_wtype(text)?;\n                Ok(true)\n            }\n            TypingTool::Kwtype if is_kwtype_available() => {\n                info!(\"Using user-specified kwtype\");\n                type_text_via_kwtype(text)?;\n                Ok(true)\n            }\n            TypingTool::Dotool if is_dotool_available() => {\n                info!(\"Using user-specified dotool\");\n                type_text_via_dotool(text)?;\n                Ok(true)\n            }\n            TypingTool::Ydotool if is_ydotool_available() => {\n                info!(\"Using user-specified ydotool\");\n                type_text_via_ydotool(text)?;\n                Ok(true)\n            }\n            TypingTool::Xdotool if is_xdotool_available() => {\n                info!(\"Using user-specified xdotool\");\n                type_text_via_xdotool(text)?;\n                Ok(true)\n            }\n            _ => Err(format!(\n                \"Typing tool {:?} is not available on this system\",\n                preferred_tool\n            )),\n        };\n    }\n\n    // Auto mode - existing fallback chain\n    if is_wayland() {\n        // KDE Wayland: prefer kwtype (uses KDE Fake Input protocol, supports umlauts)\n        if is_kde_wayland() && is_kwtype_available() {\n            info!(\"Using kwtype for direct text input on KDE Wayland\");\n            type_text_via_kwtype(text)?;\n            return Ok(true);\n        }\n        // Wayland: prefer wtype, then dotool, then ydotool\n        // Note: wtype doesn't work on KDE (no zwp_virtual_keyboard_manager_v1 support)\n        if !is_kde_wayland() && is_wtype_available() {\n            info!(\"Using wtype for direct text input\");\n            type_text_via_wtype(text)?;\n            return Ok(true);\n        }\n        if is_dotool_available() {\n            info!(\"Using dotool for direct text input\");\n            type_text_via_dotool(text)?;\n            return Ok(true);\n        }\n        if is_ydotool_available() {\n            info!(\"Using ydotool for direct text input\");\n            type_text_via_ydotool(text)?;\n            return Ok(true);\n        }\n    } else {\n        // X11: prefer xdotool, then ydotool\n        if is_xdotool_available() {\n            info!(\"Using xdotool for direct text input\");\n            type_text_via_xdotool(text)?;\n            return Ok(true);\n        }\n        if is_ydotool_available() {\n            info!(\"Using ydotool for direct text input\");\n            type_text_via_ydotool(text)?;\n            return Ok(true);\n        }\n    }\n\n    Ok(false)\n}\n\n/// Returns the list of available typing tools on this system.\n/// Always includes \"auto\" as the first entry.\n#[cfg(target_os = \"linux\")]\npub fn get_available_typing_tools() -> Vec<String> {\n    let mut tools = vec![\"auto\".to_string()];\n    if is_wtype_available() {\n        tools.push(\"wtype\".to_string());\n    }\n    if is_kwtype_available() {\n        tools.push(\"kwtype\".to_string());\n    }\n    if is_dotool_available() {\n        tools.push(\"dotool\".to_string());\n    }\n    if is_ydotool_available() {\n        tools.push(\"ydotool\".to_string());\n    }\n    if is_xdotool_available() {\n        tools.push(\"xdotool\".to_string());\n    }\n    tools\n}\n\n/// Check if wtype is available (Wayland text input tool)\n#[cfg(target_os = \"linux\")]\nfn is_wtype_available() -> bool {\n    Command::new(\"which\")\n        .arg(\"wtype\")\n        .output()\n        .map(|output| output.status.success())\n        .unwrap_or(false)\n}\n\n/// Check if dotool is available (another Wayland text input tool)\n#[cfg(target_os = \"linux\")]\nfn is_dotool_available() -> bool {\n    Command::new(\"which\")\n        .arg(\"dotool\")\n        .output()\n        .map(|output| output.status.success())\n        .unwrap_or(false)\n}\n\n/// Check if ydotool is available (uinput-based, works on both Wayland and X11)\n#[cfg(target_os = \"linux\")]\nfn is_ydotool_available() -> bool {\n    Command::new(\"which\")\n        .arg(\"ydotool\")\n        .output()\n        .map(|output| output.status.success())\n        .unwrap_or(false)\n}\n\n#[cfg(target_os = \"linux\")]\nfn is_xdotool_available() -> bool {\n    Command::new(\"which\")\n        .arg(\"xdotool\")\n        .output()\n        .map(|output| output.status.success())\n        .unwrap_or(false)\n}\n\n/// Check if kwtype is available (KDE Wayland virtual keyboard input tool)\n#[cfg(target_os = \"linux\")]\nfn is_kwtype_available() -> bool {\n    Command::new(\"which\")\n        .arg(\"kwtype\")\n        .output()\n        .map(|output| output.status.success())\n        .unwrap_or(false)\n}\n\n/// Check if wl-copy is available (Wayland clipboard tool)\n#[cfg(target_os = \"linux\")]\nfn is_wl_copy_available() -> bool {\n    Command::new(\"which\")\n        .arg(\"wl-copy\")\n        .output()\n        .map(|output| output.status.success())\n        .unwrap_or(false)\n}\n\n/// Type text directly via wtype on Wayland.\n#[cfg(target_os = \"linux\")]\nfn type_text_via_wtype(text: &str) -> Result<(), String> {\n    let output = Command::new(\"wtype\")\n        .arg(\"--\") // Protect against text starting with -\n        .arg(text)\n        .output()\n        .map_err(|e| format!(\"Failed to execute wtype: {}\", e))?;\n\n    if !output.status.success() {\n        let stderr = String::from_utf8_lossy(&output.stderr);\n        return Err(format!(\"wtype failed: {}\", stderr));\n    }\n\n    Ok(())\n}\n\n/// Type text directly via xdotool on X11.\n#[cfg(target_os = \"linux\")]\nfn type_text_via_xdotool(text: &str) -> Result<(), String> {\n    let output = Command::new(\"xdotool\")\n        .arg(\"type\")\n        .arg(\"--clearmodifiers\")\n        .arg(\"--\")\n        .arg(text)\n        .output()\n        .map_err(|e| format!(\"Failed to execute xdotool: {}\", e))?;\n\n    if !output.status.success() {\n        let stderr = String::from_utf8_lossy(&output.stderr);\n        return Err(format!(\"xdotool failed: {}\", stderr));\n    }\n\n    Ok(())\n}\n\n/// Type text directly via dotool (works on both Wayland and X11 via uinput).\n#[cfg(target_os = \"linux\")]\nfn type_text_via_dotool(text: &str) -> Result<(), String> {\n    use std::io::Write;\n    use std::process::Stdio;\n\n    let mut child = Command::new(\"dotool\")\n        .stdin(Stdio::piped())\n        .spawn()\n        .map_err(|e| format!(\"Failed to spawn dotool: {}\", e))?;\n\n    if let Some(mut stdin) = child.stdin.take() {\n        // dotool uses \"type <text>\" command\n        writeln!(stdin, \"type {}\", text)\n            .map_err(|e| format!(\"Failed to write to dotool stdin: {}\", e))?;\n    }\n\n    let output = child\n        .wait_with_output()\n        .map_err(|e| format!(\"Failed to wait for dotool: {}\", e))?;\n\n    if !output.status.success() {\n        let stderr = String::from_utf8_lossy(&output.stderr);\n        return Err(format!(\"dotool failed: {}\", stderr));\n    }\n\n    Ok(())\n}\n\n/// Type text directly via ydotool (uinput-based, requires ydotoold daemon).\n#[cfg(target_os = \"linux\")]\nfn type_text_via_ydotool(text: &str) -> Result<(), String> {\n    let output = Command::new(\"ydotool\")\n        .arg(\"type\")\n        .arg(\"--\")\n        .arg(text)\n        .output()\n        .map_err(|e| format!(\"Failed to execute ydotool: {}\", e))?;\n\n    if !output.status.success() {\n        let stderr = String::from_utf8_lossy(&output.stderr);\n        return Err(format!(\"ydotool failed: {}\", stderr));\n    }\n\n    Ok(())\n}\n\n/// Type text directly via kwtype (KDE Wayland virtual keyboard, uses KDE Fake Input protocol).\n#[cfg(target_os = \"linux\")]\nfn type_text_via_kwtype(text: &str) -> Result<(), String> {\n    let output = Command::new(\"kwtype\")\n        .arg(\"--\")\n        .arg(text)\n        .output()\n        .map_err(|e| format!(\"Failed to execute kwtype: {}\", e))?;\n\n    if !output.status.success() {\n        let stderr = String::from_utf8_lossy(&output.stderr);\n        return Err(format!(\"kwtype failed: {}\", stderr));\n    }\n\n    Ok(())\n}\n\n/// Write text to clipboard via wl-copy (Wayland clipboard tool).\n/// Uses Stdio::null() to avoid blocking on repeated calls — wl-copy forks a\n/// daemon that inherits piped fds, causing read_to_end to hang indefinitely.\n#[cfg(target_os = \"linux\")]\nfn write_clipboard_via_wl_copy(text: &str) -> Result<(), String> {\n    use std::process::Stdio;\n    let status = Command::new(\"wl-copy\")\n        .arg(\"--\")\n        .arg(text)\n        .stdout(Stdio::null())\n        .stderr(Stdio::null())\n        .status()\n        .map_err(|e| format!(\"Failed to execute wl-copy: {}\", e))?;\n\n    if !status.success() {\n        return Err(\"wl-copy failed\".into());\n    }\n\n    Ok(())\n}\n\n/// Send a key combination (e.g., Ctrl+V) via wtype on Wayland.\n#[cfg(target_os = \"linux\")]\nfn send_key_combo_via_wtype(paste_method: &PasteMethod) -> Result<(), String> {\n    let args: Vec<&str> = match paste_method {\n        PasteMethod::CtrlV => vec![\"-M\", \"ctrl\", \"-k\", \"v\"],\n        PasteMethod::ShiftInsert => vec![\"-M\", \"shift\", \"-k\", \"Insert\"],\n        PasteMethod::CtrlShiftV => vec![\"-M\", \"ctrl\", \"-M\", \"shift\", \"-k\", \"v\"],\n        _ => return Err(\"Unsupported paste method\".into()),\n    };\n\n    let output = Command::new(\"wtype\")\n        .args(&args)\n        .output()\n        .map_err(|e| format!(\"Failed to execute wtype: {}\", e))?;\n\n    if !output.status.success() {\n        let stderr = String::from_utf8_lossy(&output.stderr);\n        return Err(format!(\"wtype failed: {}\", stderr));\n    }\n\n    Ok(())\n}\n\n/// Send a key combination (e.g., Ctrl+V) via dotool.\n#[cfg(target_os = \"linux\")]\nfn send_key_combo_via_dotool(paste_method: &PasteMethod) -> Result<(), String> {\n    let command;\n    match paste_method {\n        PasteMethod::CtrlV => command = \"echo key ctrl+v | dotool\",\n        PasteMethod::ShiftInsert => command = \"echo key shift+insert | dotool\",\n        PasteMethod::CtrlShiftV => command = \"echo key ctrl+shift+v | dotool\",\n        _ => return Err(\"Unsupported paste method\".into()),\n    }\n    use std::process::Stdio;\n    let status = Command::new(\"sh\")\n        .arg(\"-c\")\n        .arg(command)\n        .stdout(Stdio::null())\n        .stderr(Stdio::null())\n        .status()\n        .map_err(|e| format!(\"Failed to execute dotool: {}\", e))?;\n    if !status.success() {\n        return Err(\"dotool failed\".into());\n    }\n\n    Ok(())\n}\n\n/// Send a key combination (e.g., Ctrl+V) via ydotool (requires ydotoold daemon).\n#[cfg(target_os = \"linux\")]\nfn send_key_combo_via_ydotool(paste_method: &PasteMethod) -> Result<(), String> {\n    // ydotool uses Linux input event keycodes with format <keycode>:<pressed>\n    // where pressed is 1 for down, 0 for up. Keycodes: ctrl=29, shift=42, v=47, insert=110\n    let args: Vec<&str> = match paste_method {\n        PasteMethod::CtrlV => vec![\"key\", \"29:1\", \"47:1\", \"47:0\", \"29:0\"],\n        PasteMethod::ShiftInsert => vec![\"key\", \"42:1\", \"110:1\", \"110:0\", \"42:0\"],\n        PasteMethod::CtrlShiftV => vec![\"key\", \"29:1\", \"42:1\", \"47:1\", \"47:0\", \"42:0\", \"29:0\"],\n        _ => return Err(\"Unsupported paste method\".into()),\n    };\n\n    let output = Command::new(\"ydotool\")\n        .args(&args)\n        .output()\n        .map_err(|e| format!(\"Failed to execute ydotool: {}\", e))?;\n\n    if !output.status.success() {\n        let stderr = String::from_utf8_lossy(&output.stderr);\n        return Err(format!(\"ydotool failed: {}\", stderr));\n    }\n\n    Ok(())\n}\n\n/// Send a key combination (e.g., Ctrl+V) via xdotool on X11.\n#[cfg(target_os = \"linux\")]\nfn send_key_combo_via_xdotool(paste_method: &PasteMethod) -> Result<(), String> {\n    let key_combo = match paste_method {\n        PasteMethod::CtrlV => \"ctrl+v\",\n        PasteMethod::CtrlShiftV => \"ctrl+shift+v\",\n        PasteMethod::ShiftInsert => \"shift+Insert\",\n        _ => return Err(\"Unsupported paste method\".into()),\n    };\n\n    let output = Command::new(\"xdotool\")\n        .arg(\"key\")\n        .arg(\"--clearmodifiers\")\n        .arg(key_combo)\n        .output()\n        .map_err(|e| format!(\"Failed to execute xdotool: {}\", e))?;\n\n    if !output.status.success() {\n        let stderr = String::from_utf8_lossy(&output.stderr);\n        return Err(format!(\"xdotool failed: {}\", stderr));\n    }\n\n    Ok(())\n}\n\n/// Pastes text by invoking an external script.\n/// The script receives the text to paste as a single argument.\nfn paste_via_external_script(text: &str, script_path: &str) -> Result<(), String> {\n    info!(\"Pasting via external script: {}\", script_path);\n\n    let output = Command::new(script_path)\n        .arg(text)\n        .output()\n        .map_err(|e| format!(\"Failed to execute external script '{}': {}\", script_path, e))?;\n\n    if !output.status.success() {\n        let stderr = String::from_utf8_lossy(&output.stderr);\n        let stdout = String::from_utf8_lossy(&output.stdout);\n        return Err(format!(\n            \"External script '{}' failed with exit code {:?}. stderr: {}, stdout: {}\",\n            script_path,\n            output.status.code(),\n            stderr.trim(),\n            stdout.trim()\n        ));\n    }\n\n    Ok(())\n}\n\n/// Types text directly by simulating individual key presses.\nfn paste_direct(\n    enigo: &mut Enigo,\n    text: &str,\n    #[cfg(target_os = \"linux\")] typing_tool: TypingTool,\n) -> Result<(), String> {\n    #[cfg(target_os = \"linux\")]\n    {\n        if try_direct_typing_linux(text, typing_tool)? {\n            return Ok(());\n        }\n        info!(\"Falling back to enigo for direct text input\");\n    }\n\n    input::paste_text_direct(enigo, text)\n}\n\nfn send_return_key(enigo: &mut Enigo, key_type: AutoSubmitKey) -> Result<(), String> {\n    match key_type {\n        AutoSubmitKey::Enter => {\n            enigo\n                .key(Key::Return, Direction::Press)\n                .map_err(|e| format!(\"Failed to press Return key: {}\", e))?;\n            enigo\n                .key(Key::Return, Direction::Release)\n                .map_err(|e| format!(\"Failed to release Return key: {}\", e))?;\n        }\n        AutoSubmitKey::CtrlEnter => {\n            enigo\n                .key(Key::Control, Direction::Press)\n                .map_err(|e| format!(\"Failed to press Control key: {}\", e))?;\n            enigo\n                .key(Key::Return, Direction::Press)\n                .map_err(|e| format!(\"Failed to press Return key: {}\", e))?;\n            enigo\n                .key(Key::Return, Direction::Release)\n                .map_err(|e| format!(\"Failed to release Return key: {}\", e))?;\n            enigo\n                .key(Key::Control, Direction::Release)\n                .map_err(|e| format!(\"Failed to release Control key: {}\", e))?;\n        }\n        AutoSubmitKey::CmdEnter => {\n            enigo\n                .key(Key::Meta, Direction::Press)\n                .map_err(|e| format!(\"Failed to press Meta/Cmd key: {}\", e))?;\n            enigo\n                .key(Key::Return, Direction::Press)\n                .map_err(|e| format!(\"Failed to press Return key: {}\", e))?;\n            enigo\n                .key(Key::Return, Direction::Release)\n                .map_err(|e| format!(\"Failed to release Return key: {}\", e))?;\n            enigo\n                .key(Key::Meta, Direction::Release)\n                .map_err(|e| format!(\"Failed to release Meta/Cmd key: {}\", e))?;\n        }\n    }\n\n    Ok(())\n}\n\nfn should_send_auto_submit(auto_submit: bool, paste_method: PasteMethod) -> bool {\n    auto_submit && paste_method != PasteMethod::None\n}\n\npub fn paste(text: String, app_handle: AppHandle) -> Result<(), String> {\n    let settings = get_settings(&app_handle);\n    let paste_method = settings.paste_method;\n    let paste_delay_ms = settings.paste_delay_ms;\n\n    // Append trailing space if setting is enabled\n    let text = if settings.append_trailing_space {\n        format!(\"{} \", text)\n    } else {\n        text\n    };\n\n    info!(\n        \"Using paste method: {:?}, delay: {}ms\",\n        paste_method, paste_delay_ms\n    );\n\n    // Get the managed Enigo instance\n    let enigo_state = app_handle\n        .try_state::<EnigoState>()\n        .ok_or(\"Enigo state not initialized\")?;\n    let mut enigo = enigo_state\n        .0\n        .lock()\n        .map_err(|e| format!(\"Failed to lock Enigo: {}\", e))?;\n\n    // Perform the paste operation\n    match paste_method {\n        PasteMethod::None => {\n            info!(\"PasteMethod::None selected - skipping paste action\");\n        }\n        PasteMethod::Direct => {\n            paste_direct(\n                &mut enigo,\n                &text,\n                #[cfg(target_os = \"linux\")]\n                settings.typing_tool,\n            )?;\n        }\n        PasteMethod::CtrlV | PasteMethod::CtrlShiftV | PasteMethod::ShiftInsert => {\n            paste_via_clipboard(\n                &mut enigo,\n                &text,\n                &app_handle,\n                &paste_method,\n                paste_delay_ms,\n            )?\n        }\n        PasteMethod::ExternalScript => {\n            let script_path = settings\n                .external_script_path\n                .as_ref()\n                .filter(|p| !p.is_empty())\n                .ok_or(\"External script path is not configured\")?;\n            paste_via_external_script(&text, script_path)?;\n        }\n    }\n\n    if should_send_auto_submit(settings.auto_submit, paste_method) {\n        std::thread::sleep(Duration::from_millis(50));\n        send_return_key(&mut enigo, settings.auto_submit_key)?;\n    }\n\n    // After pasting, optionally copy to clipboard based on settings\n    if settings.clipboard_handling == ClipboardHandling::CopyToClipboard {\n        let clipboard = app_handle.clipboard();\n        clipboard\n            .write_text(&text)\n            .map_err(|e| format!(\"Failed to copy to clipboard: {}\", e))?;\n    }\n\n    Ok(())\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn auto_submit_requires_setting_enabled() {\n        assert!(!should_send_auto_submit(false, PasteMethod::CtrlV));\n        assert!(!should_send_auto_submit(false, PasteMethod::Direct));\n    }\n\n    #[test]\n    fn auto_submit_skips_none_paste_method() {\n        assert!(!should_send_auto_submit(true, PasteMethod::None));\n    }\n\n    #[test]\n    fn auto_submit_runs_for_active_paste_methods() {\n        assert!(should_send_auto_submit(true, PasteMethod::CtrlV));\n        assert!(should_send_auto_submit(true, PasteMethod::Direct));\n        assert!(should_send_auto_submit(true, PasteMethod::CtrlShiftV));\n        assert!(should_send_auto_submit(true, PasteMethod::ShiftInsert));\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/commands/audio.rs",
    "content": "use crate::audio_feedback;\nuse crate::audio_toolkit::audio::{list_input_devices, list_output_devices};\nuse crate::managers::audio::{AudioRecordingManager, MicrophoneMode};\nuse crate::settings::{get_settings, write_settings};\nuse log::warn;\nuse serde::{Deserialize, Serialize};\nuse specta::Type;\nuse std::sync::Arc;\nuse tauri::{AppHandle, Manager};\n\n#[cfg(target_os = \"windows\")]\nuse winreg::{\n    enums::{HKEY_CURRENT_USER, HKEY_LOCAL_MACHINE},\n    RegKey, HKEY,\n};\n\n#[derive(Serialize, Type)]\npub struct CustomSounds {\n    start: bool,\n    stop: bool,\n}\n\nfn custom_sound_exists(app: &AppHandle, sound_type: &str) -> bool {\n    crate::portable::resolve_app_data(app, &format!(\"custom_{}.wav\", sound_type))\n        .map_or(false, |path| path.exists())\n}\n\n#[tauri::command]\n#[specta::specta]\npub fn check_custom_sounds(app: AppHandle) -> CustomSounds {\n    CustomSounds {\n        start: custom_sound_exists(&app, \"start\"),\n        stop: custom_sound_exists(&app, \"stop\"),\n    }\n}\n\n#[derive(Serialize, Deserialize, Debug, Clone, Type)]\npub struct AudioDevice {\n    pub index: String,\n    pub name: String,\n    pub is_default: bool,\n}\n\n#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Type)]\n#[serde(rename_all = \"snake_case\")]\npub enum PermissionAccess {\n    Allowed,\n    Denied,\n    Unknown,\n}\n\n#[derive(Serialize, Deserialize, Debug, Clone, Type)]\npub struct WindowsMicrophonePermissionStatus {\n    pub supported: bool,\n    pub overall_access: PermissionAccess,\n    pub device_access: PermissionAccess,\n    pub app_access: PermissionAccess,\n    pub desktop_app_access: PermissionAccess,\n}\n\n#[cfg(target_os = \"windows\")]\nfn read_registry_permission_access(root_hkey: HKEY, path: &str) -> PermissionAccess {\n    let root = RegKey::predef(root_hkey);\n    let Ok(key) = root.open_subkey(path) else {\n        return PermissionAccess::Unknown;\n    };\n\n    let Ok(value) = key.get_value::<String, _>(\"Value\") else {\n        return PermissionAccess::Unknown;\n    };\n\n    match value.to_ascii_lowercase().as_str() {\n        \"allow\" => PermissionAccess::Allowed,\n        \"deny\" => PermissionAccess::Denied,\n        _ => PermissionAccess::Unknown,\n    }\n}\n\n#[cfg(target_os = \"windows\")]\nfn get_windows_microphone_permission_status_impl() -> WindowsMicrophonePermissionStatus {\n    const MICROPHONE_PATH: &str =\n        \"Software\\\\Microsoft\\\\Windows\\\\CurrentVersion\\\\CapabilityAccessManager\\\\ConsentStore\\\\microphone\";\n    const DESKTOP_APPS_PATH: &str =\n        \"Software\\\\Microsoft\\\\Windows\\\\CurrentVersion\\\\CapabilityAccessManager\\\\ConsentStore\\\\microphone\\\\NonPackaged\";\n\n    let device_access = read_registry_permission_access(HKEY_LOCAL_MACHINE, MICROPHONE_PATH);\n    let app_access = read_registry_permission_access(HKEY_CURRENT_USER, MICROPHONE_PATH);\n    let desktop_app_access = read_registry_permission_access(HKEY_CURRENT_USER, DESKTOP_APPS_PATH);\n\n    let overall_access = if [device_access, app_access, desktop_app_access]\n        .into_iter()\n        .any(|access| access == PermissionAccess::Denied)\n    {\n        PermissionAccess::Denied\n    } else if [device_access, app_access, desktop_app_access]\n        .into_iter()\n        .all(|access| access == PermissionAccess::Allowed)\n    {\n        PermissionAccess::Allowed\n    } else {\n        PermissionAccess::Unknown\n    };\n\n    WindowsMicrophonePermissionStatus {\n        supported: true,\n        overall_access,\n        device_access,\n        app_access,\n        desktop_app_access,\n    }\n}\n\n#[tauri::command]\n#[specta::specta]\npub fn get_windows_microphone_permission_status() -> WindowsMicrophonePermissionStatus {\n    #[cfg(target_os = \"windows\")]\n    {\n        get_windows_microphone_permission_status_impl()\n    }\n\n    #[cfg(not(target_os = \"windows\"))]\n    {\n        WindowsMicrophonePermissionStatus {\n            supported: false,\n            overall_access: PermissionAccess::Unknown,\n            device_access: PermissionAccess::Unknown,\n            app_access: PermissionAccess::Unknown,\n            desktop_app_access: PermissionAccess::Unknown,\n        }\n    }\n}\n\n#[tauri::command]\n#[specta::specta]\npub fn open_microphone_privacy_settings() -> Result<(), String> {\n    #[cfg(target_os = \"windows\")]\n    {\n        use std::process::Command;\n        Command::new(\"cmd\")\n            .args([\"/C\", \"start\", \"\", \"ms-settings:privacy-microphone\"])\n            .spawn()\n            .map_err(|e| format!(\"Failed to open Windows microphone privacy settings: {}\", e))?;\n        return Ok(());\n    }\n\n    #[cfg(not(target_os = \"windows\"))]\n    {\n        Err(\"Opening microphone privacy settings is only supported on Windows\".to_string())\n    }\n}\n\n#[tauri::command]\n#[specta::specta]\npub fn update_microphone_mode(app: AppHandle, always_on: bool) -> Result<(), String> {\n    // Update settings\n    let mut settings = get_settings(&app);\n    settings.always_on_microphone = always_on;\n    write_settings(&app, settings);\n\n    // Update the audio manager mode\n    let rm = app.state::<Arc<AudioRecordingManager>>();\n    let new_mode = if always_on {\n        MicrophoneMode::AlwaysOn\n    } else {\n        MicrophoneMode::OnDemand\n    };\n\n    rm.update_mode(new_mode)\n        .map_err(|e| format!(\"Failed to update microphone mode: {}\", e))\n}\n\n#[tauri::command]\n#[specta::specta]\npub fn get_microphone_mode(app: AppHandle) -> Result<bool, String> {\n    let settings = get_settings(&app);\n    Ok(settings.always_on_microphone)\n}\n\n#[tauri::command]\n#[specta::specta]\npub fn get_available_microphones() -> Result<Vec<AudioDevice>, String> {\n    let devices =\n        list_input_devices().map_err(|e| format!(\"Failed to list audio devices: {}\", e))?;\n\n    let mut result = vec![AudioDevice {\n        index: \"default\".to_string(),\n        name: \"Default\".to_string(),\n        is_default: true,\n    }];\n\n    result.extend(devices.into_iter().map(|d| AudioDevice {\n        index: d.index,\n        name: d.name,\n        is_default: false, // The explicit default is handled separately\n    }));\n\n    Ok(result)\n}\n\n#[tauri::command]\n#[specta::specta]\npub fn set_selected_microphone(app: AppHandle, device_name: String) -> Result<(), String> {\n    let mut settings = get_settings(&app);\n    settings.selected_microphone = if device_name == \"default\" {\n        None\n    } else {\n        Some(device_name)\n    };\n    write_settings(&app, settings);\n\n    // Update the audio manager to use the new device\n    let rm = app.state::<Arc<AudioRecordingManager>>();\n    rm.update_selected_device()\n        .map_err(|e| format!(\"Failed to update selected device: {}\", e))?;\n\n    Ok(())\n}\n\n#[tauri::command]\n#[specta::specta]\npub fn get_selected_microphone(app: AppHandle) -> Result<String, String> {\n    let settings = get_settings(&app);\n    Ok(settings\n        .selected_microphone\n        .unwrap_or_else(|| \"default\".to_string()))\n}\n\n#[tauri::command]\n#[specta::specta]\npub fn get_available_output_devices() -> Result<Vec<AudioDevice>, String> {\n    let devices =\n        list_output_devices().map_err(|e| format!(\"Failed to list output devices: {}\", e))?;\n\n    let mut result = vec![AudioDevice {\n        index: \"default\".to_string(),\n        name: \"Default\".to_string(),\n        is_default: true,\n    }];\n\n    result.extend(devices.into_iter().map(|d| AudioDevice {\n        index: d.index,\n        name: d.name,\n        is_default: false, // The explicit default is handled separately\n    }));\n\n    Ok(result)\n}\n\n#[tauri::command]\n#[specta::specta]\npub fn set_selected_output_device(app: AppHandle, device_name: String) -> Result<(), String> {\n    let mut settings = get_settings(&app);\n    settings.selected_output_device = if device_name == \"default\" {\n        None\n    } else {\n        Some(device_name)\n    };\n    write_settings(&app, settings);\n    Ok(())\n}\n\n#[tauri::command]\n#[specta::specta]\npub fn get_selected_output_device(app: AppHandle) -> Result<String, String> {\n    let settings = get_settings(&app);\n    Ok(settings\n        .selected_output_device\n        .unwrap_or_else(|| \"default\".to_string()))\n}\n\n#[tauri::command]\n#[specta::specta]\npub async fn play_test_sound(app: AppHandle, sound_type: String) {\n    let sound = match sound_type.as_str() {\n        \"start\" => audio_feedback::SoundType::Start,\n        \"stop\" => audio_feedback::SoundType::Stop,\n        _ => {\n            warn!(\"Unknown sound type: {}\", sound_type);\n            return;\n        }\n    };\n    audio_feedback::play_test_sound(&app, sound);\n}\n\n#[tauri::command]\n#[specta::specta]\npub fn set_clamshell_microphone(app: AppHandle, device_name: String) -> Result<(), String> {\n    let mut settings = get_settings(&app);\n    settings.clamshell_microphone = if device_name == \"default\" {\n        None\n    } else {\n        Some(device_name)\n    };\n    write_settings(&app, settings);\n    Ok(())\n}\n\n#[tauri::command]\n#[specta::specta]\npub fn get_clamshell_microphone(app: AppHandle) -> Result<String, String> {\n    let settings = get_settings(&app);\n    Ok(settings\n        .clamshell_microphone\n        .unwrap_or_else(|| \"default\".to_string()))\n}\n\n#[tauri::command]\n#[specta::specta]\npub fn is_recording(app: AppHandle) -> bool {\n    let audio_manager = app.state::<Arc<AudioRecordingManager>>();\n    audio_manager.is_recording()\n}\n"
  },
  {
    "path": "src-tauri/src/commands/history.rs",
    "content": "use crate::managers::history::{HistoryEntry, HistoryManager};\nuse std::sync::Arc;\nuse tauri::{AppHandle, State};\n\n#[tauri::command]\n#[specta::specta]\npub async fn get_history_entries(\n    _app: AppHandle,\n    history_manager: State<'_, Arc<HistoryManager>>,\n) -> Result<Vec<HistoryEntry>, String> {\n    history_manager\n        .get_history_entries()\n        .await\n        .map_err(|e| e.to_string())\n}\n\n#[tauri::command]\n#[specta::specta]\npub async fn toggle_history_entry_saved(\n    _app: AppHandle,\n    history_manager: State<'_, Arc<HistoryManager>>,\n    id: i64,\n) -> Result<(), String> {\n    history_manager\n        .toggle_saved_status(id)\n        .await\n        .map_err(|e| e.to_string())\n}\n\n#[tauri::command]\n#[specta::specta]\npub async fn get_audio_file_path(\n    _app: AppHandle,\n    history_manager: State<'_, Arc<HistoryManager>>,\n    file_name: String,\n) -> Result<String, String> {\n    let path = history_manager.get_audio_file_path(&file_name);\n    path.to_str()\n        .ok_or_else(|| \"Invalid file path\".to_string())\n        .map(|s| s.to_string())\n}\n\n#[tauri::command]\n#[specta::specta]\npub async fn delete_history_entry(\n    _app: AppHandle,\n    history_manager: State<'_, Arc<HistoryManager>>,\n    id: i64,\n) -> Result<(), String> {\n    history_manager\n        .delete_entry(id)\n        .await\n        .map_err(|e| e.to_string())\n}\n\n#[tauri::command]\n#[specta::specta]\npub async fn update_history_limit(\n    app: AppHandle,\n    history_manager: State<'_, Arc<HistoryManager>>,\n    limit: usize,\n) -> Result<(), String> {\n    let mut settings = crate::settings::get_settings(&app);\n    settings.history_limit = limit;\n    crate::settings::write_settings(&app, settings);\n\n    history_manager\n        .cleanup_old_entries()\n        .map_err(|e| e.to_string())?;\n\n    Ok(())\n}\n\n#[tauri::command]\n#[specta::specta]\npub async fn update_recording_retention_period(\n    app: AppHandle,\n    history_manager: State<'_, Arc<HistoryManager>>,\n    period: String,\n) -> Result<(), String> {\n    use crate::settings::RecordingRetentionPeriod;\n\n    let retention_period = match period.as_str() {\n        \"never\" => RecordingRetentionPeriod::Never,\n        \"preserve_limit\" => RecordingRetentionPeriod::PreserveLimit,\n        \"days3\" => RecordingRetentionPeriod::Days3,\n        \"weeks2\" => RecordingRetentionPeriod::Weeks2,\n        \"months3\" => RecordingRetentionPeriod::Months3,\n        _ => return Err(format!(\"Invalid retention period: {}\", period)),\n    };\n\n    let mut settings = crate::settings::get_settings(&app);\n    settings.recording_retention_period = retention_period;\n    crate::settings::write_settings(&app, settings);\n\n    history_manager\n        .cleanup_old_entries()\n        .map_err(|e| e.to_string())?;\n\n    Ok(())\n}\n"
  },
  {
    "path": "src-tauri/src/commands/mod.rs",
    "content": "pub mod audio;\npub mod history;\npub mod models;\npub mod transcription;\n\nuse crate::settings::{get_settings, write_settings, AppSettings, LogLevel};\nuse crate::utils::cancel_current_operation;\nuse tauri::{AppHandle, Manager};\nuse tauri_plugin_opener::OpenerExt;\n\n#[tauri::command]\n#[specta::specta]\npub fn cancel_operation(app: AppHandle) {\n    cancel_current_operation(&app);\n}\n\n#[tauri::command]\n#[specta::specta]\npub fn get_app_dir_path(app: AppHandle) -> Result<String, String> {\n    let app_data_dir = crate::portable::app_data_dir(&app)\n        .map_err(|e| format!(\"Failed to get app data directory: {}\", e))?;\n\n    Ok(app_data_dir.to_string_lossy().to_string())\n}\n\n#[tauri::command]\n#[specta::specta]\npub fn get_app_settings(app: AppHandle) -> Result<AppSettings, String> {\n    Ok(get_settings(&app))\n}\n\n#[tauri::command]\n#[specta::specta]\npub fn get_default_settings() -> Result<AppSettings, String> {\n    Ok(crate::settings::get_default_settings())\n}\n\n#[tauri::command]\n#[specta::specta]\npub fn get_log_dir_path(app: AppHandle) -> Result<String, String> {\n    let log_dir = crate::portable::app_log_dir(&app)\n        .map_err(|e| format!(\"Failed to get log directory: {}\", e))?;\n\n    Ok(log_dir.to_string_lossy().to_string())\n}\n\n#[specta::specta]\n#[tauri::command]\npub fn set_log_level(app: AppHandle, level: LogLevel) -> Result<(), String> {\n    let tauri_log_level: tauri_plugin_log::LogLevel = level.into();\n    let log_level: log::Level = tauri_log_level.into();\n    // Update the file log level atomic so the filter picks up the new level\n    crate::FILE_LOG_LEVEL.store(\n        log_level.to_level_filter() as u8,\n        std::sync::atomic::Ordering::Relaxed,\n    );\n\n    let mut settings = get_settings(&app);\n    settings.log_level = level;\n    write_settings(&app, settings);\n\n    Ok(())\n}\n\n#[specta::specta]\n#[tauri::command]\npub fn open_recordings_folder(app: AppHandle) -> Result<(), String> {\n    let app_data_dir = crate::portable::app_data_dir(&app)\n        .map_err(|e| format!(\"Failed to get app data directory: {}\", e))?;\n\n    let recordings_dir = app_data_dir.join(\"recordings\");\n\n    let path = recordings_dir.to_string_lossy().as_ref().to_string();\n    app.opener()\n        .open_path(path, None::<String>)\n        .map_err(|e| format!(\"Failed to open recordings folder: {}\", e))?;\n\n    Ok(())\n}\n\n#[specta::specta]\n#[tauri::command]\npub fn open_log_dir(app: AppHandle) -> Result<(), String> {\n    let log_dir = crate::portable::app_log_dir(&app)\n        .map_err(|e| format!(\"Failed to get log directory: {}\", e))?;\n\n    let path = log_dir.to_string_lossy().as_ref().to_string();\n    app.opener()\n        .open_path(path, None::<String>)\n        .map_err(|e| format!(\"Failed to open log directory: {}\", e))?;\n\n    Ok(())\n}\n\n#[specta::specta]\n#[tauri::command]\npub fn open_app_data_dir(app: AppHandle) -> Result<(), String> {\n    let app_data_dir = crate::portable::app_data_dir(&app)\n        .map_err(|e| format!(\"Failed to get app data directory: {}\", e))?;\n\n    let path = app_data_dir.to_string_lossy().as_ref().to_string();\n    app.opener()\n        .open_path(path, None::<String>)\n        .map_err(|e| format!(\"Failed to open app data directory: {}\", e))?;\n\n    Ok(())\n}\n\n/// Check if Apple Intelligence is available on this device.\n/// Called by the frontend when the user selects Apple Intelligence provider.\n#[specta::specta]\n#[tauri::command]\npub fn check_apple_intelligence_available() -> bool {\n    #[cfg(all(target_os = \"macos\", target_arch = \"aarch64\"))]\n    {\n        crate::apple_intelligence::check_apple_intelligence_availability()\n    }\n    #[cfg(not(all(target_os = \"macos\", target_arch = \"aarch64\")))]\n    {\n        false\n    }\n}\n\n/// Try to initialize Enigo (keyboard/mouse simulation).\n/// On macOS, this will return an error if accessibility permissions are not granted.\n#[specta::specta]\n#[tauri::command]\npub fn initialize_enigo(app: AppHandle) -> Result<(), String> {\n    use crate::input::EnigoState;\n\n    // Check if already initialized\n    if app.try_state::<EnigoState>().is_some() {\n        log::debug!(\"Enigo already initialized\");\n        return Ok(());\n    }\n\n    // Try to initialize\n    match EnigoState::new() {\n        Ok(enigo_state) => {\n            app.manage(enigo_state);\n            log::info!(\"Enigo initialized successfully after permission grant\");\n            Ok(())\n        }\n        Err(e) => {\n            if cfg!(target_os = \"macos\") {\n                log::warn!(\n                    \"Failed to initialize Enigo: {} (accessibility permissions may not be granted)\",\n                    e\n                );\n            } else {\n                log::warn!(\"Failed to initialize Enigo: {}\", e);\n            }\n            Err(format!(\"Failed to initialize input system: {}\", e))\n        }\n    }\n}\n\n/// Marker state to track if shortcuts have been initialized.\npub struct ShortcutsInitialized;\n\n/// Initialize keyboard shortcuts.\n/// On macOS, this should be called after accessibility permissions are granted.\n/// This is idempotent - calling it multiple times is safe.\n#[specta::specta]\n#[tauri::command]\npub fn initialize_shortcuts(app: AppHandle) -> Result<(), String> {\n    // Check if already initialized\n    if app.try_state::<ShortcutsInitialized>().is_some() {\n        log::debug!(\"Shortcuts already initialized\");\n        return Ok(());\n    }\n\n    // Initialize shortcuts\n    crate::shortcut::init_shortcuts(&app);\n\n    // Mark as initialized\n    app.manage(ShortcutsInitialized);\n\n    log::info!(\"Shortcuts initialized successfully\");\n    Ok(())\n}\n"
  },
  {
    "path": "src-tauri/src/commands/models.rs",
    "content": "use crate::managers::model::{ModelInfo, ModelManager};\nuse crate::managers::transcription::{ModelStateEvent, TranscriptionManager};\nuse crate::settings::{get_settings, write_settings, ModelUnloadTimeout};\nuse std::sync::Arc;\nuse tauri::{AppHandle, Emitter, Manager, State};\n\n#[tauri::command]\n#[specta::specta]\npub async fn get_available_models(\n    model_manager: State<'_, Arc<ModelManager>>,\n) -> Result<Vec<ModelInfo>, String> {\n    Ok(model_manager.get_available_models())\n}\n\n#[tauri::command]\n#[specta::specta]\npub async fn get_model_info(\n    model_manager: State<'_, Arc<ModelManager>>,\n    model_id: String,\n) -> Result<Option<ModelInfo>, String> {\n    Ok(model_manager.get_model_info(&model_id))\n}\n\n#[tauri::command]\n#[specta::specta]\npub async fn download_model(\n    model_manager: State<'_, Arc<ModelManager>>,\n    model_id: String,\n) -> Result<(), String> {\n    model_manager\n        .download_model(&model_id)\n        .await\n        .map_err(|e| e.to_string())\n}\n\n#[tauri::command]\n#[specta::specta]\npub async fn delete_model(\n    app_handle: AppHandle,\n    model_manager: State<'_, Arc<ModelManager>>,\n    transcription_manager: State<'_, Arc<TranscriptionManager>>,\n    model_id: String,\n) -> Result<(), String> {\n    // If deleting the active model, unload it and clear the setting\n    let settings = get_settings(&app_handle);\n    if settings.selected_model == model_id {\n        transcription_manager\n            .unload_model()\n            .map_err(|e| format!(\"Failed to unload model: {}\", e))?;\n\n        let mut settings = get_settings(&app_handle);\n        settings.selected_model = String::new();\n        write_settings(&app_handle, settings);\n    }\n\n    model_manager\n        .delete_model(&model_id)\n        .map_err(|e| e.to_string())\n}\n\n/// Shared logic for switching the active model, used by both the Tauri command\n/// and the tray menu handler.\n///\n/// Validates the model, updates the persisted setting, and loads the model\n/// unless the unload timeout is set to \"Immediately\" (in which case the model\n/// will be loaded on-demand during the next transcription).\npub fn switch_active_model(app: &AppHandle, model_id: &str) -> Result<(), String> {\n    let model_manager = app.state::<Arc<ModelManager>>();\n    let transcription_manager = app.state::<Arc<TranscriptionManager>>();\n\n    // Atomically claim the loading slot — prevents concurrent model loads\n    // from tray double-clicks or overlapping commands. The guard resets the\n    // flag on drop (including early returns, errors, and panics).\n    let _loading_guard = transcription_manager\n        .try_start_loading()\n        .ok_or_else(|| \"Model load already in progress\".to_string())?;\n\n    // Check if model exists and is available\n    let model_info = model_manager\n        .get_model_info(model_id)\n        .ok_or_else(|| format!(\"Model not found: {}\", model_id))?;\n\n    if !model_info.is_downloaded {\n        return Err(format!(\"Model not downloaded: {}\", model_id));\n    }\n\n    let settings = get_settings(app);\n    let unload_timeout = settings.model_unload_timeout;\n    let old_model = settings.selected_model.clone();\n\n    // Persist the new selection early so the frontend sees the correct model\n    // when it reacts to events emitted by load_model.\n    let mut settings = settings;\n    settings.selected_model = model_id.to_string();\n\n    // Reset language to auto if the new model doesn't support the currently selected language.\n    // This prevents stale language settings from causing errors (e.g. Canary receiving zh-Hans)\n    // and stops downstream processing (e.g. OpenCC) from running on an irrelevant language.\n    if settings.selected_language != \"auto\"\n        && !model_info.supported_languages.is_empty()\n        && !model_info\n            .supported_languages\n            .contains(&settings.selected_language)\n    {\n        log::info!(\n            \"Resetting language from '{}' to 'auto' (not supported by {})\",\n            settings.selected_language,\n            model_id\n        );\n        settings.selected_language = \"auto\".to_string();\n    }\n\n    write_settings(app, settings);\n\n    // Skip eager loading if unload is set to \"Immediately\" — the model\n    // will be loaded on-demand during the next transcription.\n    if unload_timeout == ModelUnloadTimeout::Immediately {\n        // Notify frontend — load_model won't be called so no events\n        // would otherwise be emitted.\n        let _ = app.emit(\n            \"model-state-changed\",\n            ModelStateEvent {\n                event_type: \"selection_changed\".to_string(),\n                model_id: Some(model_id.to_string()),\n                model_name: Some(model_info.name.clone()),\n                error: None,\n            },\n        );\n        log::info!(\n            \"Model selection changed to {} (not loading — unload set to Immediately).\",\n            model_id\n        );\n        return Ok(());\n    }\n\n    // Load the model. On failure, revert the persisted selection.\n    if let Err(e) = transcription_manager.load_model(model_id) {\n        let mut settings = get_settings(app);\n        settings.selected_model = old_model;\n        write_settings(app, settings);\n        return Err(e.to_string());\n    }\n\n    Ok(())\n}\n\n#[tauri::command]\n#[specta::specta]\npub async fn set_active_model(\n    app_handle: AppHandle,\n    _model_manager: State<'_, Arc<ModelManager>>,\n    _transcription_manager: State<'_, Arc<TranscriptionManager>>,\n    model_id: String,\n) -> Result<(), String> {\n    switch_active_model(&app_handle, &model_id)\n}\n\n#[tauri::command]\n#[specta::specta]\npub async fn get_current_model(app_handle: AppHandle) -> Result<String, String> {\n    let settings = get_settings(&app_handle);\n    Ok(settings.selected_model)\n}\n\n#[tauri::command]\n#[specta::specta]\npub async fn get_transcription_model_status(\n    transcription_manager: State<'_, Arc<TranscriptionManager>>,\n) -> Result<Option<String>, String> {\n    Ok(transcription_manager.get_current_model())\n}\n\n#[tauri::command]\n#[specta::specta]\npub async fn is_model_loading(\n    transcription_manager: State<'_, Arc<TranscriptionManager>>,\n) -> Result<bool, String> {\n    // Check if transcription manager has a loaded model\n    let current_model = transcription_manager.get_current_model();\n    Ok(current_model.is_none())\n}\n\n#[tauri::command]\n#[specta::specta]\npub async fn has_any_models_available(\n    model_manager: State<'_, Arc<ModelManager>>,\n) -> Result<bool, String> {\n    let models = model_manager.get_available_models();\n    Ok(models.iter().any(|m| m.is_downloaded))\n}\n\n#[tauri::command]\n#[specta::specta]\npub async fn has_any_models_or_downloads(\n    model_manager: State<'_, Arc<ModelManager>>,\n) -> Result<bool, String> {\n    let models = model_manager.get_available_models();\n    // Return true if any models are downloaded OR if any downloads are in progress\n    Ok(models.iter().any(|m| m.is_downloaded))\n}\n\n#[tauri::command]\n#[specta::specta]\npub async fn cancel_download(\n    model_manager: State<'_, Arc<ModelManager>>,\n    model_id: String,\n) -> Result<(), String> {\n    model_manager\n        .cancel_download(&model_id)\n        .map_err(|e| e.to_string())\n}\n"
  },
  {
    "path": "src-tauri/src/commands/transcription.rs",
    "content": "use crate::managers::transcription::TranscriptionManager;\nuse crate::settings::{get_settings, write_settings, ModelUnloadTimeout};\nuse serde::Serialize;\nuse specta::Type;\nuse tauri::{AppHandle, State};\n\n#[derive(Serialize, Type)]\npub struct ModelLoadStatus {\n    is_loaded: bool,\n    current_model: Option<String>,\n}\n\n#[tauri::command]\n#[specta::specta]\npub fn set_model_unload_timeout(app: AppHandle, timeout: ModelUnloadTimeout) {\n    let mut settings = get_settings(&app);\n    settings.model_unload_timeout = timeout;\n    write_settings(&app, settings);\n}\n\n#[tauri::command]\n#[specta::specta]\npub fn get_model_load_status(\n    transcription_manager: State<TranscriptionManager>,\n) -> Result<ModelLoadStatus, String> {\n    Ok(ModelLoadStatus {\n        is_loaded: transcription_manager.is_model_loaded(),\n        current_model: transcription_manager.get_current_model(),\n    })\n}\n\n#[tauri::command]\n#[specta::specta]\npub fn unload_model_manually(\n    transcription_manager: State<TranscriptionManager>,\n) -> Result<(), String> {\n    transcription_manager\n        .unload_model()\n        .map_err(|e| format!(\"Failed to unload model: {}\", e))\n}\n"
  },
  {
    "path": "src-tauri/src/helpers/clamshell.rs",
    "content": "#[cfg(target_os = \"macos\")]\nuse std::process::Command;\n\n/// Checks if the MacBook is in clamshell mode (lid closed with external display)\n///\n/// This queries the macOS IORegistry for the AppleClamshellState key.\n/// Returns true if the lid is closed, false if open.\n#[cfg(target_os = \"macos\")]\npub fn is_clamshell() -> Result<bool, String> {\n    let output = Command::new(\"ioreg\")\n        .args([\"-r\", \"-k\", \"AppleClamshellState\", \"-d\", \"4\"])\n        .output()\n        .map_err(|e| format!(\"Failed to execute ioreg: {}\", e))?;\n\n    if !output.status.success() {\n        return Err(format!(\n            \"ioreg command failed with status: {}\",\n            output.status\n        ));\n    }\n\n    let stdout = String::from_utf8_lossy(&output.stdout);\n\n    // Look for \"AppleClamshellState\" = Yes in the output\n    Ok(stdout.contains(\"\\\"AppleClamshellState\\\" = Yes\"))\n}\n\n/// Checks if the Mac is a laptop by detecting battery presence\n///\n/// This uses pmset to check for battery information.\n/// Returns true if a battery is detected (laptop), false otherwise (desktop)\n#[cfg(target_os = \"macos\")]\n#[tauri::command]\n#[specta::specta]\npub fn is_laptop() -> Result<bool, String> {\n    let output = Command::new(\"pmset\")\n        .arg(\"-g\")\n        .arg(\"batt\")\n        .output()\n        .map_err(|e| e.to_string())?;\n\n    let stdout = String::from_utf8_lossy(&output.stdout);\n\n    // Check if InternalBattery is present (laptops have batteries, desktops typically don't)\n    Ok(stdout.contains(\"InternalBattery\"))\n}\n\n/// Stub implementation for non-macOS platforms\n/// Always returns false since clamshell mode is macOS-specific\n#[cfg(not(target_os = \"macos\"))]\npub fn is_clamshell() -> Result<bool, String> {\n    Ok(false)\n}\n\n/// Stub implementation for non-macOS platforms\n/// Always returns false since laptop detection is macOS-specific\n#[cfg(not(target_os = \"macos\"))]\n#[tauri::command]\n#[specta::specta]\npub fn is_laptop() -> Result<bool, String> {\n    Ok(false)\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    #[cfg(target_os = \"macos\")]\n    fn test_clamshell_check() {\n        // This will run on macOS and should not panic\n        let result = is_clamshell();\n        assert!(result.is_ok());\n        let _ = result.unwrap();\n    }\n\n    #[test]\n    #[cfg(target_os = \"macos\")]\n    fn test_is_laptop() {\n        let result = is_laptop();\n        assert!(result.is_ok());\n        if let Ok(is_laptop) = result {\n            println!(\"Is laptop: {}\", is_laptop);\n        }\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/helpers/mod.rs",
    "content": "pub mod clamshell;\n"
  },
  {
    "path": "src-tauri/src/input.rs",
    "content": "use enigo::{Enigo, Key, Keyboard, Mouse, Settings};\nuse std::sync::Mutex;\nuse tauri::{AppHandle, Manager};\n\n/// Wrapper for Enigo to store in Tauri's managed state.\n/// Enigo is wrapped in a Mutex since it requires mutable access.\npub struct EnigoState(pub Mutex<Enigo>);\n\nimpl EnigoState {\n    pub fn new() -> Result<Self, String> {\n        let enigo = Enigo::new(&Settings::default())\n            .map_err(|e| format!(\"Failed to initialize Enigo: {}\", e))?;\n        Ok(Self(Mutex::new(enigo)))\n    }\n}\n\n/// Get the current mouse cursor position using the managed Enigo instance.\n/// Returns None if the state is not available or if getting the location fails.\npub fn get_cursor_position(app_handle: &AppHandle) -> Option<(i32, i32)> {\n    let enigo_state = app_handle.try_state::<EnigoState>()?;\n    let enigo = enigo_state.0.lock().ok()?;\n    enigo.location().ok()\n}\n\n/// Sends a Ctrl+V or Cmd+V paste command using platform-specific virtual key codes.\n/// This ensures the paste works regardless of keyboard layout (e.g., Russian, AZERTY, DVORAK).\n/// Note: On Wayland, this may not work - callers should check for Wayland and use alternative methods.\npub fn send_paste_ctrl_v(enigo: &mut Enigo) -> Result<(), String> {\n    // Platform-specific key definitions\n    #[cfg(target_os = \"macos\")]\n    let (modifier_key, v_key_code) = (Key::Meta, Key::Other(9));\n    #[cfg(target_os = \"windows\")]\n    let (modifier_key, v_key_code) = (Key::Control, Key::Other(0x56)); // VK_V\n    #[cfg(target_os = \"linux\")]\n    let (modifier_key, v_key_code) = (Key::Control, Key::Unicode('v'));\n\n    // Press modifier + V\n    enigo\n        .key(modifier_key, enigo::Direction::Press)\n        .map_err(|e| format!(\"Failed to press modifier key: {}\", e))?;\n    enigo\n        .key(v_key_code, enigo::Direction::Click)\n        .map_err(|e| format!(\"Failed to click V key: {}\", e))?;\n\n    std::thread::sleep(std::time::Duration::from_millis(100));\n\n    enigo\n        .key(modifier_key, enigo::Direction::Release)\n        .map_err(|e| format!(\"Failed to release modifier key: {}\", e))?;\n\n    Ok(())\n}\n\n/// Sends a Ctrl+Shift+V paste command.\n/// This is commonly used in terminal applications on Linux to paste without formatting.\n/// Note: On Wayland, this may not work - callers should check for Wayland and use alternative methods.\npub fn send_paste_ctrl_shift_v(enigo: &mut Enigo) -> Result<(), String> {\n    // Platform-specific key definitions\n    #[cfg(target_os = \"macos\")]\n    let (modifier_key, v_key_code) = (Key::Meta, Key::Other(9)); // Cmd+Shift+V on macOS\n    #[cfg(target_os = \"windows\")]\n    let (modifier_key, v_key_code) = (Key::Control, Key::Other(0x56)); // VK_V\n    #[cfg(target_os = \"linux\")]\n    let (modifier_key, v_key_code) = (Key::Control, Key::Unicode('v'));\n\n    // Press Ctrl/Cmd + Shift + V\n    enigo\n        .key(modifier_key, enigo::Direction::Press)\n        .map_err(|e| format!(\"Failed to press modifier key: {}\", e))?;\n    enigo\n        .key(Key::Shift, enigo::Direction::Press)\n        .map_err(|e| format!(\"Failed to press Shift key: {}\", e))?;\n    enigo\n        .key(v_key_code, enigo::Direction::Click)\n        .map_err(|e| format!(\"Failed to click V key: {}\", e))?;\n\n    std::thread::sleep(std::time::Duration::from_millis(100));\n\n    enigo\n        .key(Key::Shift, enigo::Direction::Release)\n        .map_err(|e| format!(\"Failed to release Shift key: {}\", e))?;\n    enigo\n        .key(modifier_key, enigo::Direction::Release)\n        .map_err(|e| format!(\"Failed to release modifier key: {}\", e))?;\n\n    Ok(())\n}\n\n/// Sends a Shift+Insert paste command (Windows and Linux only).\n/// This is more universal for terminal applications and legacy software.\n/// Note: On Wayland, this may not work - callers should check for Wayland and use alternative methods.\npub fn send_paste_shift_insert(enigo: &mut Enigo) -> Result<(), String> {\n    #[cfg(target_os = \"windows\")]\n    let insert_key_code = Key::Other(0x2D); // VK_INSERT\n    #[cfg(not(target_os = \"windows\"))]\n    let insert_key_code = Key::Other(0x76); // XK_Insert (keycode 118 / 0x76, also used as fallback)\n\n    // Press Shift + Insert\n    enigo\n        .key(Key::Shift, enigo::Direction::Press)\n        .map_err(|e| format!(\"Failed to press Shift key: {}\", e))?;\n    enigo\n        .key(insert_key_code, enigo::Direction::Click)\n        .map_err(|e| format!(\"Failed to click Insert key: {}\", e))?;\n\n    std::thread::sleep(std::time::Duration::from_millis(100));\n\n    enigo\n        .key(Key::Shift, enigo::Direction::Release)\n        .map_err(|e| format!(\"Failed to release Shift key: {}\", e))?;\n\n    Ok(())\n}\n\n/// Pastes text directly using the enigo text method.\n/// This tries to use system input methods if possible, otherwise simulates keystrokes one by one.\npub fn paste_text_direct(enigo: &mut Enigo, text: &str) -> Result<(), String> {\n    enigo\n        .text(text)\n        .map_err(|e| format!(\"Failed to send text directly: {}\", e))?;\n\n    Ok(())\n}\n"
  },
  {
    "path": "src-tauri/src/lib.rs",
    "content": "mod actions;\n#[cfg(all(target_os = \"macos\", target_arch = \"aarch64\"))]\nmod apple_intelligence;\nmod audio_feedback;\npub mod audio_toolkit;\npub mod cli;\nmod clipboard;\nmod commands;\nmod helpers;\nmod input;\nmod llm_client;\nmod managers;\nmod overlay;\npub mod portable;\nmod settings;\nmod shortcut;\nmod signal_handle;\nmod transcription_coordinator;\nmod tray;\nmod tray_i18n;\nmod utils;\n\npub use cli::CliArgs;\n#[cfg(debug_assertions)]\nuse specta_typescript::{BigIntExportBehavior, Typescript};\nuse tauri_specta::{collect_commands, Builder};\n\nuse env_filter::Builder as EnvFilterBuilder;\nuse managers::audio::AudioRecordingManager;\nuse managers::history::HistoryManager;\nuse managers::model::ModelManager;\nuse managers::transcription::TranscriptionManager;\n#[cfg(unix)]\nuse signal_hook::consts::{SIGUSR1, SIGUSR2};\n#[cfg(unix)]\nuse signal_hook::iterator::Signals;\nuse std::sync::atomic::{AtomicU8, Ordering};\nuse std::sync::Arc;\nuse tauri::image::Image;\npub use transcription_coordinator::TranscriptionCoordinator;\n\nuse tauri::tray::TrayIconBuilder;\nuse tauri::{AppHandle, Emitter, Listener, Manager};\nuse tauri_plugin_autostart::{MacosLauncher, ManagerExt};\nuse tauri_plugin_log::{Builder as LogBuilder, RotationStrategy, Target, TargetKind};\n\nuse crate::settings::get_settings;\n\n// Global atomic to store the file log level filter\n// We use u8 to store the log::LevelFilter as a number\npub static FILE_LOG_LEVEL: AtomicU8 = AtomicU8::new(log::LevelFilter::Debug as u8);\n\nfn level_filter_from_u8(value: u8) -> log::LevelFilter {\n    match value {\n        0 => log::LevelFilter::Off,\n        1 => log::LevelFilter::Error,\n        2 => log::LevelFilter::Warn,\n        3 => log::LevelFilter::Info,\n        4 => log::LevelFilter::Debug,\n        5 => log::LevelFilter::Trace,\n        _ => log::LevelFilter::Trace,\n    }\n}\n\nfn build_console_filter() -> env_filter::Filter {\n    let mut builder = EnvFilterBuilder::new();\n\n    match std::env::var(\"RUST_LOG\") {\n        Ok(spec) if !spec.trim().is_empty() => {\n            if let Err(err) = builder.try_parse(&spec) {\n                log::warn!(\n                    \"Ignoring invalid RUST_LOG value '{}': {}. Falling back to info-level console logging\",\n                    spec,\n                    err\n                );\n                builder.filter_level(log::LevelFilter::Info);\n            }\n        }\n        _ => {\n            builder.filter_level(log::LevelFilter::Info);\n        }\n    }\n\n    builder.build()\n}\n\nfn show_main_window(app: &AppHandle) {\n    if let Some(main_window) = app.get_webview_window(\"main\") {\n        if let Err(e) = main_window.unminimize() {\n            log::error!(\"Failed to unminimize webview window: {}\", e);\n        }\n        if let Err(e) = main_window.show() {\n            log::error!(\"Failed to show webview window: {}\", e);\n        }\n        if let Err(e) = main_window.set_focus() {\n            log::error!(\"Failed to focus webview window: {}\", e);\n        }\n        #[cfg(target_os = \"macos\")]\n        {\n            if let Err(e) = app.set_activation_policy(tauri::ActivationPolicy::Regular) {\n                log::error!(\"Failed to set activation policy to Regular: {}\", e);\n            }\n        }\n        return;\n    }\n\n    let webview_labels = app.webview_windows().keys().cloned().collect::<Vec<_>>();\n    log::error!(\n        \"Main window not found. Webview labels: {:?}\",\n        webview_labels\n    );\n}\n\n#[allow(unused_variables)]\nfn should_force_show_permissions_window(app: &AppHandle) -> bool {\n    #[cfg(target_os = \"windows\")]\n    {\n        let model_manager = app.state::<Arc<ModelManager>>();\n        let has_downloaded_models = model_manager\n            .get_available_models()\n            .iter()\n            .any(|model| model.is_downloaded);\n\n        if !has_downloaded_models {\n            return false;\n        }\n\n        let status = commands::audio::get_windows_microphone_permission_status();\n        if status.supported && status.overall_access == commands::audio::PermissionAccess::Denied {\n            log::info!(\n                \"Windows microphone permissions are denied; forcing main window visible for onboarding\"\n            );\n            return true;\n        }\n    }\n\n    false\n}\n\nfn initialize_core_logic(app_handle: &AppHandle) {\n    // Note: Enigo (keyboard/mouse simulation) is NOT initialized here.\n    // The frontend is responsible for calling the `initialize_enigo` command\n    // after onboarding completes. This avoids triggering permission dialogs\n    // on macOS before the user is ready.\n\n    // Initialize the managers\n    let recording_manager = Arc::new(\n        AudioRecordingManager::new(app_handle).expect(\"Failed to initialize recording manager\"),\n    );\n    let model_manager =\n        Arc::new(ModelManager::new(app_handle).expect(\"Failed to initialize model manager\"));\n    let transcription_manager = Arc::new(\n        TranscriptionManager::new(app_handle, model_manager.clone())\n            .expect(\"Failed to initialize transcription manager\"),\n    );\n    let history_manager =\n        Arc::new(HistoryManager::new(app_handle).expect(\"Failed to initialize history manager\"));\n\n    // Apply accelerator preferences before any model loads\n    managers::transcription::apply_accelerator_settings(app_handle);\n\n    // Add managers to Tauri's managed state\n    app_handle.manage(recording_manager.clone());\n    app_handle.manage(model_manager.clone());\n    app_handle.manage(transcription_manager.clone());\n    app_handle.manage(history_manager.clone());\n\n    // Note: Shortcuts are NOT initialized here.\n    // The frontend is responsible for calling the `initialize_shortcuts` command\n    // after permissions are confirmed (on macOS) or after onboarding completes.\n    // This matches the pattern used for Enigo initialization.\n\n    #[cfg(unix)]\n    let signals = Signals::new(&[SIGUSR1, SIGUSR2]).unwrap();\n    // Set up signal handlers for toggling transcription\n    #[cfg(unix)]\n    signal_handle::setup_signal_handler(app_handle.clone(), signals);\n\n    // Apply macOS Accessory policy if starting hidden and tray is available.\n    // If the tray icon is disabled, keep the dock icon so the user can reopen.\n    #[cfg(target_os = \"macos\")]\n    {\n        let settings = settings::get_settings(app_handle);\n        if settings.start_hidden && settings.show_tray_icon {\n            let _ = app_handle.set_activation_policy(tauri::ActivationPolicy::Accessory);\n        }\n    }\n    // Get the current theme to set the appropriate initial icon\n    let initial_theme = tray::get_current_theme(app_handle);\n\n    // Choose the appropriate initial icon based on theme\n    let initial_icon_path = tray::get_icon_path(initial_theme, tray::TrayIconState::Idle);\n\n    let tray = TrayIconBuilder::new()\n        .icon(\n            Image::from_path(\n                app_handle\n                    .path()\n                    .resolve(initial_icon_path, tauri::path::BaseDirectory::Resource)\n                    .unwrap(),\n            )\n            .unwrap(),\n        )\n        .show_menu_on_left_click(true)\n        .icon_as_template(true)\n        .on_menu_event(|app, event| match event.id.as_ref() {\n            \"settings\" => {\n                show_main_window(app);\n            }\n            \"check_updates\" => {\n                let settings = settings::get_settings(app);\n                if settings.update_checks_enabled {\n                    show_main_window(app);\n                    let _ = app.emit(\"check-for-updates\", ());\n                }\n            }\n            \"copy_last_transcript\" => {\n                tray::copy_last_transcript(app);\n            }\n            \"unload_model\" => {\n                let transcription_manager = app.state::<Arc<TranscriptionManager>>();\n                if !transcription_manager.is_model_loaded() {\n                    log::warn!(\"No model is currently loaded.\");\n                    return;\n                }\n                match transcription_manager.unload_model() {\n                    Ok(()) => log::info!(\"Model unloaded via tray.\"),\n                    Err(e) => log::error!(\"Failed to unload model via tray: {}\", e),\n                }\n            }\n            \"cancel\" => {\n                use crate::utils::cancel_current_operation;\n\n                // Use centralized cancellation that handles all operations\n                cancel_current_operation(app);\n            }\n            \"quit\" => {\n                app.exit(0);\n            }\n            id if id.starts_with(\"model_select:\") => {\n                let model_id = id.strip_prefix(\"model_select:\").unwrap().to_string();\n                let current_model = settings::get_settings(app).selected_model;\n                if model_id == current_model {\n                    return;\n                }\n                let app_clone = app.clone();\n                std::thread::spawn(move || {\n                    match commands::models::switch_active_model(&app_clone, &model_id) {\n                        Ok(()) => {\n                            log::info!(\"Model switched to {} via tray.\", model_id);\n                        }\n                        Err(e) => {\n                            log::error!(\"Failed to switch model via tray: {}\", e);\n                        }\n                    }\n                    tray::update_tray_menu(&app_clone, &tray::TrayIconState::Idle, None);\n                });\n            }\n            _ => {}\n        })\n        .build(app_handle)\n        .unwrap();\n    app_handle.manage(tray);\n\n    // Initialize tray menu with idle state\n    utils::update_tray_menu(app_handle, &utils::TrayIconState::Idle, None);\n\n    // Apply show_tray_icon setting\n    let settings = settings::get_settings(app_handle);\n    if !settings.show_tray_icon {\n        tray::set_tray_visibility(app_handle, false);\n    }\n\n    // Refresh tray menu when model state changes\n    let app_handle_for_listener = app_handle.clone();\n    app_handle.listen(\"model-state-changed\", move |_| {\n        tray::update_tray_menu(&app_handle_for_listener, &tray::TrayIconState::Idle, None);\n    });\n\n    // Get the autostart manager and configure based on user setting\n    let autostart_manager = app_handle.autolaunch();\n    let settings = settings::get_settings(&app_handle);\n\n    if settings.autostart_enabled {\n        // Enable autostart if user has opted in\n        let _ = autostart_manager.enable();\n    } else {\n        // Disable autostart if user has opted out\n        let _ = autostart_manager.disable();\n    }\n\n    // Create the recording overlay window (hidden by default)\n    utils::create_recording_overlay(app_handle);\n}\n\n#[tauri::command]\n#[specta::specta]\nfn trigger_update_check(app: AppHandle) -> Result<(), String> {\n    let settings = settings::get_settings(&app);\n    if !settings.update_checks_enabled {\n        return Ok(());\n    }\n    app.emit(\"check-for-updates\", ())\n        .map_err(|e| e.to_string())?;\n    Ok(())\n}\n\n#[tauri::command]\n#[specta::specta]\nfn show_main_window_command(app: AppHandle) -> Result<(), String> {\n    show_main_window(&app);\n    Ok(())\n}\n\n#[cfg_attr(mobile, tauri::mobile_entry_point)]\npub fn run(cli_args: CliArgs) {\n    // Detect portable mode before anything else\n    portable::init();\n\n    // Parse console logging directives from RUST_LOG, falling back to info-level logging\n    // when the variable is unset\n    let console_filter = build_console_filter();\n\n    let specta_builder = Builder::<tauri::Wry>::new().commands(collect_commands![\n        shortcut::change_binding,\n        shortcut::reset_binding,\n        shortcut::change_ptt_setting,\n        shortcut::change_audio_feedback_setting,\n        shortcut::change_audio_feedback_volume_setting,\n        shortcut::change_sound_theme_setting,\n        shortcut::change_start_hidden_setting,\n        shortcut::change_autostart_setting,\n        shortcut::change_translate_to_english_setting,\n        shortcut::change_selected_language_setting,\n        shortcut::change_overlay_position_setting,\n        shortcut::change_debug_mode_setting,\n        shortcut::change_word_correction_threshold_setting,\n        shortcut::change_extra_recording_buffer_setting,\n        shortcut::change_paste_method_setting,\n        shortcut::get_available_typing_tools,\n        shortcut::change_typing_tool_setting,\n        shortcut::change_external_script_path_setting,\n        shortcut::change_clipboard_handling_setting,\n        shortcut::change_auto_submit_setting,\n        shortcut::change_auto_submit_key_setting,\n        shortcut::change_post_process_enabled_setting,\n        shortcut::change_experimental_enabled_setting,\n        shortcut::change_post_process_base_url_setting,\n        shortcut::change_post_process_api_key_setting,\n        shortcut::change_post_process_model_setting,\n        shortcut::set_post_process_provider,\n        shortcut::fetch_post_process_models,\n        shortcut::add_post_process_prompt,\n        shortcut::update_post_process_prompt,\n        shortcut::delete_post_process_prompt,\n        shortcut::set_post_process_selected_prompt,\n        shortcut::update_custom_words,\n        shortcut::suspend_binding,\n        shortcut::resume_binding,\n        shortcut::change_mute_while_recording_setting,\n        shortcut::change_append_trailing_space_setting,\n        shortcut::change_lazy_stream_close_setting,\n        shortcut::change_app_language_setting,\n        shortcut::change_update_checks_setting,\n        shortcut::change_keyboard_implementation_setting,\n        shortcut::get_keyboard_implementation,\n        shortcut::change_show_tray_icon_setting,\n        shortcut::change_whisper_accelerator_setting,\n        shortcut::change_ort_accelerator_setting,\n        shortcut::get_available_accelerators,\n        shortcut::handy_keys::start_handy_keys_recording,\n        shortcut::handy_keys::stop_handy_keys_recording,\n        trigger_update_check,\n        show_main_window_command,\n        commands::cancel_operation,\n        commands::get_app_dir_path,\n        commands::get_app_settings,\n        commands::get_default_settings,\n        commands::get_log_dir_path,\n        commands::set_log_level,\n        commands::open_recordings_folder,\n        commands::open_log_dir,\n        commands::open_app_data_dir,\n        commands::check_apple_intelligence_available,\n        commands::initialize_enigo,\n        commands::initialize_shortcuts,\n        commands::models::get_available_models,\n        commands::models::get_model_info,\n        commands::models::download_model,\n        commands::models::delete_model,\n        commands::models::cancel_download,\n        commands::models::set_active_model,\n        commands::models::get_current_model,\n        commands::models::get_transcription_model_status,\n        commands::models::is_model_loading,\n        commands::models::has_any_models_available,\n        commands::models::has_any_models_or_downloads,\n        commands::audio::update_microphone_mode,\n        commands::audio::get_microphone_mode,\n        commands::audio::get_windows_microphone_permission_status,\n        commands::audio::open_microphone_privacy_settings,\n        commands::audio::get_available_microphones,\n        commands::audio::set_selected_microphone,\n        commands::audio::get_selected_microphone,\n        commands::audio::get_available_output_devices,\n        commands::audio::set_selected_output_device,\n        commands::audio::get_selected_output_device,\n        commands::audio::play_test_sound,\n        commands::audio::check_custom_sounds,\n        commands::audio::set_clamshell_microphone,\n        commands::audio::get_clamshell_microphone,\n        commands::audio::is_recording,\n        commands::transcription::set_model_unload_timeout,\n        commands::transcription::get_model_load_status,\n        commands::transcription::unload_model_manually,\n        commands::history::get_history_entries,\n        commands::history::toggle_history_entry_saved,\n        commands::history::get_audio_file_path,\n        commands::history::delete_history_entry,\n        commands::history::update_history_limit,\n        commands::history::update_recording_retention_period,\n        helpers::clamshell::is_laptop,\n    ]);\n\n    #[cfg(debug_assertions)] // <- Only export on non-release builds\n    specta_builder\n        .export(\n            Typescript::default().bigint(BigIntExportBehavior::Number),\n            \"../src/bindings.ts\",\n        )\n        .expect(\"Failed to export typescript bindings\");\n\n    #[allow(unused_mut)]\n    let mut builder = tauri::Builder::default()\n        .device_event_filter(tauri::DeviceEventFilter::Always)\n        .plugin(tauri_plugin_dialog::init())\n        .plugin(\n            LogBuilder::new()\n                .level(log::LevelFilter::Trace) // Set to most verbose level globally\n                .max_file_size(500_000)\n                .rotation_strategy(RotationStrategy::KeepOne)\n                .clear_targets()\n                .targets([\n                    // Console output respects RUST_LOG environment variable\n                    Target::new(TargetKind::Stdout).filter({\n                        let console_filter = console_filter.clone();\n                        move |metadata| console_filter.enabled(metadata)\n                    }),\n                    // File logs respect the user's settings (stored in FILE_LOG_LEVEL atomic)\n                    Target::new(if let Some(data_dir) = portable::data_dir() {\n                        TargetKind::Folder {\n                            path: data_dir.join(\"logs\"),\n                            file_name: Some(\"handy\".into()),\n                        }\n                    } else {\n                        TargetKind::LogDir {\n                            file_name: Some(\"handy\".into()),\n                        }\n                    })\n                    .filter(|metadata| {\n                        let file_level = FILE_LOG_LEVEL.load(Ordering::Relaxed);\n                        metadata.level() <= level_filter_from_u8(file_level)\n                    }),\n                ])\n                .build(),\n        );\n\n    #[cfg(target_os = \"macos\")]\n    {\n        builder = builder.plugin(tauri_nspanel::init());\n    }\n\n    builder\n        .plugin(tauri_plugin_single_instance::init(|app, args, _cwd| {\n            if args.iter().any(|a| a == \"--toggle-transcription\") {\n                signal_handle::send_transcription_input(app, \"transcribe\", \"CLI\");\n            } else if args.iter().any(|a| a == \"--toggle-post-process\") {\n                signal_handle::send_transcription_input(app, \"transcribe_with_post_process\", \"CLI\");\n            } else if args.iter().any(|a| a == \"--cancel\") {\n                crate::utils::cancel_current_operation(app);\n            } else {\n                show_main_window(app);\n            }\n        }))\n        .plugin(tauri_plugin_fs::init())\n        .plugin(tauri_plugin_process::init())\n        .plugin(tauri_plugin_updater::Builder::new().build())\n        .plugin(tauri_plugin_os::init())\n        .plugin(tauri_plugin_clipboard_manager::init())\n        .plugin(tauri_plugin_macos_permissions::init())\n        .plugin(tauri_plugin_opener::init())\n        .plugin(tauri_plugin_store::Builder::default().build())\n        .plugin(tauri_plugin_global_shortcut::Builder::new().build())\n        .plugin(tauri_plugin_autostart::init(\n            MacosLauncher::LaunchAgent,\n            Some(vec![]),\n        ))\n        .manage(cli_args.clone())\n        .setup(move |app| {\n            // Create main window programmatically so we can set data_directory\n            // for portable mode (redirects WebView2 cache to portable Data dir)\n            let mut win_builder =\n                tauri::WebviewWindowBuilder::new(app, \"main\", tauri::WebviewUrl::App(\"/\".into()))\n                    .title(\"Handy\")\n                    .inner_size(680.0, 570.0)\n                    .min_inner_size(680.0, 570.0)\n                    .resizable(true)\n                    .maximizable(false)\n                    .visible(false);\n\n            if let Some(data_dir) = portable::data_dir() {\n                win_builder = win_builder.data_directory(data_dir.join(\"webview\"));\n            }\n\n            win_builder.build()?;\n\n            let mut settings = get_settings(&app.handle());\n\n            // CLI --debug flag overrides debug_mode and log level (runtime-only, not persisted)\n            if cli_args.debug {\n                settings.debug_mode = true;\n                settings.log_level = settings::LogLevel::Trace;\n            }\n\n            let tauri_log_level: tauri_plugin_log::LogLevel = settings.log_level.into();\n            let file_log_level: log::Level = tauri_log_level.into();\n            // Store the file log level in the atomic for the filter to use\n            FILE_LOG_LEVEL.store(file_log_level.to_level_filter() as u8, Ordering::Relaxed);\n            let app_handle = app.handle().clone();\n            app.manage(TranscriptionCoordinator::new(app_handle.clone()));\n\n            initialize_core_logic(&app_handle);\n\n            // Hide tray icon if --no-tray was passed\n            if cli_args.no_tray {\n                tray::set_tray_visibility(&app_handle, false);\n            }\n\n            // Show main window only if not starting hidden.\n            // CLI --start-hidden flag overrides the setting.\n            // But if permission onboarding is required, always show the window.\n            let should_hide = settings.start_hidden || cli_args.start_hidden;\n            let should_force_show = should_force_show_permissions_window(&app_handle);\n\n            // If start_hidden but tray is disabled, we must show the window\n            // anyway. Without a tray icon, the dock is the only way back in.\n            let tray_available = settings.show_tray_icon && !cli_args.no_tray;\n            if should_force_show || !should_hide || !tray_available {\n                show_main_window(&app_handle);\n            }\n\n            Ok(())\n        })\n        .on_window_event(|window, event| match event {\n            tauri::WindowEvent::CloseRequested { api, .. } => {\n                api.prevent_close();\n                let _res = window.hide();\n\n                #[cfg(target_os = \"macos\")]\n                {\n                    let settings = get_settings(&window.app_handle());\n                    let tray_visible =\n                        settings.show_tray_icon && !window.app_handle().state::<CliArgs>().no_tray;\n                    if tray_visible {\n                        // Tray is available: hide the dock icon, app lives in the tray\n                        let res = window\n                            .app_handle()\n                            .set_activation_policy(tauri::ActivationPolicy::Accessory);\n                        if let Err(e) = res {\n                            log::error!(\"Failed to set activation policy: {}\", e);\n                        }\n                    }\n                    // No tray: keep the dock icon visible so the user can reopen\n                }\n            }\n            tauri::WindowEvent::ThemeChanged(theme) => {\n                log::info!(\"Theme changed to: {:?}\", theme);\n                // Update tray icon to match new theme, maintaining idle state\n                utils::change_tray_icon(&window.app_handle(), utils::TrayIconState::Idle);\n            }\n            _ => {}\n        })\n        .invoke_handler(specta_builder.invoke_handler())\n        .build(tauri::generate_context!())\n        .expect(\"error while building tauri application\")\n        .run(|app, event| {\n            #[cfg(target_os = \"macos\")]\n            if let tauri::RunEvent::Reopen { .. } = &event {\n                show_main_window(app);\n            }\n            let _ = (app, event); // suppress unused warnings on non-macOS\n        });\n}\n"
  },
  {
    "path": "src-tauri/src/llm_client.rs",
    "content": "use crate::settings::PostProcessProvider;\nuse log::debug;\nuse reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION, CONTENT_TYPE, REFERER, USER_AGENT};\nuse serde::{Deserialize, Serialize};\nuse serde_json::Value;\n\n#[derive(Debug, Serialize)]\nstruct ChatMessage {\n    role: String,\n    content: String,\n}\n\n#[derive(Debug, Serialize)]\nstruct JsonSchema {\n    name: String,\n    strict: bool,\n    schema: Value,\n}\n\n#[derive(Debug, Serialize)]\nstruct ResponseFormat {\n    #[serde(rename = \"type\")]\n    format_type: String,\n    json_schema: JsonSchema,\n}\n\n#[derive(Debug, Serialize)]\nstruct ChatCompletionRequest {\n    model: String,\n    messages: Vec<ChatMessage>,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    response_format: Option<ResponseFormat>,\n}\n\n#[derive(Debug, Deserialize)]\nstruct ChatCompletionResponse {\n    choices: Vec<ChatChoice>,\n}\n\n#[derive(Debug, Deserialize)]\nstruct ChatChoice {\n    message: ChatMessageResponse,\n}\n\n#[derive(Debug, Deserialize)]\nstruct ChatMessageResponse {\n    content: Option<String>,\n}\n\n/// Build headers for API requests based on provider type\nfn build_headers(provider: &PostProcessProvider, api_key: &str) -> Result<HeaderMap, String> {\n    let mut headers = HeaderMap::new();\n\n    // Common headers\n    headers.insert(CONTENT_TYPE, HeaderValue::from_static(\"application/json\"));\n    headers.insert(\n        REFERER,\n        HeaderValue::from_static(\"https://github.com/cjpais/Handy\"),\n    );\n    headers.insert(\n        USER_AGENT,\n        HeaderValue::from_static(\"Handy/1.0 (+https://github.com/cjpais/Handy)\"),\n    );\n    headers.insert(\"X-Title\", HeaderValue::from_static(\"Handy\"));\n\n    // Provider-specific auth headers\n    if !api_key.is_empty() {\n        if provider.id == \"anthropic\" {\n            headers.insert(\n                \"x-api-key\",\n                HeaderValue::from_str(api_key)\n                    .map_err(|e| format!(\"Invalid API key header value: {}\", e))?,\n            );\n            headers.insert(\"anthropic-version\", HeaderValue::from_static(\"2023-06-01\"));\n        } else {\n            headers.insert(\n                AUTHORIZATION,\n                HeaderValue::from_str(&format!(\"Bearer {}\", api_key))\n                    .map_err(|e| format!(\"Invalid authorization header value: {}\", e))?,\n            );\n        }\n    }\n\n    Ok(headers)\n}\n\n/// Create an HTTP client with provider-specific headers\nfn create_client(provider: &PostProcessProvider, api_key: &str) -> Result<reqwest::Client, String> {\n    let headers = build_headers(provider, api_key)?;\n    reqwest::Client::builder()\n        .default_headers(headers)\n        .build()\n        .map_err(|e| format!(\"Failed to build HTTP client: {}\", e))\n}\n\n/// Send a chat completion request to an OpenAI-compatible API\n/// Returns Ok(Some(content)) on success, Ok(None) if response has no content,\n/// or Err on actual errors (HTTP, parsing, etc.)\npub async fn send_chat_completion(\n    provider: &PostProcessProvider,\n    api_key: String,\n    model: &str,\n    prompt: String,\n) -> Result<Option<String>, String> {\n    send_chat_completion_with_schema(provider, api_key, model, prompt, None, None).await\n}\n\n/// Send a chat completion request with structured output support\n/// When json_schema is provided, uses structured outputs mode\n/// system_prompt is used as the system message when provided\npub async fn send_chat_completion_with_schema(\n    provider: &PostProcessProvider,\n    api_key: String,\n    model: &str,\n    user_content: String,\n    system_prompt: Option<String>,\n    json_schema: Option<Value>,\n) -> Result<Option<String>, String> {\n    let base_url = provider.base_url.trim_end_matches('/');\n    let url = format!(\"{}/chat/completions\", base_url);\n\n    debug!(\"Sending chat completion request to: {}\", url);\n\n    let client = create_client(provider, &api_key)?;\n\n    // Build messages vector\n    let mut messages = Vec::new();\n\n    // Add system prompt if provided\n    if let Some(system) = system_prompt {\n        messages.push(ChatMessage {\n            role: \"system\".to_string(),\n            content: system,\n        });\n    }\n\n    // Add user message\n    messages.push(ChatMessage {\n        role: \"user\".to_string(),\n        content: user_content,\n    });\n\n    // Build response_format if schema is provided\n    let response_format = json_schema.map(|schema| ResponseFormat {\n        format_type: \"json_schema\".to_string(),\n        json_schema: JsonSchema {\n            name: \"transcription_output\".to_string(),\n            strict: true,\n            schema,\n        },\n    });\n\n    let request_body = ChatCompletionRequest {\n        model: model.to_string(),\n        messages,\n        response_format,\n    };\n\n    let response = client\n        .post(&url)\n        .json(&request_body)\n        .send()\n        .await\n        .map_err(|e| format!(\"HTTP request failed: {}\", e))?;\n\n    let status = response.status();\n    if !status.is_success() {\n        let error_text = response\n            .text()\n            .await\n            .unwrap_or_else(|_| \"Failed to read error response\".to_string());\n        return Err(format!(\n            \"API request failed with status {}: {}\",\n            status, error_text\n        ));\n    }\n\n    let completion: ChatCompletionResponse = response\n        .json()\n        .await\n        .map_err(|e| format!(\"Failed to parse API response: {}\", e))?;\n\n    Ok(completion\n        .choices\n        .first()\n        .and_then(|choice| choice.message.content.clone()))\n}\n\n/// Fetch available models from an OpenAI-compatible API\n/// Returns a list of model IDs\npub async fn fetch_models(\n    provider: &PostProcessProvider,\n    api_key: String,\n) -> Result<Vec<String>, String> {\n    let base_url = provider.base_url.trim_end_matches('/');\n    let url = format!(\"{}/models\", base_url);\n\n    debug!(\"Fetching models from: {}\", url);\n\n    let client = create_client(provider, &api_key)?;\n\n    let response = client\n        .get(&url)\n        .send()\n        .await\n        .map_err(|e| format!(\"Failed to fetch models: {}\", e))?;\n\n    let status = response.status();\n    if !status.is_success() {\n        let error_text = response\n            .text()\n            .await\n            .unwrap_or_else(|_| \"Unknown error\".to_string());\n        return Err(format!(\n            \"Model list request failed ({}): {}\",\n            status, error_text\n        ));\n    }\n\n    let parsed: serde_json::Value = response\n        .json()\n        .await\n        .map_err(|e| format!(\"Failed to parse response: {}\", e))?;\n\n    let mut models = Vec::new();\n\n    // Handle OpenAI format: { data: [ { id: \"...\" }, ... ] }\n    if let Some(data) = parsed.get(\"data\").and_then(|d| d.as_array()) {\n        for entry in data {\n            if let Some(id) = entry.get(\"id\").and_then(|i| i.as_str()) {\n                models.push(id.to_string());\n            } else if let Some(name) = entry.get(\"name\").and_then(|n| n.as_str()) {\n                models.push(name.to_string());\n            }\n        }\n    }\n    // Handle array format: [ \"model1\", \"model2\", ... ]\n    else if let Some(array) = parsed.as_array() {\n        for entry in array {\n            if let Some(model) = entry.as_str() {\n                models.push(model.to_string());\n            }\n        }\n    }\n\n    Ok(models)\n}\n"
  },
  {
    "path": "src-tauri/src/main.rs",
    "content": "// Prevents additional console window on Windows in release, DO NOT REMOVE!!\n#![cfg_attr(not(debug_assertions), windows_subsystem = \"windows\")]\n\nuse clap::Parser;\nuse handy_app_lib::CliArgs;\n\nfn main() {\n    let cli_args = CliArgs::parse();\n\n    #[cfg(target_os = \"linux\")]\n    {\n        // DMABUF renderer causes crashes on various GPU/display server configurations\n        // See: https://github.com/tauri-apps/tauri/issues/9394\n        std::env::set_var(\"WEBKIT_DISABLE_DMABUF_RENDERER\", \"1\");\n    }\n\n    handy_app_lib::run(cli_args)\n}\n"
  },
  {
    "path": "src-tauri/src/managers/audio.rs",
    "content": "use crate::audio_toolkit::{list_input_devices, vad::SmoothedVad, AudioRecorder, SileroVad};\nuse crate::helpers::clamshell;\nuse crate::settings::{get_settings, AppSettings};\nuse crate::utils;\nuse log::{debug, error, info};\nuse std::sync::atomic::{AtomicU64, Ordering};\nuse std::sync::{Arc, Mutex};\nuse std::time::{Duration, Instant};\nuse tauri::Manager;\n\nconst STREAM_IDLE_TIMEOUT: Duration = Duration::from_secs(30);\n\nfn set_mute(mute: bool) {\n    // Expected behavior:\n    // - Windows: works on most systems using standard audio drivers.\n    // - Linux: works on many systems (PipeWire, PulseAudio, ALSA),\n    //   but some distros may lack the tools used.\n    // - macOS: works on most standard setups via AppleScript.\n    // If unsupported, fails silently.\n\n    #[cfg(target_os = \"windows\")]\n    {\n        unsafe {\n            use windows::Win32::{\n                Media::Audio::{\n                    eMultimedia, eRender, Endpoints::IAudioEndpointVolume, IMMDeviceEnumerator,\n                    MMDeviceEnumerator,\n                },\n                System::Com::{CoCreateInstance, CoInitializeEx, CLSCTX_ALL, COINIT_MULTITHREADED},\n            };\n\n            macro_rules! unwrap_or_return {\n                ($expr:expr) => {\n                    match $expr {\n                        Ok(val) => val,\n                        Err(_) => return,\n                    }\n                };\n            }\n\n            // Initialize the COM library for this thread.\n            // If already initialized (e.g., by another library like Tauri), this does nothing.\n            let _ = CoInitializeEx(None, COINIT_MULTITHREADED);\n\n            let all_devices: IMMDeviceEnumerator =\n                unwrap_or_return!(CoCreateInstance(&MMDeviceEnumerator, None, CLSCTX_ALL));\n            let default_device =\n                unwrap_or_return!(all_devices.GetDefaultAudioEndpoint(eRender, eMultimedia));\n            let volume_interface = unwrap_or_return!(\n                default_device.Activate::<IAudioEndpointVolume>(CLSCTX_ALL, None)\n            );\n\n            let _ = volume_interface.SetMute(mute, std::ptr::null());\n        }\n    }\n\n    #[cfg(target_os = \"linux\")]\n    {\n        use std::process::Command;\n\n        let mute_val = if mute { \"1\" } else { \"0\" };\n        let amixer_state = if mute { \"mute\" } else { \"unmute\" };\n\n        // Try multiple backends to increase compatibility\n        // 1. PipeWire (wpctl)\n        if Command::new(\"wpctl\")\n            .args([\"set-mute\", \"@DEFAULT_AUDIO_SINK@\", mute_val])\n            .output()\n            .map(|o| o.status.success())\n            .unwrap_or(false)\n        {\n            return;\n        }\n\n        // 2. PulseAudio (pactl)\n        if Command::new(\"pactl\")\n            .args([\"set-sink-mute\", \"@DEFAULT_SINK@\", mute_val])\n            .output()\n            .map(|o| o.status.success())\n            .unwrap_or(false)\n        {\n            return;\n        }\n\n        // 3. ALSA (amixer)\n        let _ = Command::new(\"amixer\")\n            .args([\"set\", \"Master\", amixer_state])\n            .output();\n    }\n\n    #[cfg(target_os = \"macos\")]\n    {\n        use std::process::Command;\n        let script = format!(\n            \"set volume output muted {}\",\n            if mute { \"true\" } else { \"false\" }\n        );\n        let _ = Command::new(\"osascript\").args([\"-e\", &script]).output();\n    }\n}\n\nconst WHISPER_SAMPLE_RATE: usize = 16000;\n\n/* ──────────────────────────────────────────────────────────────── */\n\n#[derive(Clone, Debug)]\npub enum RecordingState {\n    Idle,\n    Recording { binding_id: String },\n}\n\n#[derive(Clone, Debug)]\npub enum MicrophoneMode {\n    AlwaysOn,\n    OnDemand,\n}\n\n/* ──────────────────────────────────────────────────────────────── */\n\nfn create_audio_recorder(\n    vad_path: &str,\n    app_handle: &tauri::AppHandle,\n) -> Result<AudioRecorder, anyhow::Error> {\n    let silero = SileroVad::new(vad_path, 0.3)\n        .map_err(|e| anyhow::anyhow!(\"Failed to create SileroVad: {}\", e))?;\n    let smoothed_vad = SmoothedVad::new(Box::new(silero), 15, 15, 2);\n\n    // Recorder with VAD plus a spectrum-level callback that forwards updates to\n    // the frontend.\n    let recorder = AudioRecorder::new()\n        .map_err(|e| anyhow::anyhow!(\"Failed to create AudioRecorder: {}\", e))?\n        .with_vad(Box::new(smoothed_vad))\n        .with_level_callback({\n            let app_handle = app_handle.clone();\n            move |levels| {\n                utils::emit_levels(&app_handle, &levels);\n            }\n        });\n\n    Ok(recorder)\n}\n\n/* ──────────────────────────────────────────────────────────────── */\n\n#[derive(Clone)]\npub struct AudioRecordingManager {\n    state: Arc<Mutex<RecordingState>>,\n    mode: Arc<Mutex<MicrophoneMode>>,\n    app_handle: tauri::AppHandle,\n\n    recorder: Arc<Mutex<Option<AudioRecorder>>>,\n    is_open: Arc<Mutex<bool>>,\n    is_recording: Arc<Mutex<bool>>,\n    did_mute: Arc<Mutex<bool>>,\n    close_generation: Arc<AtomicU64>,\n}\n\nimpl AudioRecordingManager {\n    /* ---------- construction ------------------------------------------------ */\n\n    pub fn new(app: &tauri::AppHandle) -> Result<Self, anyhow::Error> {\n        let settings = get_settings(app);\n        let mode = if settings.always_on_microphone {\n            MicrophoneMode::AlwaysOn\n        } else {\n            MicrophoneMode::OnDemand\n        };\n\n        let manager = Self {\n            state: Arc::new(Mutex::new(RecordingState::Idle)),\n            mode: Arc::new(Mutex::new(mode.clone())),\n            app_handle: app.clone(),\n\n            recorder: Arc::new(Mutex::new(None)),\n            is_open: Arc::new(Mutex::new(false)),\n            is_recording: Arc::new(Mutex::new(false)),\n            did_mute: Arc::new(Mutex::new(false)),\n            close_generation: Arc::new(AtomicU64::new(0)),\n        };\n\n        // Always-on?  Open immediately.\n        if matches!(mode, MicrophoneMode::AlwaysOn) {\n            manager.start_microphone_stream()?;\n        }\n\n        Ok(manager)\n    }\n\n    /* ---------- helper methods --------------------------------------------- */\n\n    fn get_effective_microphone_device(&self, settings: &AppSettings) -> Option<cpal::Device> {\n        // Check if we're in clamshell mode and have a clamshell microphone configured\n        let use_clamshell_mic = if let Ok(is_clamshell) = clamshell::is_clamshell() {\n            is_clamshell && settings.clamshell_microphone.is_some()\n        } else {\n            false\n        };\n\n        let device_name = if use_clamshell_mic {\n            settings.clamshell_microphone.as_ref().unwrap()\n        } else {\n            settings.selected_microphone.as_ref()?\n        };\n\n        // Find the device by name\n        match list_input_devices() {\n            Ok(devices) => devices\n                .into_iter()\n                .find(|d| d.name == *device_name)\n                .map(|d| d.device),\n            Err(e) => {\n                debug!(\"Failed to list devices, using default: {}\", e);\n                None\n            }\n        }\n    }\n\n    fn schedule_lazy_close(&self) {\n        let gen = self.close_generation.fetch_add(1, Ordering::SeqCst) + 1;\n        let app = self.app_handle.clone();\n        std::thread::spawn(move || {\n            std::thread::sleep(STREAM_IDLE_TIMEOUT);\n            let rm = app.state::<Arc<AudioRecordingManager>>();\n            // Hold state lock across the check AND close to serialize against\n            // try_start_recording, preventing a race where the stream is closed\n            // under an active recording.\n            let state = rm.state.lock().unwrap();\n            if rm.close_generation.load(Ordering::SeqCst) == gen\n                && matches!(*state, RecordingState::Idle)\n            {\n                // stop_microphone_stream does not acquire the state lock,\n                // so holding it here is safe (no deadlock).\n                info!(\n                    \"Closing idle microphone stream after {:?}\",\n                    STREAM_IDLE_TIMEOUT\n                );\n                rm.stop_microphone_stream();\n            }\n        });\n    }\n\n    /* ---------- microphone life-cycle -------------------------------------- */\n\n    /// Applies mute if mute_while_recording is enabled and stream is open\n    pub fn apply_mute(&self) {\n        let settings = get_settings(&self.app_handle);\n        let mut did_mute_guard = self.did_mute.lock().unwrap();\n\n        if settings.mute_while_recording && *self.is_open.lock().unwrap() {\n            set_mute(true);\n            *did_mute_guard = true;\n            debug!(\"Mute applied\");\n        }\n    }\n\n    /// Removes mute if it was applied\n    pub fn remove_mute(&self) {\n        let mut did_mute_guard = self.did_mute.lock().unwrap();\n        if *did_mute_guard {\n            set_mute(false);\n            *did_mute_guard = false;\n            debug!(\"Mute removed\");\n        }\n    }\n\n    pub fn start_microphone_stream(&self) -> Result<(), anyhow::Error> {\n        let mut open_flag = self.is_open.lock().unwrap();\n        if *open_flag {\n            debug!(\"Microphone stream already active\");\n            return Ok(());\n        }\n\n        let start_time = Instant::now();\n\n        // Don't mute immediately - caller will handle muting after audio feedback\n        let mut did_mute_guard = self.did_mute.lock().unwrap();\n        *did_mute_guard = false;\n\n        let vad_path = self\n            .app_handle\n            .path()\n            .resolve(\n                \"resources/models/silero_vad_v4.onnx\",\n                tauri::path::BaseDirectory::Resource,\n            )\n            .map_err(|e| anyhow::anyhow!(\"Failed to resolve VAD path: {}\", e))?;\n        let mut recorder_opt = self.recorder.lock().unwrap();\n\n        if recorder_opt.is_none() {\n            *recorder_opt = Some(create_audio_recorder(\n                vad_path.to_str().unwrap(),\n                &self.app_handle,\n            )?);\n        }\n\n        // Get the selected device from settings, considering clamshell mode\n        let settings = get_settings(&self.app_handle);\n        let selected_device = self.get_effective_microphone_device(&settings);\n\n        if let Some(rec) = recorder_opt.as_mut() {\n            rec.open(selected_device)\n                .map_err(|e| anyhow::anyhow!(\"Failed to open recorder: {}\", e))?;\n        }\n\n        *open_flag = true;\n        info!(\n            \"Microphone stream initialized in {:?}\",\n            start_time.elapsed()\n        );\n        Ok(())\n    }\n\n    pub fn stop_microphone_stream(&self) {\n        let mut open_flag = self.is_open.lock().unwrap();\n        if !*open_flag {\n            return;\n        }\n\n        let mut did_mute_guard = self.did_mute.lock().unwrap();\n        if *did_mute_guard {\n            set_mute(false);\n        }\n        *did_mute_guard = false;\n\n        if let Some(rec) = self.recorder.lock().unwrap().as_mut() {\n            // If still recording, stop first.\n            if *self.is_recording.lock().unwrap() {\n                let _ = rec.stop();\n                *self.is_recording.lock().unwrap() = false;\n            }\n            let _ = rec.close();\n        }\n\n        *open_flag = false;\n        debug!(\"Microphone stream stopped\");\n    }\n\n    /* ---------- mode switching --------------------------------------------- */\n\n    pub fn update_mode(&self, new_mode: MicrophoneMode) -> Result<(), anyhow::Error> {\n        let cur_mode = self.mode.lock().unwrap().clone();\n\n        match (cur_mode, &new_mode) {\n            (MicrophoneMode::AlwaysOn, MicrophoneMode::OnDemand) => {\n                if matches!(*self.state.lock().unwrap(), RecordingState::Idle) {\n                    self.close_generation.fetch_add(1, Ordering::SeqCst);\n                    self.stop_microphone_stream();\n                }\n            }\n            (MicrophoneMode::OnDemand, MicrophoneMode::AlwaysOn) => {\n                self.close_generation.fetch_add(1, Ordering::SeqCst);\n                self.start_microphone_stream()?;\n            }\n            _ => {}\n        }\n\n        *self.mode.lock().unwrap() = new_mode;\n        Ok(())\n    }\n\n    /* ---------- recording --------------------------------------------------- */\n\n    pub fn try_start_recording(&self, binding_id: &str) -> Result<(), String> {\n        let mut state = self.state.lock().unwrap();\n\n        if let RecordingState::Idle = *state {\n            // Ensure microphone is open in on-demand mode\n            if matches!(*self.mode.lock().unwrap(), MicrophoneMode::OnDemand) {\n                // Cancel any pending lazy close\n                self.close_generation.fetch_add(1, Ordering::SeqCst);\n                if let Err(e) = self.start_microphone_stream() {\n                    let msg = format!(\"{e}\");\n                    error!(\"Failed to open microphone stream: {msg}\");\n                    return Err(msg);\n                }\n            }\n\n            if let Some(rec) = self.recorder.lock().unwrap().as_ref() {\n                if rec.start().is_ok() {\n                    *self.is_recording.lock().unwrap() = true;\n                    *state = RecordingState::Recording {\n                        binding_id: binding_id.to_string(),\n                    };\n                    debug!(\"Recording started for binding {binding_id}\");\n                    return Ok(());\n                }\n            }\n            Err(\"Recorder not available\".to_string())\n        } else {\n            Err(\"Already recording\".to_string())\n        }\n    }\n\n    pub fn update_selected_device(&self) -> Result<(), anyhow::Error> {\n        // If currently open, restart the microphone stream to use the new device\n        if *self.is_open.lock().unwrap() {\n            self.close_generation.fetch_add(1, Ordering::SeqCst);\n            self.stop_microphone_stream();\n            self.start_microphone_stream()?;\n        }\n        Ok(())\n    }\n\n    pub fn stop_recording(&self, binding_id: &str) -> Option<Vec<f32>> {\n        let mut state = self.state.lock().unwrap();\n\n        match *state {\n            RecordingState::Recording {\n                binding_id: ref active,\n            } if active == binding_id => {\n                *state = RecordingState::Idle;\n                drop(state);\n\n                // Optionally keep recording for a bit longer to capture trailing audio\n                let settings = get_settings(&self.app_handle);\n                if settings.extra_recording_buffer_ms > 0 {\n                    debug!(\n                        \"Extra recording buffer: sleeping {}ms before stopping\",\n                        settings.extra_recording_buffer_ms\n                    );\n                    std::thread::sleep(Duration::from_millis(settings.extra_recording_buffer_ms));\n                }\n\n                let samples = if let Some(rec) = self.recorder.lock().unwrap().as_ref() {\n                    match rec.stop() {\n                        Ok(buf) => buf,\n                        Err(e) => {\n                            error!(\"stop() failed: {e}\");\n                            Vec::new()\n                        }\n                    }\n                } else {\n                    error!(\"Recorder not available\");\n                    Vec::new()\n                };\n\n                *self.is_recording.lock().unwrap() = false;\n\n                // In on-demand mode, close the mic (lazily if the setting is enabled)\n                if matches!(*self.mode.lock().unwrap(), MicrophoneMode::OnDemand) {\n                    if get_settings(&self.app_handle).lazy_stream_close {\n                        self.schedule_lazy_close();\n                    } else {\n                        self.stop_microphone_stream();\n                    }\n                }\n\n                // Pad if very short\n                let s_len = samples.len();\n                // debug!(\"Got {} samples\", s_len);\n                if s_len < WHISPER_SAMPLE_RATE && s_len > 0 {\n                    let mut padded = samples;\n                    padded.resize(WHISPER_SAMPLE_RATE * 5 / 4, 0.0);\n                    Some(padded)\n                } else {\n                    Some(samples)\n                }\n            }\n            _ => None,\n        }\n    }\n    pub fn is_recording(&self) -> bool {\n        matches!(\n            *self.state.lock().unwrap(),\n            RecordingState::Recording { .. }\n        )\n    }\n\n    /// Cancel any ongoing recording without returning audio samples\n    pub fn cancel_recording(&self) {\n        let mut state = self.state.lock().unwrap();\n\n        if let RecordingState::Recording { .. } = *state {\n            *state = RecordingState::Idle;\n            drop(state);\n\n            if let Some(rec) = self.recorder.lock().unwrap().as_ref() {\n                let _ = rec.stop(); // Discard the result\n            }\n\n            *self.is_recording.lock().unwrap() = false;\n\n            // In on-demand mode, close the mic (lazily if the setting is enabled)\n            if matches!(*self.mode.lock().unwrap(), MicrophoneMode::OnDemand) {\n                if get_settings(&self.app_handle).lazy_stream_close {\n                    self.schedule_lazy_close();\n                } else {\n                    self.stop_microphone_stream();\n                }\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/managers/history.rs",
    "content": "use anyhow::Result;\nuse chrono::{DateTime, Local, Utc};\nuse log::{debug, error, info};\nuse rusqlite::{params, Connection, OptionalExtension};\nuse rusqlite_migration::{Migrations, M};\nuse serde::{Deserialize, Serialize};\nuse specta::Type;\nuse std::fs;\nuse std::path::PathBuf;\nuse tauri::{AppHandle, Emitter};\n\nuse crate::audio_toolkit::save_wav_file;\n\n/// Database migrations for transcription history.\n/// Each migration is applied in order. The library tracks which migrations\n/// have been applied using SQLite's user_version pragma.\n///\n/// Note: For users upgrading from tauri-plugin-sql, migrate_from_tauri_plugin_sql()\n/// converts the old _sqlx_migrations table tracking to the user_version pragma,\n/// ensuring migrations don't re-run on existing databases.\nstatic MIGRATIONS: &[M] = &[\n    M::up(\n        \"CREATE TABLE IF NOT EXISTS transcription_history (\n            id INTEGER PRIMARY KEY AUTOINCREMENT,\n            file_name TEXT NOT NULL,\n            timestamp INTEGER NOT NULL,\n            saved BOOLEAN NOT NULL DEFAULT 0,\n            title TEXT NOT NULL,\n            transcription_text TEXT NOT NULL\n        );\",\n    ),\n    M::up(\"ALTER TABLE transcription_history ADD COLUMN post_processed_text TEXT;\"),\n    M::up(\"ALTER TABLE transcription_history ADD COLUMN post_process_prompt TEXT;\"),\n];\n\n#[derive(Clone, Debug, Serialize, Deserialize, Type)]\npub struct HistoryEntry {\n    pub id: i64,\n    pub file_name: String,\n    pub timestamp: i64,\n    pub saved: bool,\n    pub title: String,\n    pub transcription_text: String,\n    pub post_processed_text: Option<String>,\n    pub post_process_prompt: Option<String>,\n}\n\npub struct HistoryManager {\n    app_handle: AppHandle,\n    recordings_dir: PathBuf,\n    db_path: PathBuf,\n}\n\nimpl HistoryManager {\n    pub fn new(app_handle: &AppHandle) -> Result<Self> {\n        // Create recordings directory in app data dir\n        let app_data_dir = crate::portable::app_data_dir(app_handle)?;\n        let recordings_dir = app_data_dir.join(\"recordings\");\n        let db_path = app_data_dir.join(\"history.db\");\n\n        // Ensure recordings directory exists\n        if !recordings_dir.exists() {\n            fs::create_dir_all(&recordings_dir)?;\n            debug!(\"Created recordings directory: {:?}\", recordings_dir);\n        }\n\n        let manager = Self {\n            app_handle: app_handle.clone(),\n            recordings_dir,\n            db_path,\n        };\n\n        // Initialize database and run migrations synchronously\n        manager.init_database()?;\n\n        Ok(manager)\n    }\n\n    fn init_database(&self) -> Result<()> {\n        info!(\"Initializing database at {:?}\", self.db_path);\n\n        let mut conn = Connection::open(&self.db_path)?;\n\n        // Handle migration from tauri-plugin-sql to rusqlite_migration\n        // tauri-plugin-sql used _sqlx_migrations table, rusqlite_migration uses user_version pragma\n        self.migrate_from_tauri_plugin_sql(&conn)?;\n\n        // Create migrations object and run to latest version\n        let migrations = Migrations::new(MIGRATIONS.to_vec());\n\n        // Validate migrations in debug builds\n        #[cfg(debug_assertions)]\n        migrations.validate().expect(\"Invalid migrations\");\n\n        // Get current version before migration\n        let version_before: i32 =\n            conn.pragma_query_value(None, \"user_version\", |row| row.get(0))?;\n        debug!(\"Database version before migration: {}\", version_before);\n\n        // Apply any pending migrations\n        migrations.to_latest(&mut conn)?;\n\n        // Get version after migration\n        let version_after: i32 = conn.pragma_query_value(None, \"user_version\", |row| row.get(0))?;\n\n        if version_after > version_before {\n            info!(\n                \"Database migrated from version {} to {}\",\n                version_before, version_after\n            );\n        } else {\n            debug!(\"Database already at latest version {}\", version_after);\n        }\n\n        Ok(())\n    }\n\n    /// Migrate from tauri-plugin-sql's migration tracking to rusqlite_migration's.\n    /// tauri-plugin-sql used a _sqlx_migrations table, while rusqlite_migration uses\n    /// SQLite's user_version pragma. This function checks if the old system was in use\n    /// and sets the user_version accordingly so migrations don't re-run.\n    fn migrate_from_tauri_plugin_sql(&self, conn: &Connection) -> Result<()> {\n        // Check if the old _sqlx_migrations table exists\n        let has_sqlx_migrations: bool = conn\n            .query_row(\n                \"SELECT COUNT(*) > 0 FROM sqlite_master WHERE type='table' AND name='_sqlx_migrations'\",\n                [],\n                |row| row.get(0),\n            )\n            .unwrap_or(false);\n\n        if !has_sqlx_migrations {\n            return Ok(());\n        }\n\n        // Check current user_version\n        let current_version: i32 =\n            conn.pragma_query_value(None, \"user_version\", |row| row.get(0))?;\n\n        if current_version > 0 {\n            // Already migrated to rusqlite_migration system\n            return Ok(());\n        }\n\n        // Get the highest version from the old migrations table\n        let old_version: i32 = conn\n            .query_row(\n                \"SELECT COALESCE(MAX(version), 0) FROM _sqlx_migrations WHERE success = 1\",\n                [],\n                |row| row.get(0),\n            )\n            .unwrap_or(0);\n\n        if old_version > 0 {\n            info!(\n                \"Migrating from tauri-plugin-sql (version {}) to rusqlite_migration\",\n                old_version\n            );\n\n            // Set user_version to match the old migration state\n            conn.pragma_update(None, \"user_version\", old_version)?;\n\n            // Optionally drop the old migrations table (keeping it doesn't hurt)\n            // conn.execute(\"DROP TABLE IF EXISTS _sqlx_migrations\", [])?;\n\n            info!(\n                \"Migration tracking converted: user_version set to {}\",\n                old_version\n            );\n        }\n\n        Ok(())\n    }\n\n    fn get_connection(&self) -> Result<Connection> {\n        Ok(Connection::open(&self.db_path)?)\n    }\n\n    /// Save a transcription to history (both database and WAV file)\n    pub async fn save_transcription(\n        &self,\n        audio_samples: Vec<f32>,\n        transcription_text: String,\n        post_processed_text: Option<String>,\n        post_process_prompt: Option<String>,\n    ) -> Result<()> {\n        let timestamp = Utc::now().timestamp();\n        let file_name = format!(\"handy-{}.wav\", timestamp);\n        let title = self.format_timestamp_title(timestamp);\n\n        // Save WAV file\n        let file_path = self.recordings_dir.join(&file_name);\n        save_wav_file(file_path, &audio_samples).await?;\n\n        // Save to database\n        self.save_to_database(\n            file_name,\n            timestamp,\n            title,\n            transcription_text,\n            post_processed_text,\n            post_process_prompt,\n        )?;\n\n        // Clean up old entries\n        self.cleanup_old_entries()?;\n\n        // Emit history updated event\n        if let Err(e) = self.app_handle.emit(\"history-updated\", ()) {\n            error!(\"Failed to emit history-updated event: {}\", e);\n        }\n\n        Ok(())\n    }\n\n    fn save_to_database(\n        &self,\n        file_name: String,\n        timestamp: i64,\n        title: String,\n        transcription_text: String,\n        post_processed_text: Option<String>,\n        post_process_prompt: Option<String>,\n    ) -> Result<()> {\n        let conn = self.get_connection()?;\n        conn.execute(\n            \"INSERT INTO transcription_history (file_name, timestamp, saved, title, transcription_text, post_processed_text, post_process_prompt) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)\",\n            params![file_name, timestamp, false, title, transcription_text, post_processed_text, post_process_prompt],\n        )?;\n\n        debug!(\"Saved transcription to database\");\n        Ok(())\n    }\n\n    pub fn cleanup_old_entries(&self) -> Result<()> {\n        let retention_period = crate::settings::get_recording_retention_period(&self.app_handle);\n\n        match retention_period {\n            crate::settings::RecordingRetentionPeriod::Never => {\n                // Don't delete anything\n                return Ok(());\n            }\n            crate::settings::RecordingRetentionPeriod::PreserveLimit => {\n                // Use the old count-based logic with history_limit\n                let limit = crate::settings::get_history_limit(&self.app_handle);\n                return self.cleanup_by_count(limit);\n            }\n            _ => {\n                // Use time-based logic\n                return self.cleanup_by_time(retention_period);\n            }\n        }\n    }\n\n    fn delete_entries_and_files(&self, entries: &[(i64, String)]) -> Result<usize> {\n        if entries.is_empty() {\n            return Ok(0);\n        }\n\n        let conn = self.get_connection()?;\n        let mut deleted_count = 0;\n\n        for (id, file_name) in entries {\n            // Delete database entry\n            conn.execute(\n                \"DELETE FROM transcription_history WHERE id = ?1\",\n                params![id],\n            )?;\n\n            // Delete WAV file\n            let file_path = self.recordings_dir.join(file_name);\n            if file_path.exists() {\n                if let Err(e) = fs::remove_file(&file_path) {\n                    error!(\"Failed to delete WAV file {}: {}\", file_name, e);\n                } else {\n                    debug!(\"Deleted old WAV file: {}\", file_name);\n                    deleted_count += 1;\n                }\n            }\n        }\n\n        Ok(deleted_count)\n    }\n\n    fn cleanup_by_count(&self, limit: usize) -> Result<()> {\n        let conn = self.get_connection()?;\n\n        // Get all entries that are not saved, ordered by timestamp desc\n        let mut stmt = conn.prepare(\n            \"SELECT id, file_name FROM transcription_history WHERE saved = 0 ORDER BY timestamp DESC\"\n        )?;\n\n        let rows = stmt.query_map([], |row| {\n            Ok((row.get::<_, i64>(\"id\")?, row.get::<_, String>(\"file_name\")?))\n        })?;\n\n        let mut entries: Vec<(i64, String)> = Vec::new();\n        for row in rows {\n            entries.push(row?);\n        }\n\n        if entries.len() > limit {\n            let entries_to_delete = &entries[limit..];\n            let deleted_count = self.delete_entries_and_files(entries_to_delete)?;\n\n            if deleted_count > 0 {\n                debug!(\"Cleaned up {} old history entries by count\", deleted_count);\n            }\n        }\n\n        Ok(())\n    }\n\n    fn cleanup_by_time(\n        &self,\n        retention_period: crate::settings::RecordingRetentionPeriod,\n    ) -> Result<()> {\n        let conn = self.get_connection()?;\n\n        // Calculate cutoff timestamp (current time minus retention period)\n        let now = Utc::now().timestamp();\n        let cutoff_timestamp = match retention_period {\n            crate::settings::RecordingRetentionPeriod::Days3 => now - (3 * 24 * 60 * 60), // 3 days in seconds\n            crate::settings::RecordingRetentionPeriod::Weeks2 => now - (2 * 7 * 24 * 60 * 60), // 2 weeks in seconds\n            crate::settings::RecordingRetentionPeriod::Months3 => now - (3 * 30 * 24 * 60 * 60), // 3 months in seconds (approximate)\n            _ => unreachable!(\"Should not reach here\"),\n        };\n\n        // Get all unsaved entries older than the cutoff timestamp\n        let mut stmt = conn.prepare(\n            \"SELECT id, file_name FROM transcription_history WHERE saved = 0 AND timestamp < ?1\",\n        )?;\n\n        let rows = stmt.query_map(params![cutoff_timestamp], |row| {\n            Ok((row.get::<_, i64>(\"id\")?, row.get::<_, String>(\"file_name\")?))\n        })?;\n\n        let mut entries_to_delete: Vec<(i64, String)> = Vec::new();\n        for row in rows {\n            entries_to_delete.push(row?);\n        }\n\n        let deleted_count = self.delete_entries_and_files(&entries_to_delete)?;\n\n        if deleted_count > 0 {\n            debug!(\n                \"Cleaned up {} old history entries based on retention period\",\n                deleted_count\n            );\n        }\n\n        Ok(())\n    }\n\n    pub async fn get_history_entries(&self) -> Result<Vec<HistoryEntry>> {\n        let conn = self.get_connection()?;\n        let mut stmt = conn.prepare(\n            \"SELECT id, file_name, timestamp, saved, title, transcription_text, post_processed_text, post_process_prompt FROM transcription_history ORDER BY timestamp DESC\"\n        )?;\n\n        let rows = stmt.query_map([], |row| {\n            Ok(HistoryEntry {\n                id: row.get(\"id\")?,\n                file_name: row.get(\"file_name\")?,\n                timestamp: row.get(\"timestamp\")?,\n                saved: row.get(\"saved\")?,\n                title: row.get(\"title\")?,\n                transcription_text: row.get(\"transcription_text\")?,\n                post_processed_text: row.get(\"post_processed_text\")?,\n                post_process_prompt: row.get(\"post_process_prompt\")?,\n            })\n        })?;\n\n        let mut entries = Vec::new();\n        for row in rows {\n            entries.push(row?);\n        }\n\n        Ok(entries)\n    }\n\n    pub fn get_latest_entry(&self) -> Result<Option<HistoryEntry>> {\n        let conn = self.get_connection()?;\n        Self::get_latest_entry_with_conn(&conn)\n    }\n\n    fn get_latest_entry_with_conn(conn: &Connection) -> Result<Option<HistoryEntry>> {\n        let mut stmt = conn.prepare(\n            \"SELECT id, file_name, timestamp, saved, title, transcription_text, post_processed_text, post_process_prompt\n             FROM transcription_history\n             ORDER BY timestamp DESC\n             LIMIT 1\",\n        )?;\n\n        let entry = stmt\n            .query_row([], |row| {\n                Ok(HistoryEntry {\n                    id: row.get(\"id\")?,\n                    file_name: row.get(\"file_name\")?,\n                    timestamp: row.get(\"timestamp\")?,\n                    saved: row.get(\"saved\")?,\n                    title: row.get(\"title\")?,\n                    transcription_text: row.get(\"transcription_text\")?,\n                    post_processed_text: row.get(\"post_processed_text\")?,\n                    post_process_prompt: row.get(\"post_process_prompt\")?,\n                })\n            })\n            .optional()?;\n\n        Ok(entry)\n    }\n\n    pub async fn toggle_saved_status(&self, id: i64) -> Result<()> {\n        let conn = self.get_connection()?;\n\n        // Get current saved status\n        let current_saved: bool = conn.query_row(\n            \"SELECT saved FROM transcription_history WHERE id = ?1\",\n            params![id],\n            |row| row.get(\"saved\"),\n        )?;\n\n        let new_saved = !current_saved;\n\n        conn.execute(\n            \"UPDATE transcription_history SET saved = ?1 WHERE id = ?2\",\n            params![new_saved, id],\n        )?;\n\n        debug!(\"Toggled saved status for entry {}: {}\", id, new_saved);\n\n        // Emit history updated event\n        if let Err(e) = self.app_handle.emit(\"history-updated\", ()) {\n            error!(\"Failed to emit history-updated event: {}\", e);\n        }\n\n        Ok(())\n    }\n\n    pub fn get_audio_file_path(&self, file_name: &str) -> PathBuf {\n        self.recordings_dir.join(file_name)\n    }\n\n    pub async fn get_entry_by_id(&self, id: i64) -> Result<Option<HistoryEntry>> {\n        let conn = self.get_connection()?;\n        let mut stmt = conn.prepare(\n            \"SELECT id, file_name, timestamp, saved, title, transcription_text, post_processed_text, post_process_prompt\n             FROM transcription_history WHERE id = ?1\",\n        )?;\n\n        let entry = stmt\n            .query_row([id], |row| {\n                Ok(HistoryEntry {\n                    id: row.get(\"id\")?,\n                    file_name: row.get(\"file_name\")?,\n                    timestamp: row.get(\"timestamp\")?,\n                    saved: row.get(\"saved\")?,\n                    title: row.get(\"title\")?,\n                    transcription_text: row.get(\"transcription_text\")?,\n                    post_processed_text: row.get(\"post_processed_text\")?,\n                    post_process_prompt: row.get(\"post_process_prompt\")?,\n                })\n            })\n            .optional()?;\n\n        Ok(entry)\n    }\n\n    pub async fn delete_entry(&self, id: i64) -> Result<()> {\n        let conn = self.get_connection()?;\n\n        // Get the entry to find the file name\n        if let Some(entry) = self.get_entry_by_id(id).await? {\n            // Delete the audio file first\n            let file_path = self.get_audio_file_path(&entry.file_name);\n            if file_path.exists() {\n                if let Err(e) = fs::remove_file(&file_path) {\n                    error!(\"Failed to delete audio file {}: {}\", entry.file_name, e);\n                    // Continue with database deletion even if file deletion fails\n                }\n            }\n        }\n\n        // Delete from database\n        conn.execute(\n            \"DELETE FROM transcription_history WHERE id = ?1\",\n            params![id],\n        )?;\n\n        debug!(\"Deleted history entry with id: {}\", id);\n\n        // Emit history updated event\n        if let Err(e) = self.app_handle.emit(\"history-updated\", ()) {\n            error!(\"Failed to emit history-updated event: {}\", e);\n        }\n\n        Ok(())\n    }\n\n    fn format_timestamp_title(&self, timestamp: i64) -> String {\n        if let Some(utc_datetime) = DateTime::from_timestamp(timestamp, 0) {\n            // Convert UTC to local timezone\n            let local_datetime = utc_datetime.with_timezone(&Local);\n            local_datetime.format(\"%B %e, %Y - %l:%M%p\").to_string()\n        } else {\n            format!(\"Recording {}\", timestamp)\n        }\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n    use rusqlite::{params, Connection};\n\n    fn setup_conn() -> Connection {\n        let conn = Connection::open_in_memory().expect(\"open in-memory db\");\n        conn.execute_batch(\n            \"CREATE TABLE transcription_history (\n                id INTEGER PRIMARY KEY AUTOINCREMENT,\n                file_name TEXT NOT NULL,\n                timestamp INTEGER NOT NULL,\n                saved BOOLEAN NOT NULL DEFAULT 0,\n                title TEXT NOT NULL,\n                transcription_text TEXT NOT NULL,\n                post_processed_text TEXT,\n                post_process_prompt TEXT\n            );\",\n        )\n        .expect(\"create transcription_history table\");\n        conn\n    }\n\n    fn insert_entry(conn: &Connection, timestamp: i64, text: &str, post_processed: Option<&str>) {\n        conn.execute(\n            \"INSERT INTO transcription_history (file_name, timestamp, saved, title, transcription_text, post_processed_text, post_process_prompt)\n             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)\",\n            params![\n                format!(\"handy-{}.wav\", timestamp),\n                timestamp,\n                false,\n                format!(\"Recording {}\", timestamp),\n                text,\n                post_processed,\n                Option::<String>::None\n            ],\n        )\n        .expect(\"insert history entry\");\n    }\n\n    #[test]\n    fn get_latest_entry_returns_none_when_empty() {\n        let conn = setup_conn();\n        let entry = HistoryManager::get_latest_entry_with_conn(&conn).expect(\"fetch latest entry\");\n        assert!(entry.is_none());\n    }\n\n    #[test]\n    fn get_latest_entry_returns_newest_entry() {\n        let conn = setup_conn();\n        insert_entry(&conn, 100, \"first\", None);\n        insert_entry(&conn, 200, \"second\", Some(\"processed\"));\n\n        let entry = HistoryManager::get_latest_entry_with_conn(&conn)\n            .expect(\"fetch latest entry\")\n            .expect(\"entry exists\");\n\n        assert_eq!(entry.timestamp, 200);\n        assert_eq!(entry.transcription_text, \"second\");\n        assert_eq!(entry.post_processed_text.as_deref(), Some(\"processed\"));\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/managers/mod.rs",
    "content": "pub mod audio;\npub mod history;\npub mod model;\npub mod transcription;\n"
  },
  {
    "path": "src-tauri/src/managers/model.rs",
    "content": "use crate::settings::{get_settings, write_settings};\nuse anyhow::Result;\nuse flate2::read::GzDecoder;\nuse futures_util::StreamExt;\nuse log::{debug, info, warn};\nuse serde::{Deserialize, Serialize};\nuse specta::Type;\nuse std::collections::{HashMap, HashSet};\nuse std::fs;\nuse std::fs::File;\nuse std::io::Write;\nuse std::path::{Path, PathBuf};\nuse std::sync::atomic::{AtomicBool, Ordering};\nuse std::sync::{Arc, Mutex};\nuse std::time::{Duration, Instant};\nuse tar::Archive;\nuse tauri::{AppHandle, Emitter, Manager};\n\n#[derive(Debug, Clone, Serialize, Deserialize, Type)]\npub enum EngineType {\n    Whisper,\n    Parakeet,\n    Moonshine,\n    MoonshineStreaming,\n    SenseVoice,\n    GigaAM,\n    Canary,\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, Type)]\npub struct ModelInfo {\n    pub id: String,\n    pub name: String,\n    pub description: String,\n    pub filename: String,\n    pub url: Option<String>,\n    pub size_mb: u64,\n    pub is_downloaded: bool,\n    pub is_downloading: bool,\n    pub partial_size: u64,\n    pub is_directory: bool,\n    pub engine_type: EngineType,\n    pub accuracy_score: f32,        // 0.0 to 1.0, higher is more accurate\n    pub speed_score: f32,           // 0.0 to 1.0, higher is faster\n    pub supports_translation: bool, // Whether the model supports translating to English\n    pub is_recommended: bool,       // Whether this is the recommended model for new users\n    pub supported_languages: Vec<String>, // Languages this model can transcribe\n    pub supports_language_selection: bool, // Whether the user can explicitly pick a language\n    pub is_custom: bool,            // Whether this is a user-provided custom model\n}\n\n#[derive(Debug, Clone, Serialize, Deserialize, Type)]\npub struct DownloadProgress {\n    pub model_id: String,\n    pub downloaded: u64,\n    pub total: u64,\n    pub percentage: f64,\n}\n\npub struct ModelManager {\n    app_handle: AppHandle,\n    models_dir: PathBuf,\n    available_models: Mutex<HashMap<String, ModelInfo>>,\n    cancel_flags: Arc<Mutex<HashMap<String, Arc<AtomicBool>>>>,\n    extracting_models: Arc<Mutex<HashSet<String>>>,\n}\n\nimpl ModelManager {\n    pub fn new(app_handle: &AppHandle) -> Result<Self> {\n        // Create models directory in app data\n        let models_dir = crate::portable::app_data_dir(app_handle)\n            .map_err(|e| anyhow::anyhow!(\"Failed to get app data dir: {}\", e))?\n            .join(\"models\");\n\n        if !models_dir.exists() {\n            fs::create_dir_all(&models_dir)?;\n        }\n\n        let mut available_models = HashMap::new();\n\n        // Whisper supported languages (99 languages from tokenizer)\n        // Including zh-Hans and zh-Hant variants to match frontend language codes\n        let whisper_languages: Vec<String> = vec![\n            \"en\", \"zh\", \"zh-Hans\", \"zh-Hant\", \"de\", \"es\", \"ru\", \"ko\", \"fr\", \"ja\", \"pt\", \"tr\", \"pl\",\n            \"ca\", \"nl\", \"ar\", \"sv\", \"it\", \"id\", \"hi\", \"fi\", \"vi\", \"he\", \"uk\", \"el\", \"ms\", \"cs\",\n            \"ro\", \"da\", \"hu\", \"ta\", \"no\", \"th\", \"ur\", \"hr\", \"bg\", \"lt\", \"la\", \"mi\", \"ml\", \"cy\",\n            \"sk\", \"te\", \"fa\", \"lv\", \"bn\", \"sr\", \"az\", \"sl\", \"kn\", \"et\", \"mk\", \"br\", \"eu\", \"is\",\n            \"hy\", \"ne\", \"mn\", \"bs\", \"kk\", \"sq\", \"sw\", \"gl\", \"mr\", \"pa\", \"si\", \"km\", \"sn\", \"yo\",\n            \"so\", \"af\", \"oc\", \"ka\", \"be\", \"tg\", \"sd\", \"gu\", \"am\", \"yi\", \"lo\", \"uz\", \"fo\", \"ht\",\n            \"ps\", \"tk\", \"nn\", \"mt\", \"sa\", \"lb\", \"my\", \"bo\", \"tl\", \"mg\", \"as\", \"tt\", \"haw\", \"ln\",\n            \"ha\", \"ba\", \"jw\", \"su\", \"yue\",\n        ]\n        .into_iter()\n        .map(String::from)\n        .collect();\n\n        // TODO this should be read from a JSON file or something..\n        available_models.insert(\n            \"small\".to_string(),\n            ModelInfo {\n                id: \"small\".to_string(),\n                name: \"Whisper Small\".to_string(),\n                description: \"Fast and fairly accurate.\".to_string(),\n                filename: \"ggml-small.bin\".to_string(),\n                url: Some(\"https://blob.handy.computer/ggml-small.bin\".to_string()),\n                size_mb: 487,\n                is_downloaded: false,\n                is_downloading: false,\n                partial_size: 0,\n                is_directory: false,\n                engine_type: EngineType::Whisper,\n                accuracy_score: 0.60,\n                speed_score: 0.85,\n                supports_translation: true,\n                is_recommended: false,\n                supported_languages: whisper_languages.clone(),\n                supports_language_selection: true,\n                is_custom: false,\n            },\n        );\n\n        // Add downloadable models\n        available_models.insert(\n            \"medium\".to_string(),\n            ModelInfo {\n                id: \"medium\".to_string(),\n                name: \"Whisper Medium\".to_string(),\n                description: \"Good accuracy, medium speed\".to_string(),\n                filename: \"whisper-medium-q4_1.bin\".to_string(),\n                url: Some(\"https://blob.handy.computer/whisper-medium-q4_1.bin\".to_string()),\n                size_mb: 492, // Approximate size\n                is_downloaded: false,\n                is_downloading: false,\n                partial_size: 0,\n                is_directory: false,\n                engine_type: EngineType::Whisper,\n                accuracy_score: 0.75,\n                speed_score: 0.60,\n                supports_translation: true,\n                is_recommended: false,\n                supported_languages: whisper_languages.clone(),\n                supports_language_selection: true,\n                is_custom: false,\n            },\n        );\n\n        available_models.insert(\n            \"turbo\".to_string(),\n            ModelInfo {\n                id: \"turbo\".to_string(),\n                name: \"Whisper Turbo\".to_string(),\n                description: \"Balanced accuracy and speed.\".to_string(),\n                filename: \"ggml-large-v3-turbo.bin\".to_string(),\n                url: Some(\"https://blob.handy.computer/ggml-large-v3-turbo.bin\".to_string()),\n                size_mb: 1600, // Approximate size\n                is_downloaded: false,\n                is_downloading: false,\n                partial_size: 0,\n                is_directory: false,\n                engine_type: EngineType::Whisper,\n                accuracy_score: 0.80,\n                speed_score: 0.40,\n                supports_translation: false, // Turbo doesn't support translation\n                is_recommended: false,\n                supported_languages: whisper_languages.clone(),\n                supports_language_selection: true,\n                is_custom: false,\n            },\n        );\n\n        available_models.insert(\n            \"large\".to_string(),\n            ModelInfo {\n                id: \"large\".to_string(),\n                name: \"Whisper Large\".to_string(),\n                description: \"Good accuracy, but slow.\".to_string(),\n                filename: \"ggml-large-v3-q5_0.bin\".to_string(),\n                url: Some(\"https://blob.handy.computer/ggml-large-v3-q5_0.bin\".to_string()),\n                size_mb: 1100, // Approximate size\n                is_downloaded: false,\n                is_downloading: false,\n                partial_size: 0,\n                is_directory: false,\n                engine_type: EngineType::Whisper,\n                accuracy_score: 0.85,\n                speed_score: 0.30,\n                supports_translation: true,\n                is_recommended: false,\n                supported_languages: whisper_languages.clone(),\n                supports_language_selection: true,\n                is_custom: false,\n            },\n        );\n\n        available_models.insert(\n            \"breeze-asr\".to_string(),\n            ModelInfo {\n                id: \"breeze-asr\".to_string(),\n                name: \"Breeze ASR\".to_string(),\n                description: \"Optimized for Taiwanese Mandarin. Code-switching support.\"\n                    .to_string(),\n                filename: \"breeze-asr-q5_k.bin\".to_string(),\n                url: Some(\"https://blob.handy.computer/breeze-asr-q5_k.bin\".to_string()),\n                size_mb: 1080,\n                is_downloaded: false,\n                is_downloading: false,\n                partial_size: 0,\n                is_directory: false,\n                engine_type: EngineType::Whisper,\n                accuracy_score: 0.85,\n                speed_score: 0.35,\n                supports_translation: false,\n                is_recommended: false,\n                supported_languages: whisper_languages,\n                supports_language_selection: true,\n                is_custom: false,\n            },\n        );\n\n        // Add NVIDIA Parakeet models (directory-based)\n        available_models.insert(\n            \"parakeet-tdt-0.6b-v2\".to_string(),\n            ModelInfo {\n                id: \"parakeet-tdt-0.6b-v2\".to_string(),\n                name: \"Parakeet V2\".to_string(),\n                description: \"English only. The best model for English speakers.\".to_string(),\n                filename: \"parakeet-tdt-0.6b-v2-int8\".to_string(), // Directory name\n                url: Some(\"https://blob.handy.computer/parakeet-v2-int8.tar.gz\".to_string()),\n                size_mb: 473, // Approximate size for int8 quantized model\n                is_downloaded: false,\n                is_downloading: false,\n                partial_size: 0,\n                is_directory: true,\n                engine_type: EngineType::Parakeet,\n                accuracy_score: 0.85,\n                speed_score: 0.85,\n                supports_translation: false,\n                is_recommended: false,\n                supported_languages: vec![\"en\".to_string()],\n                supports_language_selection: false,\n                is_custom: false,\n            },\n        );\n\n        // Parakeet V3 supported languages (25 EU languages + Russian/Ukrainian):\n        // bg, hr, cs, da, nl, en, et, fi, fr, de, el, hu, it, lv, lt, mt, pl, pt, ro, sk, sl, es, sv, ru, uk\n        let parakeet_v3_languages: Vec<String> = vec![\n            \"bg\", \"hr\", \"cs\", \"da\", \"nl\", \"en\", \"et\", \"fi\", \"fr\", \"de\", \"el\", \"hu\", \"it\", \"lv\",\n            \"lt\", \"mt\", \"pl\", \"pt\", \"ro\", \"sk\", \"sl\", \"es\", \"sv\", \"ru\", \"uk\",\n        ]\n        .into_iter()\n        .map(String::from)\n        .collect();\n\n        available_models.insert(\n            \"parakeet-tdt-0.6b-v3\".to_string(),\n            ModelInfo {\n                id: \"parakeet-tdt-0.6b-v3\".to_string(),\n                name: \"Parakeet V3\".to_string(),\n                description: \"Fast and accurate. Supports 25 European languages.\".to_string(),\n                filename: \"parakeet-tdt-0.6b-v3-int8\".to_string(), // Directory name\n                url: Some(\"https://blob.handy.computer/parakeet-v3-int8.tar.gz\".to_string()),\n                size_mb: 478, // Approximate size for int8 quantized model\n                is_downloaded: false,\n                is_downloading: false,\n                partial_size: 0,\n                is_directory: true,\n                engine_type: EngineType::Parakeet,\n                accuracy_score: 0.80,\n                speed_score: 0.85,\n                supports_translation: false,\n                is_recommended: true,\n                supported_languages: parakeet_v3_languages,\n                supports_language_selection: false,\n                is_custom: false,\n            },\n        );\n\n        available_models.insert(\n            \"moonshine-base\".to_string(),\n            ModelInfo {\n                id: \"moonshine-base\".to_string(),\n                name: \"Moonshine Base\".to_string(),\n                description: \"Very fast, English only. Handles accents well.\".to_string(),\n                filename: \"moonshine-base\".to_string(),\n                url: Some(\"https://blob.handy.computer/moonshine-base.tar.gz\".to_string()),\n                size_mb: 58,\n                is_downloaded: false,\n                is_downloading: false,\n                partial_size: 0,\n                is_directory: true,\n                engine_type: EngineType::Moonshine,\n                accuracy_score: 0.70,\n                speed_score: 0.90,\n                supports_translation: false,\n                is_recommended: false,\n                supported_languages: vec![\"en\".to_string()],\n                supports_language_selection: false,\n                is_custom: false,\n            },\n        );\n\n        available_models.insert(\n            \"moonshine-tiny-streaming-en\".to_string(),\n            ModelInfo {\n                id: \"moonshine-tiny-streaming-en\".to_string(),\n                name: \"Moonshine V2 Tiny\".to_string(),\n                description: \"Ultra-fast, English only\".to_string(),\n                filename: \"moonshine-tiny-streaming-en\".to_string(),\n                url: Some(\n                    \"https://blob.handy.computer/moonshine-tiny-streaming-en.tar.gz\".to_string(),\n                ),\n                size_mb: 31,\n                is_downloaded: false,\n                is_downloading: false,\n                partial_size: 0,\n                is_directory: true,\n                engine_type: EngineType::MoonshineStreaming,\n                accuracy_score: 0.55,\n                speed_score: 0.95,\n                supports_translation: false,\n                is_recommended: false,\n                supported_languages: vec![\"en\".to_string()],\n                supports_language_selection: false,\n                is_custom: false,\n            },\n        );\n\n        available_models.insert(\n            \"moonshine-small-streaming-en\".to_string(),\n            ModelInfo {\n                id: \"moonshine-small-streaming-en\".to_string(),\n                name: \"Moonshine V2 Small\".to_string(),\n                description: \"Fast, English only. Good balance of speed and accuracy.\".to_string(),\n                filename: \"moonshine-small-streaming-en\".to_string(),\n                url: Some(\n                    \"https://blob.handy.computer/moonshine-small-streaming-en.tar.gz\".to_string(),\n                ),\n                size_mb: 100,\n                is_downloaded: false,\n                is_downloading: false,\n                partial_size: 0,\n                is_directory: true,\n                engine_type: EngineType::MoonshineStreaming,\n                accuracy_score: 0.65,\n                speed_score: 0.90,\n                supports_translation: false,\n                is_recommended: false,\n                supported_languages: vec![\"en\".to_string()],\n                supports_language_selection: false,\n                is_custom: false,\n            },\n        );\n\n        available_models.insert(\n            \"moonshine-medium-streaming-en\".to_string(),\n            ModelInfo {\n                id: \"moonshine-medium-streaming-en\".to_string(),\n                name: \"Moonshine V2 Medium\".to_string(),\n                description: \"English only. High quality.\".to_string(),\n                filename: \"moonshine-medium-streaming-en\".to_string(),\n                url: Some(\n                    \"https://blob.handy.computer/moonshine-medium-streaming-en.tar.gz\".to_string(),\n                ),\n                size_mb: 192,\n                is_downloaded: false,\n                is_downloading: false,\n                partial_size: 0,\n                is_directory: true,\n                engine_type: EngineType::MoonshineStreaming,\n                accuracy_score: 0.75,\n                speed_score: 0.80,\n                supports_translation: false,\n                is_recommended: false,\n                supported_languages: vec![\"en\".to_string()],\n                supports_language_selection: false,\n                is_custom: false,\n            },\n        );\n\n        // SenseVoice supported languages\n        let sense_voice_languages: Vec<String> =\n            vec![\"zh\", \"zh-Hans\", \"zh-Hant\", \"en\", \"yue\", \"ja\", \"ko\"]\n                .into_iter()\n                .map(String::from)\n                .collect();\n\n        available_models.insert(\n            \"sense-voice-int8\".to_string(),\n            ModelInfo {\n                id: \"sense-voice-int8\".to_string(),\n                name: \"SenseVoice\".to_string(),\n                description: \"Very fast. Chinese, English, Japanese, Korean, Cantonese.\"\n                    .to_string(),\n                filename: \"sense-voice-int8\".to_string(),\n                url: Some(\"https://blob.handy.computer/sense-voice-int8.tar.gz\".to_string()),\n                size_mb: 160,\n                is_downloaded: false,\n                is_downloading: false,\n                partial_size: 0,\n                is_directory: true,\n                engine_type: EngineType::SenseVoice,\n                accuracy_score: 0.65,\n                speed_score: 0.95,\n                supports_translation: false,\n                is_recommended: false,\n                supported_languages: sense_voice_languages,\n                supports_language_selection: true,\n                is_custom: false,\n            },\n        );\n\n        // GigaAM v3 supported languages\n        let gigaam_languages: Vec<String> = vec![\"ru\"].into_iter().map(String::from).collect();\n\n        available_models.insert(\n            \"gigaam-v3-e2e-ctc\".to_string(),\n            ModelInfo {\n                id: \"gigaam-v3-e2e-ctc\".to_string(),\n                name: \"GigaAM v3\".to_string(),\n                description: \"Russian speech recognition. Fast and accurate.\".to_string(),\n                filename: \"giga-am-v3-int8\".to_string(),\n                url: Some(\"https://blob.handy.computer/giga-am-v3-int8.tar.gz\".to_string()),\n                size_mb: 152,\n                is_downloaded: false,\n                is_downloading: false,\n                partial_size: 0,\n                is_directory: true,\n                engine_type: EngineType::GigaAM,\n                accuracy_score: 0.85,\n                speed_score: 0.75,\n                supports_translation: false,\n                is_recommended: false,\n                supported_languages: gigaam_languages,\n                supports_language_selection: false,\n                is_custom: false,\n            },\n        );\n\n        // Canary 180m Flash supported languages (4 languages)\n        let canary_flash_languages: Vec<String> = vec![\"en\", \"de\", \"es\", \"fr\"]\n            .into_iter()\n            .map(String::from)\n            .collect();\n\n        available_models.insert(\n            \"canary-180m-flash\".to_string(),\n            ModelInfo {\n                id: \"canary-180m-flash\".to_string(),\n                name: \"Canary 180M Flash\".to_string(),\n                description: \"Very fast. English, German, Spanish, French. Supports translation.\"\n                    .to_string(),\n                filename: \"canary-180m-flash\".to_string(),\n                url: Some(\"https://blob.handy.computer/canary-180m-flash.tar.gz\".to_string()),\n                size_mb: 146,\n                is_downloaded: false,\n                is_downloading: false,\n                partial_size: 0,\n                is_directory: true,\n                engine_type: EngineType::Canary,\n                accuracy_score: 0.75,\n                speed_score: 0.85,\n                supports_translation: true,\n                is_recommended: false,\n                supported_languages: canary_flash_languages,\n                supports_language_selection: true,\n                is_custom: false,\n            },\n        );\n\n        // Canary 1B v2 supported languages (25 EU languages)\n        let canary_1b_languages: Vec<String> = vec![\n            \"bg\", \"hr\", \"cs\", \"da\", \"nl\", \"en\", \"et\", \"fi\", \"fr\", \"de\", \"el\", \"hu\", \"it\", \"lv\",\n            \"lt\", \"mt\", \"pl\", \"pt\", \"ro\", \"sk\", \"sl\", \"es\", \"sv\", \"ru\", \"uk\",\n        ]\n        .into_iter()\n        .map(String::from)\n        .collect();\n\n        available_models.insert(\n            \"canary-1b-v2\".to_string(),\n            ModelInfo {\n                id: \"canary-1b-v2\".to_string(),\n                name: \"Canary 1B v2\".to_string(),\n                description: \"Accurate multilingual. 25 European languages. Supports translation.\"\n                    .to_string(),\n                filename: \"canary-1b-v2\".to_string(),\n                url: Some(\"https://blob.handy.computer/canary-1b-v2.tar.gz\".to_string()),\n                size_mb: 692,\n                is_downloaded: false,\n                is_downloading: false,\n                partial_size: 0,\n                is_directory: true,\n                engine_type: EngineType::Canary,\n                accuracy_score: 0.85,\n                speed_score: 0.70,\n                supports_translation: true,\n                is_recommended: false,\n                supported_languages: canary_1b_languages,\n                supports_language_selection: true,\n                is_custom: false,\n            },\n        );\n\n        // Auto-discover custom Whisper models (.bin files) in the models directory\n        if let Err(e) = Self::discover_custom_whisper_models(&models_dir, &mut available_models) {\n            warn!(\"Failed to discover custom models: {}\", e);\n        }\n\n        let manager = Self {\n            app_handle: app_handle.clone(),\n            models_dir,\n            available_models: Mutex::new(available_models),\n            cancel_flags: Arc::new(Mutex::new(HashMap::new())),\n            extracting_models: Arc::new(Mutex::new(HashSet::new())),\n        };\n\n        // Migrate any bundled models to user directory\n        manager.migrate_bundled_models()?;\n\n        // Migrate GigaAM from single-file to directory format\n        manager.migrate_gigaam_to_directory()?;\n\n        // Check which models are already downloaded\n        manager.update_download_status()?;\n\n        // Auto-select a model if none is currently selected\n        manager.auto_select_model_if_needed()?;\n\n        Ok(manager)\n    }\n\n    pub fn get_available_models(&self) -> Vec<ModelInfo> {\n        let models = self.available_models.lock().unwrap();\n        models.values().cloned().collect()\n    }\n\n    pub fn get_model_info(&self, model_id: &str) -> Option<ModelInfo> {\n        let models = self.available_models.lock().unwrap();\n        models.get(model_id).cloned()\n    }\n\n    fn migrate_bundled_models(&self) -> Result<()> {\n        // Check for bundled models and copy them to user directory\n        let bundled_models = [\"ggml-small.bin\"]; // Add other bundled models here if any\n\n        for filename in &bundled_models {\n            let bundled_path = self.app_handle.path().resolve(\n                &format!(\"resources/models/{}\", filename),\n                tauri::path::BaseDirectory::Resource,\n            );\n\n            if let Ok(bundled_path) = bundled_path {\n                if bundled_path.exists() {\n                    let user_path = self.models_dir.join(filename);\n\n                    // Only copy if user doesn't already have the model\n                    if !user_path.exists() {\n                        info!(\"Migrating bundled model {} to user directory\", filename);\n                        fs::copy(&bundled_path, &user_path)?;\n                        info!(\"Successfully migrated {}\", filename);\n                    }\n                }\n            }\n        }\n\n        Ok(())\n    }\n\n    /// Migrate GigaAM from the old single-file format (giga-am-v3.int8.onnx)\n    /// to the new directory format (giga-am-v3-int8/model.int8.onnx + vocab.txt).\n    /// This was required by the transcribe-rs 0.3.x upgrade.\n    fn migrate_gigaam_to_directory(&self) -> Result<()> {\n        let old_file = self.models_dir.join(\"giga-am-v3.int8.onnx\");\n        let new_dir = self.models_dir.join(\"giga-am-v3-int8\");\n\n        if !old_file.exists() || new_dir.exists() {\n            return Ok(());\n        }\n\n        info!(\"Migrating GigaAM from single-file to directory format\");\n\n        let vocab_path = self\n            .app_handle\n            .path()\n            .resolve(\n                \"resources/models/gigaam_vocab.txt\",\n                tauri::path::BaseDirectory::Resource,\n            )\n            .map_err(|e| anyhow::anyhow!(\"Failed to resolve GigaAM vocab path: {}\", e))?;\n\n        info!(\n            \"Resolved vocab path: {:?} (exists: {})\",\n            vocab_path,\n            vocab_path.exists()\n        );\n        info!(\"Old file: {:?} (exists: {})\", old_file, old_file.exists());\n        info!(\"New dir: {:?} (exists: {})\", new_dir, new_dir.exists());\n\n        fs::create_dir_all(&new_dir)?;\n        fs::rename(&old_file, new_dir.join(\"model.int8.onnx\"))?;\n        fs::copy(&vocab_path, new_dir.join(\"vocab.txt\"))?;\n\n        // Clean up old partial file if it exists\n        let old_partial = self.models_dir.join(\"giga-am-v3.int8.onnx.partial\");\n        if old_partial.exists() {\n            let _ = fs::remove_file(&old_partial);\n        }\n\n        info!(\"GigaAM migration complete\");\n        Ok(())\n    }\n\n    fn update_download_status(&self) -> Result<()> {\n        let mut models = self.available_models.lock().unwrap();\n\n        for model in models.values_mut() {\n            if model.is_directory {\n                // For directory-based models, check if the directory exists\n                let model_path = self.models_dir.join(&model.filename);\n                let partial_path = self.models_dir.join(format!(\"{}.partial\", &model.filename));\n                let extracting_path = self\n                    .models_dir\n                    .join(format!(\"{}.extracting\", &model.filename));\n\n                // Clean up any leftover .extracting directories from interrupted extractions\n                // But only if this model is NOT currently being extracted\n                let is_currently_extracting = {\n                    let extracting = self.extracting_models.lock().unwrap();\n                    extracting.contains(&model.id)\n                };\n                if extracting_path.exists() && !is_currently_extracting {\n                    warn!(\"Cleaning up interrupted extraction for model: {}\", model.id);\n                    let _ = fs::remove_dir_all(&extracting_path);\n                }\n\n                model.is_downloaded = model_path.exists() && model_path.is_dir();\n                model.is_downloading = false;\n\n                // Get partial file size if it exists (for the .tar.gz being downloaded)\n                if partial_path.exists() {\n                    model.partial_size = partial_path.metadata().map(|m| m.len()).unwrap_or(0);\n                } else {\n                    model.partial_size = 0;\n                }\n            } else {\n                // For file-based models (existing logic)\n                let model_path = self.models_dir.join(&model.filename);\n                let partial_path = self.models_dir.join(format!(\"{}.partial\", &model.filename));\n\n                model.is_downloaded = model_path.exists();\n                model.is_downloading = false;\n\n                // Get partial file size if it exists\n                if partial_path.exists() {\n                    model.partial_size = partial_path.metadata().map(|m| m.len()).unwrap_or(0);\n                } else {\n                    model.partial_size = 0;\n                }\n            }\n        }\n\n        Ok(())\n    }\n\n    fn auto_select_model_if_needed(&self) -> Result<()> {\n        let mut settings = get_settings(&self.app_handle);\n\n        // Clear stale selection: selected model is set but doesn't exist\n        // in available_models (e.g. deleted custom model file)\n        if !settings.selected_model.is_empty() {\n            let models = self.available_models.lock().unwrap();\n            let exists = models.contains_key(&settings.selected_model);\n            drop(models);\n\n            if !exists {\n                info!(\n                    \"Selected model '{}' not found in available models, clearing selection\",\n                    settings.selected_model\n                );\n                settings.selected_model = String::new();\n                write_settings(&self.app_handle, settings.clone());\n            }\n        }\n\n        // If no model is selected, pick the first downloaded one\n        if settings.selected_model.is_empty() {\n            // Find the first available (downloaded) model\n            let models = self.available_models.lock().unwrap();\n            if let Some(available_model) = models.values().find(|model| model.is_downloaded) {\n                info!(\n                    \"Auto-selecting model: {} ({})\",\n                    available_model.id, available_model.name\n                );\n\n                // Update settings with the selected model\n                let mut updated_settings = settings;\n                updated_settings.selected_model = available_model.id.clone();\n                write_settings(&self.app_handle, updated_settings);\n\n                info!(\"Successfully auto-selected model: {}\", available_model.id);\n            }\n        }\n\n        Ok(())\n    }\n\n    /// Discover custom Whisper models (.bin files) in the models directory.\n    /// Skips files that match predefined model filenames.\n    fn discover_custom_whisper_models(\n        models_dir: &Path,\n        available_models: &mut HashMap<String, ModelInfo>,\n    ) -> Result<()> {\n        if !models_dir.exists() {\n            return Ok(());\n        }\n\n        // Collect filenames of predefined Whisper file-based models to skip\n        let predefined_filenames: HashSet<String> = available_models\n            .values()\n            .filter(|m| matches!(m.engine_type, EngineType::Whisper) && !m.is_directory)\n            .map(|m| m.filename.clone())\n            .collect();\n\n        // Scan models directory for .bin files\n        for entry in fs::read_dir(models_dir)? {\n            let entry = match entry {\n                Ok(e) => e,\n                Err(e) => {\n                    warn!(\"Failed to read directory entry: {}\", e);\n                    continue;\n                }\n            };\n\n            let path = entry.path();\n\n            // Only process .bin files (not directories)\n            if !path.is_file() {\n                continue;\n            }\n\n            let filename = match path.file_name().and_then(|s| s.to_str()) {\n                Some(name) => name.to_string(),\n                None => continue,\n            };\n\n            // Skip hidden files\n            if filename.starts_with('.') {\n                continue;\n            }\n\n            // Only process .bin files (Whisper GGML format).\n            // This also excludes .partial downloads (e.g., \"model.bin.partial\").\n            // If we add discovery for other formats, add a .partial check before this filter.\n            if !filename.ends_with(\".bin\") {\n                continue;\n            }\n\n            // Skip predefined model files\n            if predefined_filenames.contains(&filename) {\n                continue;\n            }\n\n            // Generate model ID from filename (remove .bin extension)\n            let model_id = filename.trim_end_matches(\".bin\").to_string();\n\n            // Skip if model ID already exists (shouldn't happen, but be safe)\n            if available_models.contains_key(&model_id) {\n                continue;\n            }\n\n            // Generate display name: replace - and _ with space, capitalize words\n            let display_name = model_id\n                .replace(['-', '_'], \" \")\n                .split_whitespace()\n                .map(|word| {\n                    let mut chars = word.chars();\n                    match chars.next() {\n                        None => String::new(),\n                        Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),\n                    }\n                })\n                .collect::<Vec<_>>()\n                .join(\" \");\n\n            // Get file size in MB\n            let size_mb = match path.metadata() {\n                Ok(meta) => meta.len() / (1024 * 1024),\n                Err(e) => {\n                    warn!(\"Failed to get metadata for {}: {}\", filename, e);\n                    0\n                }\n            };\n\n            info!(\n                \"Discovered custom Whisper model: {} ({}, {} MB)\",\n                model_id, filename, size_mb\n            );\n\n            available_models.insert(\n                model_id.clone(),\n                ModelInfo {\n                    id: model_id,\n                    name: display_name,\n                    description: \"Not officially supported\".to_string(),\n                    filename,\n                    url: None, // Custom models have no download URL\n                    size_mb,\n                    is_downloaded: true, // Already present on disk\n                    is_downloading: false,\n                    partial_size: 0,\n                    is_directory: false,\n                    engine_type: EngineType::Whisper,\n                    accuracy_score: 0.0, // Sentinel: UI hides score bars when both are 0\n                    speed_score: 0.0,\n                    supports_translation: false,\n                    is_recommended: false,\n                    supported_languages: vec![],\n                    supports_language_selection: true,\n                    is_custom: true,\n                },\n            );\n        }\n\n        Ok(())\n    }\n\n    pub async fn download_model(&self, model_id: &str) -> Result<()> {\n        let model_info = {\n            let models = self.available_models.lock().unwrap();\n            models.get(model_id).cloned()\n        };\n\n        let model_info =\n            model_info.ok_or_else(|| anyhow::anyhow!(\"Model not found: {}\", model_id))?;\n\n        let url = model_info\n            .url\n            .ok_or_else(|| anyhow::anyhow!(\"No download URL for model\"))?;\n        let model_path = self.models_dir.join(&model_info.filename);\n        let partial_path = self\n            .models_dir\n            .join(format!(\"{}.partial\", &model_info.filename));\n\n        // Don't download if complete version already exists\n        if model_path.exists() {\n            // Clean up any partial file that might exist\n            if partial_path.exists() {\n                let _ = fs::remove_file(&partial_path);\n            }\n            self.update_download_status()?;\n            return Ok(());\n        }\n\n        // Check if we have a partial download to resume\n        let mut resume_from = if partial_path.exists() {\n            let size = partial_path.metadata()?.len();\n            info!(\"Resuming download of model {} from byte {}\", model_id, size);\n            size\n        } else {\n            info!(\"Starting fresh download of model {} from {}\", model_id, url);\n            0\n        };\n\n        // Mark as downloading\n        {\n            let mut models = self.available_models.lock().unwrap();\n            if let Some(model) = models.get_mut(model_id) {\n                model.is_downloading = true;\n            }\n        }\n\n        // Create cancellation flag for this download\n        let cancel_flag = Arc::new(AtomicBool::new(false));\n        {\n            let mut flags = self.cancel_flags.lock().unwrap();\n            flags.insert(model_id.to_string(), cancel_flag.clone());\n        }\n\n        // Create HTTP client with range request for resuming\n        let client = reqwest::Client::new();\n        let mut request = client.get(&url);\n\n        if resume_from > 0 {\n            request = request.header(\"Range\", format!(\"bytes={}-\", resume_from));\n        }\n\n        let mut response = request.send().await?;\n\n        // If we tried to resume but server returned 200 (not 206 Partial Content),\n        // the server doesn't support range requests. Delete partial file and restart\n        // fresh to avoid file corruption (appending full file to partial).\n        if resume_from > 0 && response.status() == reqwest::StatusCode::OK {\n            warn!(\n                \"Server doesn't support range requests for model {}, restarting download\",\n                model_id\n            );\n            drop(response);\n            let _ = fs::remove_file(&partial_path);\n\n            // Reset resume_from since we're starting fresh\n            resume_from = 0;\n\n            // Restart download without range header\n            response = client.get(&url).send().await?;\n        }\n\n        // Check for success or partial content status\n        if !response.status().is_success()\n            && response.status() != reqwest::StatusCode::PARTIAL_CONTENT\n        {\n            // Mark as not downloading on error\n            {\n                let mut models = self.available_models.lock().unwrap();\n                if let Some(model) = models.get_mut(model_id) {\n                    model.is_downloading = false;\n                }\n            }\n            return Err(anyhow::anyhow!(\n                \"Failed to download model: HTTP {}\",\n                response.status()\n            ));\n        }\n\n        let total_size = if resume_from > 0 {\n            // For resumed downloads, add the resume point to content length\n            resume_from + response.content_length().unwrap_or(0)\n        } else {\n            response.content_length().unwrap_or(0)\n        };\n\n        let mut downloaded = resume_from;\n        let mut stream = response.bytes_stream();\n\n        // Open file for appending if resuming, or create new if starting fresh\n        let mut file = if resume_from > 0 {\n            std::fs::OpenOptions::new()\n                .create(true)\n                .append(true)\n                .open(&partial_path)?\n        } else {\n            std::fs::File::create(&partial_path)?\n        };\n\n        // Emit initial progress\n        let initial_progress = DownloadProgress {\n            model_id: model_id.to_string(),\n            downloaded,\n            total: total_size,\n            percentage: if total_size > 0 {\n                (downloaded as f64 / total_size as f64) * 100.0\n            } else {\n                0.0\n            },\n        };\n        let _ = self\n            .app_handle\n            .emit(\"model-download-progress\", &initial_progress);\n\n        // Throttle progress events to max 10/sec (100ms intervals)\n        let mut last_emit = Instant::now();\n        let throttle_duration = Duration::from_millis(100);\n\n        // Download with progress\n        while let Some(chunk) = stream.next().await {\n            // Check if download was cancelled\n            if cancel_flag.load(Ordering::Relaxed) {\n                // Close the file before returning\n                drop(file);\n                info!(\"Download cancelled for: {}\", model_id);\n\n                // Update state to mark as not downloading\n                {\n                    let mut models = self.available_models.lock().unwrap();\n                    if let Some(model) = models.get_mut(model_id) {\n                        model.is_downloading = false;\n                    }\n                }\n\n                // Remove cancel flag\n                {\n                    let mut flags = self.cancel_flags.lock().unwrap();\n                    flags.remove(model_id);\n                }\n\n                // Keep partial file for resume functionality\n                return Ok(());\n            }\n\n            let chunk = chunk.map_err(|e| {\n                // Mark as not downloading on error\n                {\n                    let mut models = self.available_models.lock().unwrap();\n                    if let Some(model) = models.get_mut(model_id) {\n                        model.is_downloading = false;\n                    }\n                }\n                e\n            })?;\n\n            file.write_all(&chunk)?;\n            downloaded += chunk.len() as u64;\n\n            let percentage = if total_size > 0 {\n                (downloaded as f64 / total_size as f64) * 100.0\n            } else {\n                0.0\n            };\n\n            // Emit progress event (throttled to avoid UI freeze)\n            if last_emit.elapsed() >= throttle_duration {\n                let progress = DownloadProgress {\n                    model_id: model_id.to_string(),\n                    downloaded,\n                    total: total_size,\n                    percentage,\n                };\n                let _ = self.app_handle.emit(\"model-download-progress\", &progress);\n                last_emit = Instant::now();\n            }\n        }\n\n        // Emit final progress to ensure 100% is shown\n        let final_progress = DownloadProgress {\n            model_id: model_id.to_string(),\n            downloaded,\n            total: total_size,\n            percentage: if total_size > 0 {\n                (downloaded as f64 / total_size as f64) * 100.0\n            } else {\n                100.0\n            },\n        };\n        let _ = self\n            .app_handle\n            .emit(\"model-download-progress\", &final_progress);\n\n        file.flush()?;\n        drop(file); // Ensure file is closed before moving\n\n        // Verify downloaded file size matches expected size\n        if total_size > 0 {\n            let actual_size = partial_path.metadata()?.len();\n            if actual_size != total_size {\n                // Download is incomplete/corrupted - delete partial and return error\n                let _ = fs::remove_file(&partial_path);\n                {\n                    let mut models = self.available_models.lock().unwrap();\n                    if let Some(model) = models.get_mut(model_id) {\n                        model.is_downloading = false;\n                    }\n                }\n                return Err(anyhow::anyhow!(\n                    \"Download incomplete: expected {} bytes, got {} bytes\",\n                    total_size,\n                    actual_size\n                ));\n            }\n        }\n\n        // Handle directory-based models (extract tar.gz) vs file-based models\n        if model_info.is_directory {\n            // Track that this model is being extracted\n            {\n                let mut extracting = self.extracting_models.lock().unwrap();\n                extracting.insert(model_id.to_string());\n            }\n\n            // Emit extraction started event\n            let _ = self.app_handle.emit(\"model-extraction-started\", model_id);\n            info!(\"Extracting archive for directory-based model: {}\", model_id);\n\n            // Use a temporary extraction directory to ensure atomic operations\n            let temp_extract_dir = self\n                .models_dir\n                .join(format!(\"{}.extracting\", &model_info.filename));\n            let final_model_dir = self.models_dir.join(&model_info.filename);\n\n            // Clean up any previous incomplete extraction\n            if temp_extract_dir.exists() {\n                let _ = fs::remove_dir_all(&temp_extract_dir);\n            }\n\n            // Create temporary extraction directory\n            fs::create_dir_all(&temp_extract_dir)?;\n\n            // Open the downloaded tar.gz file\n            let tar_gz = File::open(&partial_path)?;\n            let tar = GzDecoder::new(tar_gz);\n            let mut archive = Archive::new(tar);\n\n            // Extract to the temporary directory first\n            archive.unpack(&temp_extract_dir).map_err(|e| {\n                let error_msg = format!(\"Failed to extract archive: {}\", e);\n                // Clean up failed extraction\n                let _ = fs::remove_dir_all(&temp_extract_dir);\n                // Remove from extracting set\n                {\n                    let mut extracting = self.extracting_models.lock().unwrap();\n                    extracting.remove(model_id);\n                }\n                let _ = self.app_handle.emit(\n                    \"model-extraction-failed\",\n                    &serde_json::json!({\n                        \"model_id\": model_id,\n                        \"error\": error_msg\n                    }),\n                );\n                anyhow::anyhow!(error_msg)\n            })?;\n\n            // Find the actual extracted directory (archive might have a nested structure)\n            let extracted_dirs: Vec<_> = fs::read_dir(&temp_extract_dir)?\n                .filter_map(|entry| entry.ok())\n                .filter(|entry| entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false))\n                .collect();\n\n            if extracted_dirs.len() == 1 {\n                // Single directory extracted, move it to the final location\n                let source_dir = extracted_dirs[0].path();\n                if final_model_dir.exists() {\n                    fs::remove_dir_all(&final_model_dir)?;\n                }\n                fs::rename(&source_dir, &final_model_dir)?;\n                // Clean up temp directory\n                let _ = fs::remove_dir_all(&temp_extract_dir);\n            } else {\n                // Multiple items or no directories, rename the temp directory itself\n                if final_model_dir.exists() {\n                    fs::remove_dir_all(&final_model_dir)?;\n                }\n                fs::rename(&temp_extract_dir, &final_model_dir)?;\n            }\n\n            info!(\"Successfully extracted archive for model: {}\", model_id);\n            // Remove from extracting set\n            {\n                let mut extracting = self.extracting_models.lock().unwrap();\n                extracting.remove(model_id);\n            }\n            // Emit extraction completed event\n            let _ = self.app_handle.emit(\"model-extraction-completed\", model_id);\n\n            // Remove the downloaded tar.gz file\n            let _ = fs::remove_file(&partial_path);\n        } else {\n            // Move partial file to final location for file-based models\n            fs::rename(&partial_path, &model_path)?;\n        }\n\n        // Update download status\n        {\n            let mut models = self.available_models.lock().unwrap();\n            if let Some(model) = models.get_mut(model_id) {\n                model.is_downloading = false;\n                model.is_downloaded = true;\n                model.partial_size = 0;\n            }\n        }\n\n        // Remove cancel flag on successful completion\n        {\n            let mut flags = self.cancel_flags.lock().unwrap();\n            flags.remove(model_id);\n        }\n\n        // Emit completion event\n        let _ = self.app_handle.emit(\"model-download-complete\", model_id);\n\n        info!(\n            \"Successfully downloaded model {} to {:?}\",\n            model_id, model_path\n        );\n\n        Ok(())\n    }\n\n    pub fn delete_model(&self, model_id: &str) -> Result<()> {\n        debug!(\"ModelManager: delete_model called for: {}\", model_id);\n\n        let model_info = {\n            let models = self.available_models.lock().unwrap();\n            models.get(model_id).cloned()\n        };\n\n        let model_info =\n            model_info.ok_or_else(|| anyhow::anyhow!(\"Model not found: {}\", model_id))?;\n\n        debug!(\"ModelManager: Found model info: {:?}\", model_info);\n\n        let model_path = self.models_dir.join(&model_info.filename);\n        let partial_path = self\n            .models_dir\n            .join(format!(\"{}.partial\", &model_info.filename));\n        debug!(\"ModelManager: Model path: {:?}\", model_path);\n        debug!(\"ModelManager: Partial path: {:?}\", partial_path);\n\n        let mut deleted_something = false;\n\n        if model_info.is_directory {\n            // Delete complete model directory if it exists\n            if model_path.exists() && model_path.is_dir() {\n                info!(\"Deleting model directory at: {:?}\", model_path);\n                fs::remove_dir_all(&model_path)?;\n                info!(\"Model directory deleted successfully\");\n                deleted_something = true;\n            }\n        } else {\n            // Delete complete model file if it exists\n            if model_path.exists() {\n                info!(\"Deleting model file at: {:?}\", model_path);\n                fs::remove_file(&model_path)?;\n                info!(\"Model file deleted successfully\");\n                deleted_something = true;\n            }\n        }\n\n        // Delete partial file if it exists (same for both types)\n        if partial_path.exists() {\n            info!(\"Deleting partial file at: {:?}\", partial_path);\n            fs::remove_file(&partial_path)?;\n            info!(\"Partial file deleted successfully\");\n            deleted_something = true;\n        }\n\n        if !deleted_something {\n            return Err(anyhow::anyhow!(\"No model files found to delete\"));\n        }\n\n        // Custom models should be removed from the list entirely since they\n        // have no download URL and can't be re-downloaded\n        if model_info.is_custom {\n            let mut models = self.available_models.lock().unwrap();\n            models.remove(model_id);\n            debug!(\"ModelManager: removed custom model from available models\");\n        } else {\n            // Update download status (marks predefined models as not downloaded)\n            self.update_download_status()?;\n            debug!(\"ModelManager: download status updated\");\n        }\n\n        // Emit event to notify UI\n        let _ = self.app_handle.emit(\"model-deleted\", model_id);\n\n        Ok(())\n    }\n\n    pub fn get_model_path(&self, model_id: &str) -> Result<PathBuf> {\n        let model_info = self\n            .get_model_info(model_id)\n            .ok_or_else(|| anyhow::anyhow!(\"Model not found: {}\", model_id))?;\n\n        if !model_info.is_downloaded {\n            return Err(anyhow::anyhow!(\"Model not available: {}\", model_id));\n        }\n\n        // Ensure we don't return partial files/directories\n        if model_info.is_downloading {\n            return Err(anyhow::anyhow!(\n                \"Model is currently downloading: {}\",\n                model_id\n            ));\n        }\n\n        let model_path = self.models_dir.join(&model_info.filename);\n        let partial_path = self\n            .models_dir\n            .join(format!(\"{}.partial\", &model_info.filename));\n\n        if model_info.is_directory {\n            // For directory-based models, ensure the directory exists and is complete\n            if model_path.exists() && model_path.is_dir() && !partial_path.exists() {\n                Ok(model_path)\n            } else {\n                Err(anyhow::anyhow!(\n                    \"Complete model directory not found: {}\",\n                    model_id\n                ))\n            }\n        } else {\n            // For file-based models (existing logic)\n            if model_path.exists() && !partial_path.exists() {\n                Ok(model_path)\n            } else {\n                Err(anyhow::anyhow!(\n                    \"Complete model file not found: {}\",\n                    model_id\n                ))\n            }\n        }\n    }\n\n    pub fn cancel_download(&self, model_id: &str) -> Result<()> {\n        debug!(\"ModelManager: cancel_download called for: {}\", model_id);\n\n        // Set the cancellation flag to stop the download loop\n        {\n            let flags = self.cancel_flags.lock().unwrap();\n            if let Some(flag) = flags.get(model_id) {\n                flag.store(true, Ordering::Relaxed);\n                info!(\"Cancellation flag set for: {}\", model_id);\n            } else {\n                warn!(\"No active download found for: {}\", model_id);\n            }\n        }\n\n        // Update state immediately for UI responsiveness\n        {\n            let mut models = self.available_models.lock().unwrap();\n            if let Some(model) = models.get_mut(model_id) {\n                model.is_downloading = false;\n            }\n        }\n\n        // Update download status to reflect current state\n        self.update_download_status()?;\n\n        // Emit cancellation event so all UI components can clear their state\n        let _ = self.app_handle.emit(\"model-download-cancelled\", model_id);\n\n        info!(\"Download cancellation initiated for: {}\", model_id);\n        Ok(())\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n    use std::io::Write;\n    use tempfile::TempDir;\n\n    #[test]\n    fn test_discover_custom_whisper_models() {\n        let temp_dir = TempDir::new().unwrap();\n        let models_dir = temp_dir.path().to_path_buf();\n\n        // Create test .bin files\n        let mut custom_file = File::create(models_dir.join(\"my-custom-model.bin\")).unwrap();\n        custom_file.write_all(b\"fake model data\").unwrap();\n\n        let mut another_file = File::create(models_dir.join(\"whisper_medical_v2.bin\")).unwrap();\n        another_file.write_all(b\"another fake model\").unwrap();\n\n        // Create files that should be ignored\n        File::create(models_dir.join(\".hidden-model.bin\")).unwrap(); // Hidden file\n        File::create(models_dir.join(\"readme.txt\")).unwrap(); // Non-.bin file\n        File::create(models_dir.join(\"ggml-small.bin\")).unwrap(); // Predefined filename\n        fs::create_dir(models_dir.join(\"some-directory.bin\")).unwrap(); // Directory\n\n        // Set up available_models with a predefined Whisper model\n        let mut models = HashMap::new();\n        models.insert(\n            \"small\".to_string(),\n            ModelInfo {\n                id: \"small\".to_string(),\n                name: \"Whisper Small\".to_string(),\n                description: \"Test\".to_string(),\n                filename: \"ggml-small.bin\".to_string(),\n                url: Some(\"https://example.com\".to_string()),\n                size_mb: 100,\n                is_downloaded: false,\n                is_downloading: false,\n                partial_size: 0,\n                is_directory: false,\n                engine_type: EngineType::Whisper,\n                accuracy_score: 0.5,\n                speed_score: 0.5,\n                supports_translation: true,\n                is_recommended: false,\n                supported_languages: vec![\"en\".to_string()],\n                supports_language_selection: true,\n                is_custom: false,\n            },\n        );\n\n        // Discover custom models\n        ModelManager::discover_custom_whisper_models(&models_dir, &mut models).unwrap();\n\n        // Should have discovered 2 custom models (my-custom-model and whisper_medical_v2)\n        assert!(models.contains_key(\"my-custom-model\"));\n        assert!(models.contains_key(\"whisper_medical_v2\"));\n\n        // Verify custom model properties\n        let custom = models.get(\"my-custom-model\").unwrap();\n        assert_eq!(custom.name, \"My Custom Model\");\n        assert_eq!(custom.filename, \"my-custom-model.bin\");\n        assert!(custom.url.is_none()); // Custom models have no URL\n        assert!(custom.is_downloaded);\n        assert!(custom.is_custom);\n        assert_eq!(custom.accuracy_score, 0.0);\n        assert_eq!(custom.speed_score, 0.0);\n        assert!(custom.supported_languages.is_empty());\n\n        // Verify underscore handling\n        let medical = models.get(\"whisper_medical_v2\").unwrap();\n        assert_eq!(medical.name, \"Whisper Medical V2\");\n\n        // Should NOT have discovered hidden, non-.bin, predefined, or directories\n        assert!(!models.contains_key(\".hidden-model\"));\n        assert!(!models.contains_key(\"readme\"));\n        assert!(!models.contains_key(\"some-directory\"));\n    }\n\n    #[test]\n    fn test_discover_custom_models_empty_dir() {\n        let temp_dir = TempDir::new().unwrap();\n        let models_dir = temp_dir.path().to_path_buf();\n\n        let mut models = HashMap::new();\n        let count_before = models.len();\n\n        ModelManager::discover_custom_whisper_models(&models_dir, &mut models).unwrap();\n\n        // No new models should be added\n        assert_eq!(models.len(), count_before);\n    }\n\n    #[test]\n    fn test_discover_custom_models_nonexistent_dir() {\n        let models_dir = PathBuf::from(\"/nonexistent/path/that/does/not/exist\");\n\n        let mut models = HashMap::new();\n        let count_before = models.len();\n\n        // Should not error, just return Ok\n        let result = ModelManager::discover_custom_whisper_models(&models_dir, &mut models);\n        assert!(result.is_ok());\n        assert_eq!(models.len(), count_before);\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/managers/transcription.rs",
    "content": "use crate::audio_toolkit::{apply_custom_words, filter_transcription_output};\nuse crate::managers::audio::AudioRecordingManager;\nuse crate::managers::model::{EngineType, ModelManager};\nuse crate::settings::{\n    get_settings, ModelUnloadTimeout, OrtAcceleratorSetting, WhisperAcceleratorSetting,\n};\nuse anyhow::Result;\nuse log::{debug, error, info, warn};\nuse serde::Serialize;\nuse specta::Type;\nuse std::panic::{catch_unwind, AssertUnwindSafe};\nuse std::sync::atomic::{AtomicBool, AtomicU64, Ordering};\nuse std::sync::{Arc, Condvar, Mutex, MutexGuard};\nuse std::thread;\nuse std::time::{Duration, SystemTime};\nuse tauri::{AppHandle, Emitter, Manager};\nuse transcribe_rs::{\n    onnx::{\n        canary::CanaryModel,\n        gigaam::GigaAMModel,\n        moonshine::{MoonshineModel, MoonshineVariant, StreamingModel},\n        parakeet::{ParakeetModel, ParakeetParams, TimestampGranularity},\n        sense_voice::{SenseVoiceModel, SenseVoiceParams},\n        Quantization,\n    },\n    whisper_cpp::{WhisperEngine, WhisperInferenceParams},\n    SpeechModel, TranscribeOptions,\n};\n\n#[derive(Clone, Debug, Serialize)]\npub struct ModelStateEvent {\n    pub event_type: String,\n    pub model_id: Option<String>,\n    pub model_name: Option<String>,\n    pub error: Option<String>,\n}\n\nenum LoadedEngine {\n    Whisper(WhisperEngine),\n    Parakeet(ParakeetModel),\n    Moonshine(MoonshineModel),\n    MoonshineStreaming(StreamingModel),\n    SenseVoice(SenseVoiceModel),\n    GigaAM(GigaAMModel),\n    Canary(CanaryModel),\n}\n\n/// RAII guard that clears the `is_loading` flag and notifies waiters on drop.\n/// Ensures the loading flag is always reset, even on early returns or panics.\npub struct LoadingGuard {\n    is_loading: Arc<Mutex<bool>>,\n    loading_condvar: Arc<Condvar>,\n}\n\nimpl Drop for LoadingGuard {\n    fn drop(&mut self) {\n        let mut is_loading = self.is_loading.lock().unwrap();\n        *is_loading = false;\n        self.loading_condvar.notify_all();\n    }\n}\n\n#[derive(Clone)]\npub struct TranscriptionManager {\n    engine: Arc<Mutex<Option<LoadedEngine>>>,\n    model_manager: Arc<ModelManager>,\n    app_handle: AppHandle,\n    current_model_id: Arc<Mutex<Option<String>>>,\n    last_activity: Arc<AtomicU64>,\n    shutdown_signal: Arc<AtomicBool>,\n    watcher_handle: Arc<Mutex<Option<thread::JoinHandle<()>>>>,\n    is_loading: Arc<Mutex<bool>>,\n    loading_condvar: Arc<Condvar>,\n}\n\nimpl TranscriptionManager {\n    pub fn new(app_handle: &AppHandle, model_manager: Arc<ModelManager>) -> Result<Self> {\n        let manager = Self {\n            engine: Arc::new(Mutex::new(None)),\n            model_manager,\n            app_handle: app_handle.clone(),\n            current_model_id: Arc::new(Mutex::new(None)),\n            last_activity: Arc::new(AtomicU64::new(Self::now_ms())),\n            shutdown_signal: Arc::new(AtomicBool::new(false)),\n            watcher_handle: Arc::new(Mutex::new(None)),\n            is_loading: Arc::new(Mutex::new(false)),\n            loading_condvar: Arc::new(Condvar::new()),\n        };\n\n        // Start the idle watcher\n        {\n            let app_handle_cloned = app_handle.clone();\n            let manager_cloned = manager.clone();\n            let shutdown_signal = manager.shutdown_signal.clone();\n            let handle = thread::spawn(move || {\n                debug!(\"Idle watcher thread started\");\n                while !shutdown_signal.load(Ordering::Relaxed) {\n                    thread::sleep(Duration::from_secs(10)); // Check every 10 seconds\n\n                    // Check shutdown signal again after sleep\n                    if shutdown_signal.load(Ordering::Relaxed) {\n                        break;\n                    }\n\n                    let settings = get_settings(&app_handle_cloned);\n                    let timeout = settings.model_unload_timeout;\n\n                    // Skip Immediately — that variant is handled by\n                    // maybe_unload_immediately() after each transcription.\n                    // Treating it as 0s here would unload the model mid-recording.\n                    if timeout == ModelUnloadTimeout::Immediately {\n                        continue;\n                    }\n\n                    // While recording, keep the idle timer fresh so the\n                    // model is never unloaded mid-session.\n                    let is_recording = app_handle_cloned\n                        .try_state::<Arc<AudioRecordingManager>>()\n                        .map_or(false, |a| a.is_recording());\n                    if is_recording {\n                        manager_cloned.touch_activity();\n                        continue;\n                    }\n\n                    if let Some(limit_seconds) = timeout.to_seconds() {\n                        let last = manager_cloned.last_activity.load(Ordering::Relaxed);\n                        let now_ms = TranscriptionManager::now_ms();\n                        let idle_ms = now_ms.saturating_sub(last);\n                        let limit_ms = limit_seconds * 1000;\n\n                        if idle_ms > limit_ms {\n                            // idle -> unload\n                            if manager_cloned.is_model_loaded() {\n                                let unload_start = std::time::Instant::now();\n                                info!(\n                                    \"Model idle for {}s (limit: {}s), unloading\",\n                                    idle_ms / 1000,\n                                    limit_seconds\n                                );\n                                match manager_cloned.unload_model() {\n                                    Ok(()) => {\n                                        let unload_duration = unload_start.elapsed();\n                                        info!(\n                                            \"Model unloaded due to inactivity (took {}ms)\",\n                                            unload_duration.as_millis()\n                                        );\n                                    }\n                                    Err(e) => {\n                                        error!(\"Failed to unload idle model: {}\", e);\n                                    }\n                                }\n                            }\n                        }\n                    }\n                }\n                debug!(\"Idle watcher thread shutting down gracefully\");\n            });\n            *manager.watcher_handle.lock().unwrap() = Some(handle);\n        }\n\n        Ok(manager)\n    }\n\n    /// Lock the engine mutex, recovering from poison if a previous transcription panicked.\n    fn lock_engine(&self) -> MutexGuard<'_, Option<LoadedEngine>> {\n        self.engine.lock().unwrap_or_else(|poisoned| {\n            warn!(\"Engine mutex was poisoned by a previous panic, recovering\");\n            poisoned.into_inner()\n        })\n    }\n\n    pub fn is_model_loaded(&self) -> bool {\n        let engine = self.lock_engine();\n        engine.is_some()\n    }\n\n    /// Atomically check whether a model load is in progress and, if not, mark\n    /// one as starting. Returns a [`LoadingGuard`] whose [`Drop`] impl will\n    /// clear the flag and wake waiters. Returns `None` if a load is already in\n    /// progress.\n    pub fn try_start_loading(&self) -> Option<LoadingGuard> {\n        let mut is_loading = self.is_loading.lock().unwrap();\n        if *is_loading {\n            return None;\n        }\n        *is_loading = true;\n        Some(LoadingGuard {\n            is_loading: self.is_loading.clone(),\n            loading_condvar: self.loading_condvar.clone(),\n        })\n    }\n\n    pub fn unload_model(&self) -> Result<()> {\n        let unload_start = std::time::Instant::now();\n        debug!(\"Starting to unload model\");\n\n        {\n            let mut engine = self.lock_engine();\n            // Dropping the engine frees all resources\n            *engine = None;\n        }\n        {\n            let mut current_model = self.current_model_id.lock().unwrap();\n            *current_model = None;\n        }\n\n        // Emit unloaded event\n        let _ = self.app_handle.emit(\n            \"model-state-changed\",\n            ModelStateEvent {\n                event_type: \"unloaded\".to_string(),\n                model_id: None,\n                model_name: None,\n                error: None,\n            },\n        );\n\n        let unload_duration = unload_start.elapsed();\n        debug!(\n            \"Model unloaded manually (took {}ms)\",\n            unload_duration.as_millis()\n        );\n        Ok(())\n    }\n\n    fn now_ms() -> u64 {\n        SystemTime::now()\n            .duration_since(SystemTime::UNIX_EPOCH)\n            .unwrap()\n            .as_millis() as u64\n    }\n\n    /// Reset the idle timer to now.\n    fn touch_activity(&self) {\n        self.last_activity.store(Self::now_ms(), Ordering::Relaxed);\n    }\n\n    /// Unloads the model immediately if the setting is enabled and the model is loaded\n    pub fn maybe_unload_immediately(&self, context: &str) {\n        let settings = get_settings(&self.app_handle);\n        if settings.model_unload_timeout == ModelUnloadTimeout::Immediately\n            && self.is_model_loaded()\n        {\n            info!(\"Immediately unloading model after {}\", context);\n            if let Err(e) = self.unload_model() {\n                warn!(\"Failed to immediately unload model: {}\", e);\n            }\n        }\n    }\n\n    pub fn load_model(&self, model_id: &str) -> Result<()> {\n        let load_start = std::time::Instant::now();\n        debug!(\"Starting to load model: {}\", model_id);\n\n        // Emit loading started event\n        let _ = self.app_handle.emit(\n            \"model-state-changed\",\n            ModelStateEvent {\n                event_type: \"loading_started\".to_string(),\n                model_id: Some(model_id.to_string()),\n                model_name: None,\n                error: None,\n            },\n        );\n\n        let model_info = self\n            .model_manager\n            .get_model_info(model_id)\n            .ok_or_else(|| anyhow::anyhow!(\"Model not found: {}\", model_id))?;\n\n        if !model_info.is_downloaded {\n            let error_msg = \"Model not downloaded\";\n            let _ = self.app_handle.emit(\n                \"model-state-changed\",\n                ModelStateEvent {\n                    event_type: \"loading_failed\".to_string(),\n                    model_id: Some(model_id.to_string()),\n                    model_name: Some(model_info.name.clone()),\n                    error: Some(error_msg.to_string()),\n                },\n            );\n            return Err(anyhow::anyhow!(error_msg));\n        }\n\n        let model_path = self.model_manager.get_model_path(model_id)?;\n\n        // Create appropriate engine based on model type\n        let emit_loading_failed = |error_msg: &str| {\n            let _ = self.app_handle.emit(\n                \"model-state-changed\",\n                ModelStateEvent {\n                    event_type: \"loading_failed\".to_string(),\n                    model_id: Some(model_id.to_string()),\n                    model_name: Some(model_info.name.clone()),\n                    error: Some(error_msg.to_string()),\n                },\n            );\n        };\n\n        let loaded_engine = match model_info.engine_type {\n            EngineType::Whisper => {\n                let engine = WhisperEngine::load(&model_path).map_err(|e| {\n                    let error_msg = format!(\"Failed to load whisper model {}: {}\", model_id, e);\n                    emit_loading_failed(&error_msg);\n                    anyhow::anyhow!(error_msg)\n                })?;\n                LoadedEngine::Whisper(engine)\n            }\n            EngineType::Parakeet => {\n                let engine =\n                    ParakeetModel::load(&model_path, &Quantization::Int8).map_err(|e| {\n                        let error_msg =\n                            format!(\"Failed to load parakeet model {}: {}\", model_id, e);\n                        emit_loading_failed(&error_msg);\n                        anyhow::anyhow!(error_msg)\n                    })?;\n                LoadedEngine::Parakeet(engine)\n            }\n            EngineType::Moonshine => {\n                let engine = MoonshineModel::load(\n                    &model_path,\n                    MoonshineVariant::Base,\n                    &Quantization::default(),\n                )\n                .map_err(|e| {\n                    let error_msg = format!(\"Failed to load moonshine model {}: {}\", model_id, e);\n                    emit_loading_failed(&error_msg);\n                    anyhow::anyhow!(error_msg)\n                })?;\n                LoadedEngine::Moonshine(engine)\n            }\n            EngineType::MoonshineStreaming => {\n                let engine = StreamingModel::load(&model_path, 0, &Quantization::default())\n                    .map_err(|e| {\n                        let error_msg = format!(\n                            \"Failed to load moonshine streaming model {}: {}\",\n                            model_id, e\n                        );\n                        emit_loading_failed(&error_msg);\n                        anyhow::anyhow!(error_msg)\n                    })?;\n                LoadedEngine::MoonshineStreaming(engine)\n            }\n            EngineType::SenseVoice => {\n                let engine =\n                    SenseVoiceModel::load(&model_path, &Quantization::Int8).map_err(|e| {\n                        let error_msg =\n                            format!(\"Failed to load SenseVoice model {}: {}\", model_id, e);\n                        emit_loading_failed(&error_msg);\n                        anyhow::anyhow!(error_msg)\n                    })?;\n                LoadedEngine::SenseVoice(engine)\n            }\n            EngineType::GigaAM => {\n                let engine = GigaAMModel::load(&model_path, &Quantization::Int8).map_err(|e| {\n                    let error_msg = format!(\"Failed to load gigaam model {}: {}\", model_id, e);\n                    emit_loading_failed(&error_msg);\n                    anyhow::anyhow!(error_msg)\n                })?;\n                LoadedEngine::GigaAM(engine)\n            }\n            EngineType::Canary => {\n                let engine = CanaryModel::load(&model_path, &Quantization::Int8).map_err(|e| {\n                    let error_msg = format!(\"Failed to load canary model {}: {}\", model_id, e);\n                    emit_loading_failed(&error_msg);\n                    anyhow::anyhow!(error_msg)\n                })?;\n                LoadedEngine::Canary(engine)\n            }\n        };\n\n        // Update the current engine and model ID\n        {\n            let mut engine = self.lock_engine();\n            *engine = Some(loaded_engine);\n        }\n        {\n            let mut current_model = self.current_model_id.lock().unwrap();\n            *current_model = Some(model_id.to_string());\n        }\n\n        // Reset idle timer so the watcher doesn't immediately unload a just-loaded model\n        self.touch_activity();\n\n        // Emit loading completed event\n        let _ = self.app_handle.emit(\n            \"model-state-changed\",\n            ModelStateEvent {\n                event_type: \"loading_completed\".to_string(),\n                model_id: Some(model_id.to_string()),\n                model_name: Some(model_info.name.clone()),\n                error: None,\n            },\n        );\n\n        let load_duration = load_start.elapsed();\n        debug!(\n            \"Successfully loaded transcription model: {} (took {}ms)\",\n            model_id,\n            load_duration.as_millis()\n        );\n        Ok(())\n    }\n\n    /// Kicks off the model loading in a background thread if it's not already loaded\n    pub fn initiate_model_load(&self) {\n        let mut is_loading = self.is_loading.lock().unwrap();\n        if *is_loading || self.is_model_loaded() {\n            return;\n        }\n\n        *is_loading = true;\n        let self_clone = self.clone();\n        thread::spawn(move || {\n            let settings = get_settings(&self_clone.app_handle);\n            if let Err(e) = self_clone.load_model(&settings.selected_model) {\n                error!(\"Failed to load model: {}\", e);\n            }\n            let mut is_loading = self_clone.is_loading.lock().unwrap();\n            *is_loading = false;\n            self_clone.loading_condvar.notify_all();\n        });\n    }\n\n    pub fn get_current_model(&self) -> Option<String> {\n        let current_model = self.current_model_id.lock().unwrap();\n        current_model.clone()\n    }\n\n    pub fn transcribe(&self, audio: Vec<f32>) -> Result<String> {\n        // Update last activity timestamp\n        self.touch_activity();\n\n        let st = std::time::Instant::now();\n\n        debug!(\"Audio vector length: {}\", audio.len());\n\n        if audio.is_empty() {\n            debug!(\"Empty audio vector\");\n            self.maybe_unload_immediately(\"empty audio\");\n            return Ok(String::new());\n        }\n\n        // Check if model is loaded, if not try to load it\n        {\n            // If the model is loading, wait for it to complete.\n            let mut is_loading = self.is_loading.lock().unwrap();\n            while *is_loading {\n                is_loading = self.loading_condvar.wait(is_loading).unwrap();\n            }\n\n            let engine_guard = self.lock_engine();\n            if engine_guard.is_none() {\n                return Err(anyhow::anyhow!(\"Model is not loaded for transcription.\"));\n            }\n        }\n\n        // Get current settings for configuration\n        let settings = get_settings(&self.app_handle);\n\n        // Validate selected language against the model's supported languages.\n        // If the language isn't supported, fall back to \"auto\" to prevent errors.\n        let validated_language = if settings.selected_language == \"auto\" {\n            \"auto\".to_string()\n        } else {\n            let is_supported = self\n                .model_manager\n                .get_model_info(&settings.selected_model)\n                .map(|info| {\n                    info.supported_languages.is_empty()\n                        || info\n                            .supported_languages\n                            .contains(&settings.selected_language)\n                })\n                .unwrap_or(true);\n\n            if is_supported {\n                settings.selected_language.clone()\n            } else {\n                warn!(\n                    \"Language '{}' not supported by current model, falling back to auto-detect\",\n                    settings.selected_language\n                );\n                \"auto\".to_string()\n            }\n        };\n\n        // Perform transcription with the appropriate engine.\n        // We use catch_unwind to prevent engine panics from poisoning the mutex,\n        // which would make the app hang indefinitely on subsequent operations.\n        let result = {\n            let mut engine_guard = self.lock_engine();\n\n            // Take the engine out so we own it during transcription.\n            // If the engine panics, we simply don't put it back (effectively unloading it)\n            // instead of poisoning the mutex.\n            let mut engine = match engine_guard.take() {\n                Some(e) => e,\n                None => {\n                    return Err(anyhow::anyhow!(\n                        \"Model failed to load after auto-load attempt. Please check your model settings.\"\n                    ));\n                }\n            };\n\n            // Release the lock before transcribing — no mutex held during the engine call\n            drop(engine_guard);\n\n            let transcribe_result = catch_unwind(AssertUnwindSafe(\n                || -> Result<transcribe_rs::TranscriptionResult> {\n                    match &mut engine {\n                        LoadedEngine::Whisper(whisper_engine) => {\n                            let whisper_language = if validated_language == \"auto\" {\n                                None\n                            } else {\n                                let normalized = if validated_language == \"zh-Hans\"\n                                    || validated_language == \"zh-Hant\"\n                                {\n                                    \"zh\".to_string()\n                                } else {\n                                    validated_language.clone()\n                                };\n                                Some(normalized)\n                            };\n\n                            let params = WhisperInferenceParams {\n                                language: whisper_language,\n                                translate: settings.translate_to_english,\n                                initial_prompt: if settings.custom_words.is_empty() {\n                                    None\n                                } else {\n                                    Some(settings.custom_words.join(\", \"))\n                                },\n                                ..Default::default()\n                            };\n\n                            whisper_engine\n                                .transcribe_with(&audio, &params)\n                                .map_err(|e| anyhow::anyhow!(\"Whisper transcription failed: {}\", e))\n                        }\n                        LoadedEngine::Parakeet(parakeet_engine) => {\n                            let params = ParakeetParams {\n                                timestamp_granularity: Some(TimestampGranularity::Segment),\n                                ..Default::default()\n                            };\n                            parakeet_engine\n                                .transcribe_with(&audio, &params)\n                                .map_err(|e| {\n                                    anyhow::anyhow!(\"Parakeet transcription failed: {}\", e)\n                                })\n                        }\n                        LoadedEngine::Moonshine(moonshine_engine) => moonshine_engine\n                            .transcribe(&audio, &TranscribeOptions::default())\n                            .map_err(|e| anyhow::anyhow!(\"Moonshine transcription failed: {}\", e)),\n                        LoadedEngine::MoonshineStreaming(streaming_engine) => streaming_engine\n                            .transcribe(&audio, &TranscribeOptions::default())\n                            .map_err(|e| {\n                                anyhow::anyhow!(\"Moonshine streaming transcription failed: {}\", e)\n                            }),\n                        LoadedEngine::SenseVoice(sense_voice_engine) => {\n                            let language = match validated_language.as_str() {\n                                \"zh\" | \"zh-Hans\" | \"zh-Hant\" => Some(\"zh\".to_string()),\n                                \"en\" => Some(\"en\".to_string()),\n                                \"ja\" => Some(\"ja\".to_string()),\n                                \"ko\" => Some(\"ko\".to_string()),\n                                \"yue\" => Some(\"yue\".to_string()),\n                                _ => None,\n                            };\n                            let params = SenseVoiceParams {\n                                language,\n                                use_itn: Some(true),\n                            };\n                            sense_voice_engine\n                                .transcribe_with(&audio, &params)\n                                .map_err(|e| {\n                                    anyhow::anyhow!(\"SenseVoice transcription failed: {}\", e)\n                                })\n                        }\n                        LoadedEngine::GigaAM(gigaam_engine) => gigaam_engine\n                            .transcribe(&audio, &TranscribeOptions::default())\n                            .map_err(|e| anyhow::anyhow!(\"GigaAM transcription failed: {}\", e)),\n                        LoadedEngine::Canary(canary_engine) => {\n                            let lang = if validated_language == \"auto\" {\n                                None\n                            } else {\n                                Some(validated_language.clone())\n                            };\n                            let options = TranscribeOptions {\n                                language: lang,\n                                translate: settings.translate_to_english,\n                            };\n                            canary_engine\n                                .transcribe(&audio, &options)\n                                .map_err(|e| anyhow::anyhow!(\"Canary transcription failed: {}\", e))\n                        }\n                    }\n                },\n            ));\n\n            match transcribe_result {\n                Ok(inner_result) => {\n                    // Success or normal error — put the engine back\n                    let mut engine_guard = self.lock_engine();\n                    *engine_guard = Some(engine);\n                    inner_result?\n                }\n                Err(panic_payload) => {\n                    // Engine panicked — do NOT put it back (it's in an unknown state).\n                    // The engine is dropped here, effectively unloading it.\n                    let panic_msg = if let Some(s) = panic_payload.downcast_ref::<&str>() {\n                        s.to_string()\n                    } else if let Some(s) = panic_payload.downcast_ref::<String>() {\n                        s.clone()\n                    } else {\n                        \"unknown panic\".to_string()\n                    };\n                    error!(\n                        \"Transcription engine panicked: {}. Model has been unloaded.\",\n                        panic_msg\n                    );\n\n                    // Clear the model ID so it will be reloaded on next attempt\n                    {\n                        let mut current_model = self\n                            .current_model_id\n                            .lock()\n                            .unwrap_or_else(|e| e.into_inner());\n                        *current_model = None;\n                    }\n\n                    let _ = self.app_handle.emit(\n                        \"model-state-changed\",\n                        ModelStateEvent {\n                            event_type: \"unloaded\".to_string(),\n                            model_id: None,\n                            model_name: None,\n                            error: Some(format!(\"Engine panicked: {}\", panic_msg)),\n                        },\n                    );\n\n                    return Err(anyhow::anyhow!(\n                        \"Transcription engine panicked: {}. The model has been unloaded and will reload on next attempt.\",\n                        panic_msg\n                    ));\n                }\n            }\n        };\n\n        // Apply word correction if custom words are configured.\n        // Skip for Whisper models since custom words are already passed as initial_prompt.\n        let is_whisper = self\n            .model_manager\n            .get_model_info(&settings.selected_model)\n            .map(|info| matches!(info.engine_type, EngineType::Whisper))\n            .unwrap_or(false);\n\n        let corrected_result = if !settings.custom_words.is_empty() && !is_whisper {\n            apply_custom_words(\n                &result.text,\n                &settings.custom_words,\n                settings.word_correction_threshold,\n            )\n        } else {\n            result.text\n        };\n\n        // Filter out filler words and hallucinations\n        let filtered_result = filter_transcription_output(\n            &corrected_result,\n            &settings.app_language,\n            &settings.custom_filler_words,\n        );\n\n        let et = std::time::Instant::now();\n        let translation_note = if settings.translate_to_english {\n            \" (translated)\"\n        } else {\n            \"\"\n        };\n        info!(\n            \"Transcription completed in {}ms{}\",\n            (et - st).as_millis(),\n            translation_note\n        );\n\n        let final_result = filtered_result;\n\n        if final_result.is_empty() {\n            info!(\"Transcription result is empty\");\n        } else {\n            info!(\"Transcription result: {}\", final_result);\n        }\n\n        self.maybe_unload_immediately(\"transcription\");\n\n        Ok(final_result)\n    }\n}\n\n/// Apply the user's accelerator preferences to the transcribe-rs global atomics.\n/// Called on startup and whenever the user changes the setting.\npub fn apply_accelerator_settings(app: &tauri::AppHandle) {\n    use transcribe_rs::accel;\n\n    let settings = get_settings(app);\n\n    let whisper_pref = match settings.whisper_accelerator {\n        WhisperAcceleratorSetting::Auto => accel::WhisperAccelerator::Auto,\n        WhisperAcceleratorSetting::Cpu => accel::WhisperAccelerator::CpuOnly,\n        WhisperAcceleratorSetting::Gpu => accel::WhisperAccelerator::Gpu,\n    };\n    accel::set_whisper_accelerator(whisper_pref);\n    info!(\"Whisper accelerator set to: {}\", whisper_pref);\n\n    let ort_pref = match settings.ort_accelerator {\n        OrtAcceleratorSetting::Auto => accel::OrtAccelerator::Auto,\n        OrtAcceleratorSetting::Cpu => accel::OrtAccelerator::CpuOnly,\n        OrtAcceleratorSetting::Cuda => accel::OrtAccelerator::Cuda,\n        OrtAcceleratorSetting::DirectMl => accel::OrtAccelerator::DirectMl,\n        OrtAcceleratorSetting::Rocm => accel::OrtAccelerator::Rocm,\n    };\n    accel::set_ort_accelerator(ort_pref);\n    info!(\"ORT accelerator set to: {}\", ort_pref);\n}\n\n#[derive(Serialize, Clone, Debug, Type)]\npub struct AvailableAccelerators {\n    pub whisper: Vec<String>,\n    pub ort: Vec<String>,\n}\n\n/// Return which accelerators are compiled into this build.\npub fn get_available_accelerators() -> AvailableAccelerators {\n    use transcribe_rs::accel::OrtAccelerator;\n\n    let ort_options: Vec<String> = OrtAccelerator::available()\n        .into_iter()\n        .map(|a| a.to_string())\n        .collect();\n\n    let whisper_options = vec![\"auto\".to_string(), \"cpu\".to_string(), \"gpu\".to_string()];\n\n    AvailableAccelerators {\n        whisper: whisper_options,\n        ort: ort_options,\n    }\n}\n\nimpl Drop for TranscriptionManager {\n    fn drop(&mut self) {\n        // Skip shutdown unless this is the very last clone. TranscriptionManager\n        // is cloned by initiate_model_load() and the watcher thread — those\n        // clones dropping must not kill the watcher. The watcher thread holds\n        // its own clone, so engine's strong_count is always >= 2 while the\n        // watcher is alive. When it reaches 1, only this instance remains\n        // and we can safely shut down.\n        if Arc::strong_count(&self.engine) > 1 {\n            return;\n        }\n\n        // Signal the watcher thread to shutdown\n        self.shutdown_signal.store(true, Ordering::Relaxed);\n\n        // Wait for the thread to finish gracefully\n        if let Some(handle) = self.watcher_handle.lock().unwrap().take() {\n            if let Err(e) = handle.join() {\n                warn!(\"Failed to join idle watcher thread: {:?}\", e);\n            } else {\n                debug!(\"Idle watcher thread joined successfully\");\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/managers/transcription_mock.rs",
    "content": "// CI-only mock TranscriptionManager - avoids whisper/Vulkan dependencies.\n// This file is copied over transcription.rs during CI tests.\n// Existing tests don't exercise transcription, so this is safe.\n\nuse crate::managers::model::ModelManager;\nuse anyhow::Result;\nuse serde::Serialize;\nuse specta::Type;\nuse std::sync::Arc;\nuse tauri::AppHandle;\n\n#[derive(Clone, Debug, Serialize)]\npub struct ModelStateEvent {\n    pub event_type: String,\n    pub model_id: Option<String>,\n    pub model_name: Option<String>,\n    pub error: Option<String>,\n}\n\n/// RAII guard that is a no-op in the mock — mirrors the real `LoadingGuard`.\npub struct LoadingGuard;\n\n#[derive(Clone)]\npub struct TranscriptionManager {\n    #[allow(dead_code)]\n    app_handle: AppHandle,\n}\n\nimpl TranscriptionManager {\n    pub fn new(app_handle: &AppHandle, _model_manager: Arc<ModelManager>) -> Result<Self> {\n        Ok(Self {\n            app_handle: app_handle.clone(),\n        })\n    }\n\n    pub fn is_model_loaded(&self) -> bool {\n        false\n    }\n\n    pub fn try_start_loading(&self) -> Option<LoadingGuard> {\n        Some(LoadingGuard)\n    }\n\n    pub fn unload_model(&self) -> Result<()> {\n        Ok(())\n    }\n\n    pub fn maybe_unload_immediately(&self, _context: &str) {}\n\n    pub fn load_model(&self, _model_id: &str) -> Result<()> {\n        Ok(())\n    }\n\n    pub fn initiate_model_load(&self) {}\n\n    pub fn get_current_model(&self) -> Option<String> {\n        None\n    }\n\n    pub fn transcribe(&self, _audio: Vec<f32>) -> Result<String> {\n        Ok(String::new())\n    }\n}\n\n/// No-op in CI mock.\npub fn apply_accelerator_settings(_app: &tauri::AppHandle) {}\n\n#[derive(Serialize, Clone, Debug, Type)]\npub struct AvailableAccelerators {\n    pub whisper: Vec<String>,\n    pub ort: Vec<String>,\n}\n\n/// Returns empty lists in CI mock.\npub fn get_available_accelerators() -> AvailableAccelerators {\n    AvailableAccelerators {\n        whisper: vec![],\n        ort: vec![],\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/overlay.rs",
    "content": "use crate::input;\nuse crate::settings;\nuse crate::settings::OverlayPosition;\nuse tauri::{AppHandle, Emitter, Manager, PhysicalPosition, PhysicalSize};\n\n#[cfg(not(target_os = \"macos\"))]\nuse log::debug;\n\n#[cfg(not(target_os = \"macos\"))]\nuse tauri::WebviewWindowBuilder;\n\n#[cfg(target_os = \"macos\")]\nuse tauri::WebviewUrl;\n\n#[cfg(target_os = \"macos\")]\nuse tauri_nspanel::{tauri_panel, CollectionBehavior, PanelBuilder, PanelLevel};\n\n#[cfg(target_os = \"linux\")]\nuse gtk_layer_shell::{Edge, KeyboardMode, Layer, LayerShell};\n#[cfg(target_os = \"linux\")]\nuse std::env;\n\n#[cfg(target_os = \"macos\")]\ntauri_panel! {\n    panel!(RecordingOverlayPanel {\n        config: {\n            can_become_key_window: false,\n            is_floating_panel: true\n        }\n    })\n}\n\nconst OVERLAY_WIDTH: f64 = 172.0;\nconst OVERLAY_HEIGHT: f64 = 36.0;\n\n#[cfg(target_os = \"macos\")]\nconst OVERLAY_TOP_OFFSET: f64 = 46.0;\n#[cfg(any(target_os = \"windows\", target_os = \"linux\"))]\nconst OVERLAY_TOP_OFFSET: f64 = 4.0;\n\n#[cfg(target_os = \"macos\")]\nconst OVERLAY_BOTTOM_OFFSET: f64 = 15.0;\n\n#[cfg(any(target_os = \"windows\", target_os = \"linux\"))]\nconst OVERLAY_BOTTOM_OFFSET: f64 = 40.0;\n\n#[cfg(target_os = \"linux\")]\nfn update_gtk_layer_shell_anchors(overlay_window: &tauri::webview::WebviewWindow) {\n    let window_clone = overlay_window.clone();\n    let _ = overlay_window.run_on_main_thread(move || {\n        // Try to get the GTK window from the Tauri webview\n        if let Ok(gtk_window) = window_clone.gtk_window() {\n            let settings = settings::get_settings(window_clone.app_handle());\n            match settings.overlay_position {\n                OverlayPosition::Top => {\n                    gtk_window.set_anchor(Edge::Top, true);\n                    gtk_window.set_anchor(Edge::Bottom, false);\n                }\n                OverlayPosition::Bottom | OverlayPosition::None => {\n                    gtk_window.set_anchor(Edge::Bottom, true);\n                    gtk_window.set_anchor(Edge::Top, false);\n                }\n            }\n        }\n    });\n}\n\n/// Initializes GTK layer shell for Linux overlay window\n/// Returns true if layer shell was successfully initialized, false otherwise\n#[cfg(target_os = \"linux\")]\nfn init_gtk_layer_shell(overlay_window: &tauri::webview::WebviewWindow) -> bool {\n    // On KDE Wayland, layer-shell init has shown protocol instability.\n    // Fall back to regular always-on-top overlay behavior (as in v0.7.1).\n    let is_wayland = env::var(\"WAYLAND_DISPLAY\").is_ok()\n        || env::var(\"XDG_SESSION_TYPE\")\n            .map(|v| v.eq_ignore_ascii_case(\"wayland\"))\n            .unwrap_or(false);\n    let is_kde = env::var(\"XDG_CURRENT_DESKTOP\")\n        .map(|v| v.to_uppercase().contains(\"KDE\"))\n        .unwrap_or(false)\n        || env::var(\"KDE_SESSION_VERSION\").is_ok();\n    if is_wayland && is_kde {\n        debug!(\"Skipping GTK layer shell init on KDE Wayland\");\n        return false;\n    }\n\n    if !gtk_layer_shell::is_supported() {\n        return false;\n    }\n\n    // Try to get the GTK window from the Tauri webview\n    if let Ok(gtk_window) = overlay_window.gtk_window() {\n        // Initialize layer shell\n        gtk_window.init_layer_shell();\n        gtk_window.set_layer(Layer::Overlay);\n        gtk_window.set_keyboard_mode(KeyboardMode::None);\n        gtk_window.set_exclusive_zone(0);\n\n        update_gtk_layer_shell_anchors(overlay_window);\n\n        return true;\n    }\n    false\n}\n\n/// Forces a window to be topmost using Win32 API (Windows only)\n/// This is more reliable than Tauri's set_always_on_top which can be overridden\n#[cfg(target_os = \"windows\")]\nfn force_overlay_topmost(overlay_window: &tauri::webview::WebviewWindow) {\n    use windows::Win32::UI::WindowsAndMessaging::{\n        SetWindowPos, HWND_TOPMOST, SWP_NOACTIVATE, SWP_NOMOVE, SWP_NOSIZE, SWP_SHOWWINDOW,\n    };\n\n    // Clone because run_on_main_thread takes 'static\n    let overlay_clone = overlay_window.clone();\n\n    // Make sure the Win32 call happens on the UI thread\n    let _ = overlay_clone.clone().run_on_main_thread(move || {\n        if let Ok(hwnd) = overlay_clone.hwnd() {\n            unsafe {\n                // Force Z-order: make this window topmost without changing size/pos or stealing focus\n                let _ = SetWindowPos(\n                    hwnd,\n                    Some(HWND_TOPMOST),\n                    0,\n                    0,\n                    0,\n                    0,\n                    SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE | SWP_SHOWWINDOW,\n                );\n            }\n        }\n    });\n}\n\nfn get_monitor_with_cursor(app_handle: &AppHandle) -> Option<tauri::Monitor> {\n    if let Some(mouse_location) = input::get_cursor_position(app_handle) {\n        if let Ok(monitors) = app_handle.available_monitors() {\n            for monitor in monitors {\n                // Tauri's monitor position/size are physical pixels, but enigo\n                // may return logical coordinates (confirmed on macOS via\n                // NSEvent::mouseLocation; on Windows, GetCursorPos behavior\n                // depends on the process DPI-awareness context). Dividing by\n                // scale_factor normalizes to logical, which is safe regardless:\n                // if enigo returns logical it matches directly, and if it returns\n                // physical on a scale=1 monitor the division is a no-op.\n                let scale = monitor.scale_factor();\n                let pos = PhysicalPosition::new(\n                    (monitor.position().x as f64 / scale) as i32,\n                    (monitor.position().y as f64 / scale) as i32,\n                );\n                let size = PhysicalSize::new(\n                    (monitor.size().width as f64 / scale) as u32,\n                    (monitor.size().height as f64 / scale) as u32,\n                );\n                if is_mouse_within_monitor(mouse_location, &pos, &size) {\n                    return Some(monitor);\n                }\n            }\n        }\n    }\n\n    app_handle.primary_monitor().ok().flatten()\n}\n\nfn is_mouse_within_monitor(\n    mouse_pos: (i32, i32),\n    monitor_pos: &PhysicalPosition<i32>,\n    monitor_size: &PhysicalSize<u32>,\n) -> bool {\n    let (mouse_x, mouse_y) = mouse_pos;\n    let PhysicalPosition {\n        x: monitor_x,\n        y: monitor_y,\n    } = *monitor_pos;\n    let PhysicalSize {\n        width: monitor_width,\n        height: monitor_height,\n    } = *monitor_size;\n\n    mouse_x >= monitor_x\n        && mouse_x < (monitor_x + monitor_width as i32)\n        && mouse_y >= monitor_y\n        && mouse_y < (monitor_y + monitor_height as i32)\n}\n\n/// Returns overlay position in logical coordinates (points on macOS).\n///\n/// Uses monitor position/size directly rather than work_area(), which can\n/// return incorrect coordinates on macOS for monitors with negative positions.\n/// The per-platform OVERLAY_TOP_OFFSET / OVERLAY_BOTTOM_OFFSET constants\n/// already account for system chrome (menu bar, taskbar).\n///\n/// We must use LogicalPosition (not PhysicalPosition) because Tauri/tao\n/// converts PhysicalPosition using the scale factor of the monitor the window\n/// is *currently* on, which is wrong when moving cross-monitor.\nfn calculate_overlay_position(app_handle: &AppHandle) -> Option<(f64, f64)> {\n    let monitor = get_monitor_with_cursor(app_handle)?;\n    let scale = monitor.scale_factor();\n    let monitor_x = monitor.position().x as f64 / scale;\n    let monitor_y = monitor.position().y as f64 / scale;\n    let monitor_width = monitor.size().width as f64 / scale;\n    let monitor_height = monitor.size().height as f64 / scale;\n\n    let settings = settings::get_settings(app_handle);\n\n    let x = monitor_x + (monitor_width - OVERLAY_WIDTH) / 2.0;\n    let y = match settings.overlay_position {\n        OverlayPosition::Top => monitor_y + OVERLAY_TOP_OFFSET,\n        OverlayPosition::Bottom | OverlayPosition::None => {\n            monitor_y + monitor_height - OVERLAY_HEIGHT - OVERLAY_BOTTOM_OFFSET\n        }\n    };\n\n    Some((x, y))\n}\n\n/// Creates the recording overlay window and keeps it hidden by default\n#[cfg(not(target_os = \"macos\"))]\npub fn create_recording_overlay(app_handle: &AppHandle) {\n    // On Linux (Wayland), monitor detection often fails, but we don't need exact coordinates\n    // for Layer Shell as we use anchors. On other platforms, we require a monitor.\n    #[cfg(not(target_os = \"linux\"))]\n    {\n        let position = calculate_overlay_position(app_handle);\n        if position.is_none() {\n            debug!(\"Failed to determine overlay position, not creating overlay window\");\n            return;\n        }\n    }\n\n    // Position starts unset — update_overlay_position() sets the correct\n    // LogicalPosition before the overlay is shown.\n    let mut builder = WebviewWindowBuilder::new(\n        app_handle,\n        \"recording_overlay\",\n        tauri::WebviewUrl::App(\"src/overlay/index.html\".into()),\n    )\n    .title(\"Recording\")\n    .resizable(false)\n    .inner_size(OVERLAY_WIDTH, OVERLAY_HEIGHT)\n    .shadow(false)\n    .maximizable(false)\n    .minimizable(false)\n    .closable(false)\n    .accept_first_mouse(true)\n    .decorations(false)\n    .always_on_top(true)\n    .skip_taskbar(true)\n    .transparent(true)\n    .focused(false)\n    .visible(false);\n\n    if let Some(data_dir) = crate::portable::data_dir() {\n        builder = builder.data_directory(data_dir.join(\"webview\"));\n    }\n\n    #[allow(unused_variables)]\n    match builder.build() {\n        Ok(window) => {\n            #[cfg(target_os = \"linux\")]\n            {\n                // Try to initialize GTK layer shell, ignore errors if compositor doesn't support it\n                if init_gtk_layer_shell(&window) {\n                    debug!(\"GTK layer shell initialized for overlay window\");\n                } else {\n                    debug!(\"GTK layer shell not available, falling back to regular window\");\n                }\n            }\n\n            debug!(\"Recording overlay window created successfully (hidden)\");\n        }\n        Err(e) => {\n            debug!(\"Failed to create recording overlay window: {}\", e);\n        }\n    }\n}\n\n/// Creates the recording overlay panel and keeps it hidden by default (macOS)\n#[cfg(target_os = \"macos\")]\npub fn create_recording_overlay(app_handle: &AppHandle) {\n    if let Some((x, y)) = calculate_overlay_position(app_handle) {\n        // PanelBuilder creates a Tauri window then converts it to NSPanel.\n        // The window remains registered, so get_webview_window() still works.\n        match PanelBuilder::<_, RecordingOverlayPanel>::new(app_handle, \"recording_overlay\")\n            .url(WebviewUrl::App(\"src/overlay/index.html\".into()))\n            .title(\"Recording\")\n            .position(tauri::Position::Logical(tauri::LogicalPosition { x, y }))\n            .level(PanelLevel::Status)\n            .size(tauri::Size::Logical(tauri::LogicalSize {\n                width: OVERLAY_WIDTH,\n                height: OVERLAY_HEIGHT,\n            }))\n            .has_shadow(false)\n            .transparent(true)\n            .no_activate(true)\n            .corner_radius(0.0)\n            .with_window(|w| w.decorations(false).transparent(true))\n            .collection_behavior(\n                CollectionBehavior::new()\n                    .can_join_all_spaces()\n                    .full_screen_auxiliary(),\n            )\n            .build()\n        {\n            Ok(panel) => {\n                let _ = panel.hide();\n            }\n            Err(e) => {\n                log::error!(\"Failed to create recording overlay panel: {}\", e);\n            }\n        }\n    }\n}\n\nfn show_overlay_state(app_handle: &AppHandle, state: &str) {\n    // Check if overlay should be shown based on position setting\n    let settings = settings::get_settings(app_handle);\n    if settings.overlay_position == OverlayPosition::None {\n        return;\n    }\n\n    update_overlay_position(app_handle);\n\n    if let Some(overlay_window) = app_handle.get_webview_window(\"recording_overlay\") {\n        let _ = overlay_window.show();\n\n        // On Windows, aggressively re-assert \"topmost\" in the native Z-order after showing\n        #[cfg(target_os = \"windows\")]\n        force_overlay_topmost(&overlay_window);\n\n        let _ = overlay_window.emit(\"show-overlay\", state);\n    }\n}\n\n/// Shows the recording overlay window with fade-in animation\npub fn show_recording_overlay(app_handle: &AppHandle) {\n    show_overlay_state(app_handle, \"recording\");\n}\n\n/// Shows the transcribing overlay window\npub fn show_transcribing_overlay(app_handle: &AppHandle) {\n    show_overlay_state(app_handle, \"transcribing\");\n}\n\n/// Shows the processing overlay window\npub fn show_processing_overlay(app_handle: &AppHandle) {\n    show_overlay_state(app_handle, \"processing\");\n}\n\n/// Updates the overlay window position based on current settings\npub fn update_overlay_position(app_handle: &AppHandle) {\n    if let Some(overlay_window) = app_handle.get_webview_window(\"recording_overlay\") {\n        #[cfg(target_os = \"linux\")]\n        {\n            update_gtk_layer_shell_anchors(&overlay_window);\n        }\n\n        if let Some((x, y)) = calculate_overlay_position(app_handle) {\n            let _ = overlay_window\n                .set_position(tauri::Position::Logical(tauri::LogicalPosition { x, y }));\n        }\n    }\n}\n\n/// Hides the recording overlay window with fade-out animation\npub fn hide_recording_overlay(app_handle: &AppHandle) {\n    // Always hide the overlay regardless of settings - if setting was changed while recording,\n    // we still want to hide it properly\n    if let Some(overlay_window) = app_handle.get_webview_window(\"recording_overlay\") {\n        // Emit event to trigger fade-out animation\n        let _ = overlay_window.emit(\"hide-overlay\", ());\n        // Hide the window after a short delay to allow animation to complete\n        let window_clone = overlay_window.clone();\n        std::thread::spawn(move || {\n            std::thread::sleep(std::time::Duration::from_millis(300));\n            let _ = window_clone.hide();\n        });\n    }\n}\n\npub fn emit_levels(app_handle: &AppHandle, levels: &Vec<f32>) {\n    // emit levels to main app\n    let _ = app_handle.emit(\"mic-level\", levels);\n\n    // also emit to the recording overlay if it's open\n    if let Some(overlay_window) = app_handle.get_webview_window(\"recording_overlay\") {\n        let _ = overlay_window.emit(\"mic-level\", levels);\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/portable.rs",
    "content": "use std::path::PathBuf;\nuse std::sync::OnceLock;\nuse tauri::Manager;\n\n/// Portable mode support for Handy.\n///\n/// When a file named `portable` exists next to the executable, all user data\n/// (settings, models, recordings, database, logs) is stored in a `Data/`\n/// directory alongside the executable instead of `%APPDATA%`.\n\nstatic PORTABLE_DATA_DIR: OnceLock<Option<PathBuf>> = OnceLock::new();\n\n/// Detect portable mode by looking for a `portable` marker file next to the exe.\n/// Must be called once at startup before Tauri initializes.\npub fn init() {\n    PORTABLE_DATA_DIR.get_or_init(|| {\n        let exe_path = std::env::current_exe().ok()?;\n        let exe_dir = exe_path.parent()?;\n\n        if exe_dir.join(\"portable\").exists() {\n            let data_dir = exe_dir.join(\"Data\");\n            if !data_dir.exists() {\n                std::fs::create_dir_all(&data_dir).ok()?;\n            }\n            eprintln!(\"[portable] data dir: {}\", data_dir.display());\n            Some(data_dir)\n        } else {\n            None\n        }\n    });\n}\n\n/// Returns `true` if running in portable mode.\npub fn is_portable() -> bool {\n    PORTABLE_DATA_DIR.get().and_then(|v| v.as_ref()).is_some()\n}\n\n/// Get the portable data dir (if active). Does not require an AppHandle.\n/// Returns `None` when not in portable mode.\npub fn data_dir() -> Option<&'static PathBuf> {\n    PORTABLE_DATA_DIR.get().and_then(|v| v.as_ref())\n}\n\n/// Portable-aware replacement for `app.path().app_data_dir()`.\npub fn app_data_dir(app: &tauri::AppHandle) -> Result<PathBuf, tauri::Error> {\n    if let Some(dir) = data_dir() {\n        Ok(dir.clone())\n    } else {\n        app.path().app_data_dir()\n    }\n}\n\n/// Portable-aware replacement for `app.path().app_log_dir()`.\npub fn app_log_dir(app: &tauri::AppHandle) -> Result<PathBuf, tauri::Error> {\n    if let Some(dir) = data_dir() {\n        Ok(dir.join(\"logs\"))\n    } else {\n        app.path().app_log_dir()\n    }\n}\n\n/// Resolve a relative path against the app data directory (portable-aware).\n/// Replaces `app.path().resolve(path, BaseDirectory::AppData)`.\npub fn resolve_app_data(app: &tauri::AppHandle, relative: &str) -> Result<PathBuf, tauri::Error> {\n    Ok(app_data_dir(app)?.join(relative))\n}\n\n/// Get the path to use with `tauri-plugin-store`.\n/// Returns an absolute path in portable mode (so the store plugin writes to\n/// the portable Data dir) or the original relative path otherwise.\npub fn store_path(relative: &str) -> PathBuf {\n    if let Some(dir) = data_dir() {\n        dir.join(relative)\n    } else {\n        PathBuf::from(relative)\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/settings.rs",
    "content": "use log::{debug, warn};\nuse serde::de::{self, Visitor};\nuse serde::{Deserialize, Deserializer, Serialize};\nuse specta::Type;\nuse std::collections::HashMap;\nuse tauri::AppHandle;\nuse tauri_plugin_store::StoreExt;\n\npub const APPLE_INTELLIGENCE_PROVIDER_ID: &str = \"apple_intelligence\";\npub const APPLE_INTELLIGENCE_DEFAULT_MODEL_ID: &str = \"Apple Intelligence\";\n\n#[derive(Serialize, Debug, Clone, Copy, PartialEq, Eq, Type)]\n#[serde(rename_all = \"lowercase\")]\npub enum LogLevel {\n    Trace,\n    Debug,\n    Info,\n    Warn,\n    Error,\n}\n\n// Custom deserializer to handle both old numeric format (1-5) and new string format (\"trace\", \"debug\", etc.)\nimpl<'de> Deserialize<'de> for LogLevel {\n    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>\n    where\n        D: Deserializer<'de>,\n    {\n        struct LogLevelVisitor;\n\n        impl<'de> Visitor<'de> for LogLevelVisitor {\n            type Value = LogLevel;\n\n            fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {\n                formatter.write_str(\"a string or integer representing log level\")\n            }\n\n            fn visit_str<E: de::Error>(self, value: &str) -> Result<LogLevel, E> {\n                match value.to_lowercase().as_str() {\n                    \"trace\" => Ok(LogLevel::Trace),\n                    \"debug\" => Ok(LogLevel::Debug),\n                    \"info\" => Ok(LogLevel::Info),\n                    \"warn\" => Ok(LogLevel::Warn),\n                    \"error\" => Ok(LogLevel::Error),\n                    _ => Err(E::unknown_variant(\n                        value,\n                        &[\"trace\", \"debug\", \"info\", \"warn\", \"error\"],\n                    )),\n                }\n            }\n\n            fn visit_u64<E: de::Error>(self, value: u64) -> Result<LogLevel, E> {\n                match value {\n                    1 => Ok(LogLevel::Trace),\n                    2 => Ok(LogLevel::Debug),\n                    3 => Ok(LogLevel::Info),\n                    4 => Ok(LogLevel::Warn),\n                    5 => Ok(LogLevel::Error),\n                    _ => Err(E::invalid_value(de::Unexpected::Unsigned(value), &\"1-5\")),\n                }\n            }\n        }\n\n        deserializer.deserialize_any(LogLevelVisitor)\n    }\n}\n\nimpl From<LogLevel> for tauri_plugin_log::LogLevel {\n    fn from(level: LogLevel) -> Self {\n        match level {\n            LogLevel::Trace => tauri_plugin_log::LogLevel::Trace,\n            LogLevel::Debug => tauri_plugin_log::LogLevel::Debug,\n            LogLevel::Info => tauri_plugin_log::LogLevel::Info,\n            LogLevel::Warn => tauri_plugin_log::LogLevel::Warn,\n            LogLevel::Error => tauri_plugin_log::LogLevel::Error,\n        }\n    }\n}\n\n#[derive(Serialize, Deserialize, Debug, Clone, Type)]\npub struct ShortcutBinding {\n    pub id: String,\n    pub name: String,\n    pub description: String,\n    pub default_binding: String,\n    pub current_binding: String,\n}\n\n#[derive(Serialize, Deserialize, Debug, Clone, Type)]\npub struct LLMPrompt {\n    pub id: String,\n    pub name: String,\n    pub prompt: String,\n}\n\n#[derive(Serialize, Deserialize, Debug, Clone, Type)]\npub struct PostProcessProvider {\n    pub id: String,\n    pub label: String,\n    pub base_url: String,\n    #[serde(default)]\n    pub allow_base_url_edit: bool,\n    #[serde(default)]\n    pub models_endpoint: Option<String>,\n    #[serde(default)]\n    pub supports_structured_output: bool,\n}\n\n#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Type)]\n#[serde(rename_all = \"lowercase\")]\npub enum OverlayPosition {\n    None,\n    Top,\n    Bottom,\n}\n\n#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Type)]\n#[serde(rename_all = \"snake_case\")]\npub enum ModelUnloadTimeout {\n    Never,\n    Immediately,\n    Min2,\n    Min5,\n    Min10,\n    Min15,\n    Hour1,\n    Sec15, // Debug mode only\n}\n\n#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Type)]\n#[serde(rename_all = \"snake_case\")]\npub enum PasteMethod {\n    CtrlV,\n    Direct,\n    None,\n    ShiftInsert,\n    CtrlShiftV,\n    ExternalScript,\n}\n\n#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Type)]\n#[serde(rename_all = \"snake_case\")]\npub enum ClipboardHandling {\n    DontModify,\n    CopyToClipboard,\n}\n\n#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Type)]\n#[serde(rename_all = \"snake_case\")]\npub enum AutoSubmitKey {\n    Enter,\n    CtrlEnter,\n    CmdEnter,\n}\n\n#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Type)]\n#[serde(rename_all = \"snake_case\")]\npub enum RecordingRetentionPeriod {\n    Never,\n    PreserveLimit,\n    Days3,\n    Weeks2,\n    Months3,\n}\n\n#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Type)]\n#[serde(rename_all = \"snake_case\")]\npub enum KeyboardImplementation {\n    Tauri,\n    HandyKeys,\n}\n\nimpl Default for KeyboardImplementation {\n    fn default() -> Self {\n        #[cfg(target_os = \"linux\")]\n        return KeyboardImplementation::Tauri;\n        #[cfg(not(target_os = \"linux\"))]\n        return KeyboardImplementation::HandyKeys;\n    }\n}\n\nimpl Default for ModelUnloadTimeout {\n    fn default() -> Self {\n        ModelUnloadTimeout::Min5\n    }\n}\n\nimpl Default for PasteMethod {\n    fn default() -> Self {\n        // Default to CtrlV for macOS and Windows, Direct for Linux\n        #[cfg(target_os = \"linux\")]\n        return PasteMethod::Direct;\n        #[cfg(not(target_os = \"linux\"))]\n        return PasteMethod::CtrlV;\n    }\n}\n\nimpl Default for ClipboardHandling {\n    fn default() -> Self {\n        ClipboardHandling::DontModify\n    }\n}\n\nimpl Default for AutoSubmitKey {\n    fn default() -> Self {\n        AutoSubmitKey::Enter\n    }\n}\n\nimpl ModelUnloadTimeout {\n    pub fn to_minutes(self) -> Option<u64> {\n        match self {\n            ModelUnloadTimeout::Never => None,\n            ModelUnloadTimeout::Immediately => Some(0), // Special case for immediate unloading\n            ModelUnloadTimeout::Min2 => Some(2),\n            ModelUnloadTimeout::Min5 => Some(5),\n            ModelUnloadTimeout::Min10 => Some(10),\n            ModelUnloadTimeout::Min15 => Some(15),\n            ModelUnloadTimeout::Hour1 => Some(60),\n            ModelUnloadTimeout::Sec15 => Some(0), // Special case for debug - handled separately\n        }\n    }\n\n    pub fn to_seconds(self) -> Option<u64> {\n        match self {\n            ModelUnloadTimeout::Never => None,\n            ModelUnloadTimeout::Immediately => Some(0), // Special case for immediate unloading\n            ModelUnloadTimeout::Sec15 => Some(15),\n            _ => self.to_minutes().map(|m| m * 60),\n        }\n    }\n}\n\n#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Type)]\n#[serde(rename_all = \"snake_case\")]\npub enum SoundTheme {\n    Marimba,\n    Pop,\n    Custom,\n}\n\nimpl SoundTheme {\n    fn as_str(&self) -> &'static str {\n        match self {\n            SoundTheme::Marimba => \"marimba\",\n            SoundTheme::Pop => \"pop\",\n            SoundTheme::Custom => \"custom\",\n        }\n    }\n\n    pub fn to_start_path(&self) -> String {\n        format!(\"resources/{}_start.wav\", self.as_str())\n    }\n\n    pub fn to_stop_path(&self) -> String {\n        format!(\"resources/{}_stop.wav\", self.as_str())\n    }\n}\n\n#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Type)]\n#[serde(rename_all = \"snake_case\")]\npub enum TypingTool {\n    Auto,\n    Wtype,\n    Kwtype,\n    Dotool,\n    Ydotool,\n    Xdotool,\n}\n\nimpl Default for TypingTool {\n    fn default() -> Self {\n        TypingTool::Auto\n    }\n}\n\n#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Type)]\n#[serde(rename_all = \"snake_case\")]\npub enum WhisperAcceleratorSetting {\n    Auto,\n    Cpu,\n    Gpu,\n}\n\nimpl Default for WhisperAcceleratorSetting {\n    fn default() -> Self {\n        WhisperAcceleratorSetting::Auto\n    }\n}\n\n#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Type)]\n#[serde(rename_all = \"snake_case\")]\npub enum OrtAcceleratorSetting {\n    Auto,\n    Cpu,\n    Cuda,\n    #[serde(rename = \"directml\")]\n    DirectMl,\n    Rocm,\n}\n\nimpl Default for OrtAcceleratorSetting {\n    fn default() -> Self {\n        OrtAcceleratorSetting::Auto\n    }\n}\n\n/* still handy for composing the initial JSON in the store ------------- */\n#[derive(Serialize, Deserialize, Debug, Clone, Type)]\npub struct AppSettings {\n    pub bindings: HashMap<String, ShortcutBinding>,\n    pub push_to_talk: bool,\n    pub audio_feedback: bool,\n    #[serde(default = \"default_audio_feedback_volume\")]\n    pub audio_feedback_volume: f32,\n    #[serde(default = \"default_sound_theme\")]\n    pub sound_theme: SoundTheme,\n    #[serde(default = \"default_start_hidden\")]\n    pub start_hidden: bool,\n    #[serde(default = \"default_autostart_enabled\")]\n    pub autostart_enabled: bool,\n    #[serde(default = \"default_update_checks_enabled\")]\n    pub update_checks_enabled: bool,\n    #[serde(default = \"default_model\")]\n    pub selected_model: String,\n    #[serde(default = \"default_always_on_microphone\")]\n    pub always_on_microphone: bool,\n    #[serde(default)]\n    pub selected_microphone: Option<String>,\n    #[serde(default)]\n    pub clamshell_microphone: Option<String>,\n    #[serde(default)]\n    pub selected_output_device: Option<String>,\n    #[serde(default = \"default_translate_to_english\")]\n    pub translate_to_english: bool,\n    #[serde(default = \"default_selected_language\")]\n    pub selected_language: String,\n    #[serde(default = \"default_overlay_position\")]\n    pub overlay_position: OverlayPosition,\n    #[serde(default = \"default_debug_mode\")]\n    pub debug_mode: bool,\n    #[serde(default = \"default_log_level\")]\n    pub log_level: LogLevel,\n    #[serde(default)]\n    pub custom_words: Vec<String>,\n    #[serde(default)]\n    pub model_unload_timeout: ModelUnloadTimeout,\n    #[serde(default = \"default_word_correction_threshold\")]\n    pub word_correction_threshold: f64,\n    #[serde(default = \"default_history_limit\")]\n    pub history_limit: usize,\n    #[serde(default = \"default_recording_retention_period\")]\n    pub recording_retention_period: RecordingRetentionPeriod,\n    #[serde(default)]\n    pub paste_method: PasteMethod,\n    #[serde(default)]\n    pub clipboard_handling: ClipboardHandling,\n    #[serde(default = \"default_auto_submit\")]\n    pub auto_submit: bool,\n    #[serde(default)]\n    pub auto_submit_key: AutoSubmitKey,\n    #[serde(default = \"default_post_process_enabled\")]\n    pub post_process_enabled: bool,\n    #[serde(default = \"default_post_process_provider_id\")]\n    pub post_process_provider_id: String,\n    #[serde(default = \"default_post_process_providers\")]\n    pub post_process_providers: Vec<PostProcessProvider>,\n    #[serde(default = \"default_post_process_api_keys\")]\n    pub post_process_api_keys: HashMap<String, String>,\n    #[serde(default = \"default_post_process_models\")]\n    pub post_process_models: HashMap<String, String>,\n    #[serde(default = \"default_post_process_prompts\")]\n    pub post_process_prompts: Vec<LLMPrompt>,\n    #[serde(default)]\n    pub post_process_selected_prompt_id: Option<String>,\n    #[serde(default)]\n    pub mute_while_recording: bool,\n    #[serde(default)]\n    pub append_trailing_space: bool,\n    #[serde(default = \"default_app_language\")]\n    pub app_language: String,\n    #[serde(default)]\n    pub experimental_enabled: bool,\n    #[serde(default)]\n    pub lazy_stream_close: bool,\n    #[serde(default)]\n    pub keyboard_implementation: KeyboardImplementation,\n    #[serde(default = \"default_show_tray_icon\")]\n    pub show_tray_icon: bool,\n    #[serde(default = \"default_paste_delay_ms\")]\n    pub paste_delay_ms: u64,\n    #[serde(default = \"default_typing_tool\")]\n    pub typing_tool: TypingTool,\n    pub external_script_path: Option<String>,\n    #[serde(default)]\n    pub custom_filler_words: Option<Vec<String>>,\n    #[serde(default)]\n    pub whisper_accelerator: WhisperAcceleratorSetting,\n    #[serde(default)]\n    pub ort_accelerator: OrtAcceleratorSetting,\n    #[serde(default)]\n    pub extra_recording_buffer_ms: u64,\n}\n\nfn default_model() -> String {\n    \"\".to_string()\n}\n\nfn default_always_on_microphone() -> bool {\n    false\n}\n\nfn default_translate_to_english() -> bool {\n    false\n}\n\nfn default_start_hidden() -> bool {\n    false\n}\n\nfn default_autostart_enabled() -> bool {\n    false\n}\n\nfn default_update_checks_enabled() -> bool {\n    true\n}\n\nfn default_selected_language() -> String {\n    \"auto\".to_string()\n}\n\nfn default_overlay_position() -> OverlayPosition {\n    #[cfg(target_os = \"linux\")]\n    return OverlayPosition::None;\n    #[cfg(not(target_os = \"linux\"))]\n    return OverlayPosition::Bottom;\n}\n\nfn default_debug_mode() -> bool {\n    false\n}\n\nfn default_log_level() -> LogLevel {\n    LogLevel::Debug\n}\n\nfn default_word_correction_threshold() -> f64 {\n    0.18\n}\n\nfn default_paste_delay_ms() -> u64 {\n    60\n}\n\nfn default_auto_submit() -> bool {\n    false\n}\n\nfn default_history_limit() -> usize {\n    5\n}\n\nfn default_recording_retention_period() -> RecordingRetentionPeriod {\n    RecordingRetentionPeriod::PreserveLimit\n}\n\nfn default_audio_feedback_volume() -> f32 {\n    1.0\n}\n\nfn default_sound_theme() -> SoundTheme {\n    SoundTheme::Marimba\n}\n\nfn default_post_process_enabled() -> bool {\n    false\n}\n\nfn default_app_language() -> String {\n    tauri_plugin_os::locale()\n        .map(|l| l.replace('_', \"-\"))\n        .unwrap_or_else(|| \"en\".to_string())\n}\n\nfn default_show_tray_icon() -> bool {\n    true\n}\n\nfn default_post_process_provider_id() -> String {\n    \"openai\".to_string()\n}\n\nfn default_post_process_providers() -> Vec<PostProcessProvider> {\n    let mut providers = vec![\n        PostProcessProvider {\n            id: \"openai\".to_string(),\n            label: \"OpenAI\".to_string(),\n            base_url: \"https://api.openai.com/v1\".to_string(),\n            allow_base_url_edit: false,\n            models_endpoint: Some(\"/models\".to_string()),\n            supports_structured_output: true,\n        },\n        PostProcessProvider {\n            id: \"zai\".to_string(),\n            label: \"Z.AI\".to_string(),\n            base_url: \"https://api.z.ai/api/paas/v4\".to_string(),\n            allow_base_url_edit: false,\n            models_endpoint: Some(\"/models\".to_string()),\n            supports_structured_output: true,\n        },\n        PostProcessProvider {\n            id: \"openrouter\".to_string(),\n            label: \"OpenRouter\".to_string(),\n            base_url: \"https://openrouter.ai/api/v1\".to_string(),\n            allow_base_url_edit: false,\n            models_endpoint: Some(\"/models\".to_string()),\n            supports_structured_output: true,\n        },\n        PostProcessProvider {\n            id: \"anthropic\".to_string(),\n            label: \"Anthropic\".to_string(),\n            base_url: \"https://api.anthropic.com/v1\".to_string(),\n            allow_base_url_edit: false,\n            models_endpoint: Some(\"/models\".to_string()),\n            supports_structured_output: false,\n        },\n        PostProcessProvider {\n            id: \"groq\".to_string(),\n            label: \"Groq\".to_string(),\n            base_url: \"https://api.groq.com/openai/v1\".to_string(),\n            allow_base_url_edit: false,\n            models_endpoint: Some(\"/models\".to_string()),\n            supports_structured_output: false,\n        },\n        PostProcessProvider {\n            id: \"cerebras\".to_string(),\n            label: \"Cerebras\".to_string(),\n            base_url: \"https://api.cerebras.ai/v1\".to_string(),\n            allow_base_url_edit: false,\n            models_endpoint: Some(\"/models\".to_string()),\n            supports_structured_output: true,\n        },\n    ];\n\n    // Note: We always include Apple Intelligence on macOS ARM64 without checking availability\n    // at startup. The availability check is deferred to when the user actually tries to use it\n    // (in actions.rs). This prevents crashes on macOS 26.x beta where accessing\n    // SystemLanguageModel.default during early app initialization causes SIGABRT.\n    #[cfg(all(target_os = \"macos\", target_arch = \"aarch64\"))]\n    {\n        providers.push(PostProcessProvider {\n            id: APPLE_INTELLIGENCE_PROVIDER_ID.to_string(),\n            label: \"Apple Intelligence\".to_string(),\n            base_url: \"apple-intelligence://local\".to_string(),\n            allow_base_url_edit: false,\n            models_endpoint: None,\n            supports_structured_output: true,\n        });\n    }\n\n    // Custom provider always comes last\n    providers.push(PostProcessProvider {\n        id: \"custom\".to_string(),\n        label: \"Custom\".to_string(),\n        base_url: \"http://localhost:11434/v1\".to_string(),\n        allow_base_url_edit: true,\n        models_endpoint: Some(\"/models\".to_string()),\n        supports_structured_output: false,\n    });\n\n    providers\n}\n\nfn default_post_process_api_keys() -> HashMap<String, String> {\n    let mut map = HashMap::new();\n    for provider in default_post_process_providers() {\n        map.insert(provider.id, String::new());\n    }\n    map\n}\n\nfn default_model_for_provider(provider_id: &str) -> String {\n    if provider_id == APPLE_INTELLIGENCE_PROVIDER_ID {\n        return APPLE_INTELLIGENCE_DEFAULT_MODEL_ID.to_string();\n    }\n    String::new()\n}\n\nfn default_post_process_models() -> HashMap<String, String> {\n    let mut map = HashMap::new();\n    for provider in default_post_process_providers() {\n        map.insert(\n            provider.id.clone(),\n            default_model_for_provider(&provider.id),\n        );\n    }\n    map\n}\n\nfn default_post_process_prompts() -> Vec<LLMPrompt> {\n    vec![LLMPrompt {\n        id: \"default_improve_transcriptions\".to_string(),\n        name: \"Improve Transcriptions\".to_string(),\n        prompt: \"Clean this transcript:\\n1. Fix spelling, capitalization, and punctuation errors\\n2. Convert number words to digits (twenty-five → 25, ten percent → 10%, five dollars → $5)\\n3. Replace spoken punctuation with symbols (period → ., comma → ,, question mark → ?)\\n4. Remove filler words (um, uh, like as filler)\\n5. Keep the language in the original version (if it was french, keep it in french for example)\\n\\nPreserve exact meaning and word order. Do not paraphrase or reorder content.\\n\\nReturn only the cleaned transcript.\\n\\nTranscript:\\n${output}\".to_string(),\n    }]\n}\n\nfn default_typing_tool() -> TypingTool {\n    TypingTool::Auto\n}\n\nfn ensure_post_process_defaults(settings: &mut AppSettings) -> bool {\n    let mut changed = false;\n    for provider in default_post_process_providers() {\n        // Use match to do a single lookup - either sync existing or add new\n        match settings\n            .post_process_providers\n            .iter_mut()\n            .find(|p| p.id == provider.id)\n        {\n            Some(existing) => {\n                // Sync supports_structured_output field for existing providers (migration)\n                if existing.supports_structured_output != provider.supports_structured_output {\n                    debug!(\n                        \"Updating supports_structured_output for provider '{}' from {} to {}\",\n                        provider.id,\n                        existing.supports_structured_output,\n                        provider.supports_structured_output\n                    );\n                    existing.supports_structured_output = provider.supports_structured_output;\n                    changed = true;\n                }\n            }\n            None => {\n                // Provider doesn't exist, add it\n                settings.post_process_providers.push(provider.clone());\n                changed = true;\n            }\n        }\n\n        if !settings.post_process_api_keys.contains_key(&provider.id) {\n            settings\n                .post_process_api_keys\n                .insert(provider.id.clone(), String::new());\n            changed = true;\n        }\n\n        let default_model = default_model_for_provider(&provider.id);\n        match settings.post_process_models.get_mut(&provider.id) {\n            Some(existing) => {\n                if existing.is_empty() && !default_model.is_empty() {\n                    *existing = default_model.clone();\n                    changed = true;\n                }\n            }\n            None => {\n                settings\n                    .post_process_models\n                    .insert(provider.id.clone(), default_model);\n                changed = true;\n            }\n        }\n    }\n\n    changed\n}\n\npub const SETTINGS_STORE_PATH: &str = \"settings_store.json\";\n\npub fn get_default_settings() -> AppSettings {\n    #[cfg(target_os = \"windows\")]\n    let default_shortcut = \"ctrl+space\";\n    #[cfg(target_os = \"macos\")]\n    let default_shortcut = \"option+space\";\n    #[cfg(target_os = \"linux\")]\n    let default_shortcut = \"ctrl+space\";\n    #[cfg(not(any(target_os = \"windows\", target_os = \"macos\", target_os = \"linux\")))]\n    let default_shortcut = \"alt+space\";\n\n    let mut bindings = HashMap::new();\n    bindings.insert(\n        \"transcribe\".to_string(),\n        ShortcutBinding {\n            id: \"transcribe\".to_string(),\n            name: \"Transcribe\".to_string(),\n            description: \"Converts your speech into text.\".to_string(),\n            default_binding: default_shortcut.to_string(),\n            current_binding: default_shortcut.to_string(),\n        },\n    );\n    #[cfg(target_os = \"windows\")]\n    let default_post_process_shortcut = \"ctrl+shift+space\";\n    #[cfg(target_os = \"macos\")]\n    let default_post_process_shortcut = \"option+shift+space\";\n    #[cfg(target_os = \"linux\")]\n    let default_post_process_shortcut = \"ctrl+shift+space\";\n    #[cfg(not(any(target_os = \"windows\", target_os = \"macos\", target_os = \"linux\")))]\n    let default_post_process_shortcut = \"alt+shift+space\";\n\n    bindings.insert(\n        \"transcribe_with_post_process\".to_string(),\n        ShortcutBinding {\n            id: \"transcribe_with_post_process\".to_string(),\n            name: \"Transcribe with Post-Processing\".to_string(),\n            description: \"Converts your speech into text and applies AI post-processing.\"\n                .to_string(),\n            default_binding: default_post_process_shortcut.to_string(),\n            current_binding: default_post_process_shortcut.to_string(),\n        },\n    );\n    bindings.insert(\n        \"cancel\".to_string(),\n        ShortcutBinding {\n            id: \"cancel\".to_string(),\n            name: \"Cancel\".to_string(),\n            description: \"Cancels the current recording.\".to_string(),\n            default_binding: \"escape\".to_string(),\n            current_binding: \"escape\".to_string(),\n        },\n    );\n\n    AppSettings {\n        bindings,\n        push_to_talk: true,\n        audio_feedback: false,\n        audio_feedback_volume: default_audio_feedback_volume(),\n        sound_theme: default_sound_theme(),\n        start_hidden: default_start_hidden(),\n        autostart_enabled: default_autostart_enabled(),\n        update_checks_enabled: default_update_checks_enabled(),\n        selected_model: \"\".to_string(),\n        always_on_microphone: false,\n        selected_microphone: None,\n        clamshell_microphone: None,\n        selected_output_device: None,\n        translate_to_english: false,\n        selected_language: \"auto\".to_string(),\n        overlay_position: default_overlay_position(),\n        debug_mode: false,\n        log_level: default_log_level(),\n        custom_words: Vec::new(),\n        model_unload_timeout: ModelUnloadTimeout::default(),\n        word_correction_threshold: default_word_correction_threshold(),\n        history_limit: default_history_limit(),\n        recording_retention_period: default_recording_retention_period(),\n        paste_method: PasteMethod::default(),\n        clipboard_handling: ClipboardHandling::default(),\n        auto_submit: default_auto_submit(),\n        auto_submit_key: AutoSubmitKey::default(),\n        post_process_enabled: default_post_process_enabled(),\n        post_process_provider_id: default_post_process_provider_id(),\n        post_process_providers: default_post_process_providers(),\n        post_process_api_keys: default_post_process_api_keys(),\n        post_process_models: default_post_process_models(),\n        post_process_prompts: default_post_process_prompts(),\n        post_process_selected_prompt_id: None,\n        mute_while_recording: false,\n        append_trailing_space: false,\n        app_language: default_app_language(),\n        experimental_enabled: false,\n        lazy_stream_close: false,\n        keyboard_implementation: KeyboardImplementation::default(),\n        show_tray_icon: default_show_tray_icon(),\n        paste_delay_ms: default_paste_delay_ms(),\n        typing_tool: default_typing_tool(),\n        external_script_path: None,\n        custom_filler_words: None,\n        whisper_accelerator: WhisperAcceleratorSetting::default(),\n        ort_accelerator: OrtAcceleratorSetting::default(),\n        extra_recording_buffer_ms: 0,\n    }\n}\n\nimpl AppSettings {\n    pub fn active_post_process_provider(&self) -> Option<&PostProcessProvider> {\n        self.post_process_providers\n            .iter()\n            .find(|provider| provider.id == self.post_process_provider_id)\n    }\n\n    pub fn post_process_provider(&self, provider_id: &str) -> Option<&PostProcessProvider> {\n        self.post_process_providers\n            .iter()\n            .find(|provider| provider.id == provider_id)\n    }\n\n    pub fn post_process_provider_mut(\n        &mut self,\n        provider_id: &str,\n    ) -> Option<&mut PostProcessProvider> {\n        self.post_process_providers\n            .iter_mut()\n            .find(|provider| provider.id == provider_id)\n    }\n}\n\npub fn load_or_create_app_settings(app: &AppHandle) -> AppSettings {\n    // Initialize store\n    let store = app\n        .store(crate::portable::store_path(SETTINGS_STORE_PATH))\n        .expect(\"Failed to initialize store\");\n\n    let mut settings = if let Some(settings_value) = store.get(\"settings\") {\n        // Parse the entire settings object\n        match serde_json::from_value::<AppSettings>(settings_value) {\n            Ok(mut settings) => {\n                debug!(\"Found existing settings: {:?}\", settings);\n                let default_settings = get_default_settings();\n                let mut updated = false;\n\n                // Merge default bindings into existing settings\n                for (key, value) in default_settings.bindings {\n                    if !settings.bindings.contains_key(&key) {\n                        debug!(\"Adding missing binding: {}\", key);\n                        settings.bindings.insert(key, value);\n                        updated = true;\n                    }\n                }\n\n                if updated {\n                    debug!(\"Settings updated with new bindings\");\n                    store.set(\"settings\", serde_json::to_value(&settings).unwrap());\n                }\n\n                settings\n            }\n            Err(e) => {\n                warn!(\"Failed to parse settings: {}\", e);\n                // Fall back to default settings if parsing fails\n                let default_settings = get_default_settings();\n                store.set(\"settings\", serde_json::to_value(&default_settings).unwrap());\n                default_settings\n            }\n        }\n    } else {\n        let default_settings = get_default_settings();\n        store.set(\"settings\", serde_json::to_value(&default_settings).unwrap());\n        default_settings\n    };\n\n    if ensure_post_process_defaults(&mut settings) {\n        store.set(\"settings\", serde_json::to_value(&settings).unwrap());\n    }\n\n    settings\n}\n\npub fn get_settings(app: &AppHandle) -> AppSettings {\n    let store = app\n        .store(crate::portable::store_path(SETTINGS_STORE_PATH))\n        .expect(\"Failed to initialize store\");\n\n    let mut settings = if let Some(settings_value) = store.get(\"settings\") {\n        serde_json::from_value::<AppSettings>(settings_value).unwrap_or_else(|_| {\n            let default_settings = get_default_settings();\n            store.set(\"settings\", serde_json::to_value(&default_settings).unwrap());\n            default_settings\n        })\n    } else {\n        let default_settings = get_default_settings();\n        store.set(\"settings\", serde_json::to_value(&default_settings).unwrap());\n        default_settings\n    };\n\n    if ensure_post_process_defaults(&mut settings) {\n        store.set(\"settings\", serde_json::to_value(&settings).unwrap());\n    }\n\n    settings\n}\n\npub fn write_settings(app: &AppHandle, settings: AppSettings) {\n    let store = app\n        .store(crate::portable::store_path(SETTINGS_STORE_PATH))\n        .expect(\"Failed to initialize store\");\n\n    store.set(\"settings\", serde_json::to_value(&settings).unwrap());\n}\n\npub fn get_bindings(app: &AppHandle) -> HashMap<String, ShortcutBinding> {\n    let settings = get_settings(app);\n\n    settings.bindings\n}\n\npub fn get_stored_binding(app: &AppHandle, id: &str) -> ShortcutBinding {\n    let bindings = get_bindings(app);\n\n    let binding = bindings.get(id).unwrap().clone();\n\n    binding\n}\n\npub fn get_history_limit(app: &AppHandle) -> usize {\n    let settings = get_settings(app);\n    settings.history_limit\n}\n\npub fn get_recording_retention_period(app: &AppHandle) -> RecordingRetentionPeriod {\n    let settings = get_settings(app);\n    settings.recording_retention_period\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn default_settings_disable_auto_submit() {\n        let settings = get_default_settings();\n        assert!(!settings.auto_submit);\n        assert_eq!(settings.auto_submit_key, AutoSubmitKey::Enter);\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/shortcut/handler.rs",
    "content": "//! Shared shortcut event handling logic\n//!\n//! This module contains the common logic for handling shortcut events,\n//! used by both the Tauri and handy-keys implementations.\n\nuse log::warn;\nuse std::sync::Arc;\nuse tauri::{AppHandle, Manager};\n\nuse crate::actions::ACTION_MAP;\nuse crate::managers::audio::AudioRecordingManager;\nuse crate::settings::get_settings;\nuse crate::transcription_coordinator::is_transcribe_binding;\nuse crate::TranscriptionCoordinator;\n\n/// Handle a shortcut event from either implementation.\n///\n/// This function contains the shared logic for:\n/// - Looking up the action in ACTION_MAP\n/// - Handling the cancel binding (only fires when recording)\n/// - Handling push-to-talk mode (start on press, stop on release)\n/// - Handling toggle mode (toggle state on press only)\n///\n/// # Arguments\n/// * `app` - The Tauri app handle\n/// * `binding_id` - The ID of the binding (e.g., \"transcribe\", \"cancel\")\n/// * `hotkey_string` - The string representation of the hotkey\n/// * `is_pressed` - Whether this is a key press (true) or release (false)\npub fn handle_shortcut_event(\n    app: &AppHandle,\n    binding_id: &str,\n    hotkey_string: &str,\n    is_pressed: bool,\n) {\n    let settings = get_settings(app);\n\n    // Transcribe bindings are handled by the coordinator.\n    if is_transcribe_binding(binding_id) {\n        if let Some(coordinator) = app.try_state::<TranscriptionCoordinator>() {\n            coordinator.send_input(binding_id, hotkey_string, is_pressed, settings.push_to_talk);\n        } else {\n            warn!(\"TranscriptionCoordinator is not initialized\");\n        }\n        return;\n    }\n\n    let Some(action) = ACTION_MAP.get(binding_id) else {\n        warn!(\n            \"No action defined in ACTION_MAP for shortcut ID '{}'. Shortcut: '{}', Pressed: {}\",\n            binding_id, hotkey_string, is_pressed\n        );\n        return;\n    };\n\n    // Cancel binding: only fires when recording and key is pressed\n    if binding_id == \"cancel\" {\n        let audio_manager = app.state::<Arc<AudioRecordingManager>>();\n        if audio_manager.is_recording() && is_pressed {\n            action.start(app, binding_id, hotkey_string);\n        }\n        return;\n    }\n\n    // Remaining bindings (e.g. \"test\") use simple start/stop on press/release.\n    if is_pressed {\n        action.start(app, binding_id, hotkey_string);\n    } else {\n        action.stop(app, binding_id, hotkey_string);\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/shortcut/handy_keys.rs",
    "content": "//! Handy-keys based keyboard shortcut implementation\n//!\n//! This module provides an alternative to Tauri's global-shortcut plugin\n//! using the handy-keys library for more control over keyboard events.\n//!\n//! ## Architecture\n//!\n//! The implementation uses a dedicated manager thread that owns the `HotkeyManager`:\n//!\n//! ```text\n//! ┌─────────────────┐     commands      ┌──────────────────────┐\n//! │   Main Thread   │ ───────────────▶ │   Manager Thread     │\n//! │                 │   (via channel)   │                      │\n//! │ - register()    │                   │ - owns HotkeyManager │\n//! │ - unregister()  │                   │ - polls for events   │\n//! └─────────────────┘                   │ - dispatches actions │\n//!                                       └──────────────────────┘\n//! ```\n//!\n//! This design ensures thread-safety since `HotkeyManager` is only accessed\n//! from a single thread. Commands (register/unregister) are sent via an mpsc\n//! channel and responses are synchronously awaited.\n//!\n//! ## Recording Mode\n//!\n//! For UI key capture, a separate `KeyboardListener` is created on-demand and\n//! polled from a dedicated recording thread. Events are emitted to the frontend\n//! via Tauri's event system.\n\nuse handy_keys::{Hotkey, HotkeyId, HotkeyManager, HotkeyState, KeyboardListener};\nuse log::{debug, error, info};\nuse serde::Serialize;\nuse specta::Type;\nuse std::collections::HashMap;\nuse std::sync::atomic::{AtomicBool, Ordering};\nuse std::sync::mpsc::{self, Receiver, Sender};\nuse std::sync::{Arc, Mutex};\nuse std::thread::{self, JoinHandle};\nuse tauri::{AppHandle, Emitter, Manager};\n\nuse crate::settings::{self, get_settings, ShortcutBinding};\n\nuse super::handler::handle_shortcut_event;\n\n/// Commands that can be sent to the hotkey manager thread\nenum ManagerCommand {\n    Register {\n        binding_id: String,\n        hotkey_string: String,\n        response: Sender<Result<(), String>>,\n    },\n    Unregister {\n        binding_id: String,\n        response: Sender<Result<(), String>>,\n    },\n    Shutdown,\n}\n\n/// State for the handy-keys shortcut manager\npub struct HandyKeysState {\n    /// Channel to send commands to the manager thread (wrapped in Mutex for Sync)\n    command_sender: Mutex<Sender<ManagerCommand>>,\n    /// Handle to the manager thread (wrapped in Mutex for Sync, allows proper join on drop)\n    thread_handle: Mutex<Option<JoinHandle<()>>>,\n    /// Recording listener for UI key capture (only active during recording)\n    recording_listener: Mutex<Option<KeyboardListener>>,\n    /// Flag indicating if we're in recording mode\n    is_recording: AtomicBool,\n    /// The binding ID being recorded (if any)\n    recording_binding_id: Mutex<Option<String>>,\n    /// Flag to stop recording loop\n    recording_running: Arc<AtomicBool>,\n}\n\n/// Key event sent to frontend during recording mode\n#[derive(Debug, Clone, Serialize, Type)]\npub struct FrontendKeyEvent {\n    /// Currently pressed modifier keys\n    pub modifiers: Vec<String>,\n    /// The key that was pressed (if any)\n    pub key: Option<String>,\n    /// Whether this is a key down event\n    pub is_key_down: bool,\n    /// The full hotkey string (e.g., \"option+space\")\n    pub hotkey_string: String,\n}\n\nimpl HandyKeysState {\n    /// Create a new HandyKeysState\n    pub fn new(app: AppHandle) -> Result<Self, String> {\n        let (cmd_tx, cmd_rx) = mpsc::channel::<ManagerCommand>();\n\n        // Start the manager thread\n        let app_clone = app.clone();\n        let thread_handle = thread::spawn(move || {\n            Self::manager_thread(cmd_rx, app_clone);\n        });\n\n        Ok(Self {\n            command_sender: Mutex::new(cmd_tx),\n            thread_handle: Mutex::new(Some(thread_handle)),\n            recording_listener: Mutex::new(None),\n            is_recording: AtomicBool::new(false),\n            recording_binding_id: Mutex::new(None),\n            recording_running: Arc::new(AtomicBool::new(false)),\n        })\n    }\n\n    /// The main manager thread - owns the HotkeyManager and processes commands\n    fn manager_thread(cmd_rx: Receiver<ManagerCommand>, app: AppHandle) {\n        info!(\"handy-keys manager thread started\");\n\n        // Create the HotkeyManager in this thread\n        let manager = match HotkeyManager::new_with_blocking() {\n            Ok(m) => m,\n            Err(e) => {\n                error!(\"Failed to create HotkeyManager: {}\", e);\n                return;\n            }\n        };\n\n        // Maps binding IDs to HotkeyIds and hotkey strings\n        let mut binding_to_hotkey: HashMap<String, HotkeyId> = HashMap::new();\n        let mut hotkey_to_binding: HashMap<HotkeyId, (String, String)> = HashMap::new(); // (binding_id, hotkey_string)\n\n        loop {\n            // Check for hotkey events (non-blocking)\n            while let Some(event) = manager.try_recv() {\n                if let Some((binding_id, hotkey_string)) = hotkey_to_binding.get(&event.id) {\n                    debug!(\n                        \"handy-keys event: binding={}, hotkey={}, state={:?}\",\n                        binding_id, hotkey_string, event.state\n                    );\n                    let is_pressed = event.state == HotkeyState::Pressed;\n                    handle_shortcut_event(&app, binding_id, hotkey_string, is_pressed);\n                }\n            }\n\n            // Check for commands (non-blocking with timeout)\n            match cmd_rx.recv_timeout(std::time::Duration::from_millis(10)) {\n                Ok(cmd) => match cmd {\n                    ManagerCommand::Register {\n                        binding_id,\n                        hotkey_string,\n                        response,\n                    } => {\n                        let result = Self::do_register(\n                            &manager,\n                            &mut binding_to_hotkey,\n                            &mut hotkey_to_binding,\n                            &binding_id,\n                            &hotkey_string,\n                        );\n                        let _ = response.send(result);\n                    }\n                    ManagerCommand::Unregister {\n                        binding_id,\n                        response,\n                    } => {\n                        let result = Self::do_unregister(\n                            &manager,\n                            &mut binding_to_hotkey,\n                            &mut hotkey_to_binding,\n                            &binding_id,\n                        );\n                        let _ = response.send(result);\n                    }\n                    ManagerCommand::Shutdown => {\n                        info!(\"handy-keys manager thread shutting down\");\n                        break;\n                    }\n                },\n                Err(mpsc::RecvTimeoutError::Timeout) => {\n                    // No command, continue\n                }\n                Err(mpsc::RecvTimeoutError::Disconnected) => {\n                    info!(\"Command channel disconnected, shutting down\");\n                    break;\n                }\n            }\n        }\n\n        info!(\"handy-keys manager thread stopped\");\n    }\n\n    /// Register a hotkey\n    fn do_register(\n        manager: &HotkeyManager,\n        binding_to_hotkey: &mut HashMap<String, HotkeyId>,\n        hotkey_to_binding: &mut HashMap<HotkeyId, (String, String)>,\n        binding_id: &str,\n        hotkey_string: &str,\n    ) -> Result<(), String> {\n        let hotkey: Hotkey = hotkey_string\n            .parse()\n            .map_err(|e| format!(\"Failed to parse hotkey '{}': {}\", hotkey_string, e))?;\n\n        let id = manager\n            .register(hotkey)\n            .map_err(|e| format!(\"Failed to register hotkey: {}\", e))?;\n\n        binding_to_hotkey.insert(binding_id.to_string(), id);\n        hotkey_to_binding.insert(id, (binding_id.to_string(), hotkey_string.to_string()));\n\n        debug!(\n            \"Registered handy-keys shortcut: {} -> {:?}\",\n            binding_id, hotkey\n        );\n        Ok(())\n    }\n\n    /// Unregister a hotkey\n    fn do_unregister(\n        manager: &HotkeyManager,\n        binding_to_hotkey: &mut HashMap<String, HotkeyId>,\n        hotkey_to_binding: &mut HashMap<HotkeyId, (String, String)>,\n        binding_id: &str,\n    ) -> Result<(), String> {\n        if let Some(id) = binding_to_hotkey.remove(binding_id) {\n            manager\n                .unregister(id)\n                .map_err(|e| format!(\"Failed to unregister hotkey: {}\", e))?;\n            hotkey_to_binding.remove(&id);\n            debug!(\"Unregistered handy-keys shortcut: {}\", binding_id);\n        }\n        Ok(())\n    }\n\n    /// Register a shortcut binding\n    pub fn register(&self, binding: &ShortcutBinding) -> Result<(), String> {\n        let (tx, rx) = mpsc::channel();\n        self.command_sender\n            .lock()\n            .map_err(|_| \"Failed to lock command_sender\")?\n            .send(ManagerCommand::Register {\n                binding_id: binding.id.clone(),\n                hotkey_string: binding.current_binding.clone(),\n                response: tx,\n            })\n            .map_err(|_| \"Failed to send register command\")?;\n\n        rx.recv()\n            .map_err(|_| \"Failed to receive register response\")?\n    }\n\n    /// Unregister a shortcut binding\n    pub fn unregister(&self, binding: &ShortcutBinding) -> Result<(), String> {\n        let (tx, rx) = mpsc::channel();\n        self.command_sender\n            .lock()\n            .map_err(|_| \"Failed to lock command_sender\")?\n            .send(ManagerCommand::Unregister {\n                binding_id: binding.id.clone(),\n                response: tx,\n            })\n            .map_err(|_| \"Failed to send unregister command\")?;\n\n        rx.recv()\n            .map_err(|_| \"Failed to receive unregister response\")?\n    }\n\n    /// Start recording mode for a specific binding\n    pub fn start_recording(&self, app: &AppHandle, binding_id: String) -> Result<(), String> {\n        if self.is_recording.load(Ordering::SeqCst) {\n            return Err(\"Already recording\".into());\n        }\n\n        // Create a new keyboard listener for recording\n        let listener = KeyboardListener::new()\n            .map_err(|e| format!(\"Failed to create keyboard listener: {}\", e))?;\n\n        {\n            let mut recording = self\n                .recording_listener\n                .lock()\n                .map_err(|_| \"Failed to lock recording_listener\")?;\n            *recording = Some(listener);\n        }\n        {\n            let mut binding = self\n                .recording_binding_id\n                .lock()\n                .map_err(|_| \"Failed to lock recording_binding_id\")?;\n            *binding = Some(binding_id);\n        }\n\n        self.is_recording.store(true, Ordering::SeqCst);\n        self.recording_running.store(true, Ordering::SeqCst);\n\n        // Start a thread to emit key events to the frontend\n        let app_clone = app.clone();\n        let recording_running = Arc::clone(&self.recording_running);\n        thread::spawn(move || {\n            Self::recording_loop(app_clone, recording_running);\n        });\n\n        debug!(\"Started handy-keys recording mode\");\n        Ok(())\n    }\n\n    /// Recording loop - emits key events to frontend during recording\n    fn recording_loop(app: AppHandle, running: Arc<AtomicBool>) {\n        while running.load(Ordering::SeqCst) {\n            let event = {\n                let state = match app.try_state::<HandyKeysState>() {\n                    Some(s) => s,\n                    None => break,\n                };\n                let listener = state.recording_listener.lock().ok();\n                listener.as_ref().and_then(|l| l.as_ref()?.try_recv())\n            };\n\n            if let Some(key_event) = event {\n                // Convert to frontend-friendly format\n                let frontend_event = FrontendKeyEvent {\n                    modifiers: modifiers_to_strings(key_event.modifiers),\n                    key: key_event.key.map(|k| k.to_string().to_lowercase()),\n                    is_key_down: key_event.is_key_down,\n                    hotkey_string: key_event\n                        .as_hotkey()\n                        .map(|h| h.to_handy_string())\n                        .unwrap_or_default(),\n                };\n\n                // Emit to frontend\n                if let Err(e) = app.emit(\"handy-keys-event\", &frontend_event) {\n                    error!(\"Failed to emit key event: {}\", e);\n                }\n            } else {\n                thread::sleep(std::time::Duration::from_millis(10));\n            }\n        }\n\n        debug!(\"Recording loop ended\");\n    }\n\n    /// Stop recording mode\n    pub fn stop_recording(&self) -> Result<(), String> {\n        self.is_recording.store(false, Ordering::SeqCst);\n        self.recording_running.store(false, Ordering::SeqCst);\n\n        {\n            let mut recording = self\n                .recording_listener\n                .lock()\n                .map_err(|_| \"Failed to lock recording_listener\")?;\n            *recording = None;\n        }\n        {\n            let mut binding = self\n                .recording_binding_id\n                .lock()\n                .map_err(|_| \"Failed to lock recording_binding_id\")?;\n            *binding = None;\n        }\n\n        debug!(\"Stopped handy-keys recording mode\");\n        Ok(())\n    }\n}\n\nimpl Drop for HandyKeysState {\n    fn drop(&mut self) {\n        // Signal recording to stop\n        self.recording_running.store(false, Ordering::SeqCst);\n        self.is_recording.store(false, Ordering::SeqCst);\n\n        // Send shutdown command\n        if let Ok(sender) = self.command_sender.lock() {\n            let _ = sender.send(ManagerCommand::Shutdown);\n        }\n\n        // Wait for the manager thread to finish\n        if let Ok(mut handle) = self.thread_handle.lock() {\n            if let Some(h) = handle.take() {\n                let _ = h.join();\n            }\n        }\n    }\n}\n\n/// Convert handy-keys Modifiers to a list of strings\nfn modifiers_to_strings(modifiers: handy_keys::Modifiers) -> Vec<String> {\n    let mut result = Vec::new();\n\n    if modifiers.contains(handy_keys::Modifiers::CTRL) {\n        result.push(\"ctrl\".to_string());\n    }\n    if modifiers.contains(handy_keys::Modifiers::OPT) {\n        #[cfg(target_os = \"macos\")]\n        result.push(\"option\".to_string());\n        #[cfg(not(target_os = \"macos\"))]\n        result.push(\"alt\".to_string());\n    }\n    if modifiers.contains(handy_keys::Modifiers::SHIFT) {\n        result.push(\"shift\".to_string());\n    }\n    if modifiers.contains(handy_keys::Modifiers::CMD) {\n        #[cfg(target_os = \"macos\")]\n        result.push(\"command\".to_string());\n        #[cfg(not(target_os = \"macos\"))]\n        result.push(\"super\".to_string());\n    }\n    if modifiers.contains(handy_keys::Modifiers::FN) {\n        result.push(\"fn\".to_string());\n    }\n\n    result\n}\n\n/// Validate a shortcut string for the HandyKeys implementation.\n/// HandyKeys is more permissive: allows modifier-only combos and the fn key.\npub fn validate_shortcut(raw: &str) -> Result<(), String> {\n    if raw.trim().is_empty() {\n        return Err(\"Shortcut cannot be empty\".into());\n    }\n    // HandyKeys accepts modifier-only, key-only, and modifier+key combos\n    // Just verify the string is parseable\n    raw.parse::<Hotkey>()\n        .map(|_| ())\n        .map_err(|e| format!(\"Invalid shortcut for HandyKeys: {}\", e))\n}\n\n/// Initialize handy-keys shortcuts\npub fn init_shortcuts(app: &AppHandle) -> Result<(), String> {\n    let state = HandyKeysState::new(app.clone())?;\n\n    let default_bindings = settings::get_default_settings().bindings;\n    let user_settings = settings::load_or_create_app_settings(app);\n\n    // Register all bindings except cancel (which is dynamic)\n    for (id, default_binding) in default_bindings {\n        if id == \"cancel\" {\n            continue;\n        }\n        // Skip post-processing shortcut when the feature is disabled\n        if id == \"transcribe_with_post_process\" && !user_settings.post_process_enabled {\n            continue;\n        }\n\n        let binding = user_settings\n            .bindings\n            .get(&id)\n            .cloned()\n            .unwrap_or(default_binding);\n\n        if let Err(e) = state.register(&binding) {\n            error!(\n                \"Failed to register handy-keys shortcut {} during init: {}\",\n                id, e\n            );\n        }\n    }\n\n    app.manage(state);\n    info!(\"handy-keys shortcuts initialized\");\n    Ok(())\n}\n\n/// Register the cancel shortcut (called when recording starts)\npub fn register_cancel_shortcut(app: &AppHandle) {\n    // Disabled on Linux due to instability\n    #[cfg(target_os = \"linux\")]\n    {\n        let _ = app;\n        return;\n    }\n\n    #[cfg(not(target_os = \"linux\"))]\n    {\n        let app_clone = app.clone();\n        tauri::async_runtime::spawn(async move {\n            if let Some(cancel_binding) = get_settings(&app_clone).bindings.get(\"cancel\").cloned() {\n                if let Some(state) = app_clone.try_state::<HandyKeysState>() {\n                    if let Err(e) = state.register(&cancel_binding) {\n                        error!(\"Failed to register cancel shortcut: {}\", e);\n                    }\n                }\n            }\n        });\n    }\n}\n\n/// Unregister the cancel shortcut (called when recording stops)\npub fn unregister_cancel_shortcut(app: &AppHandle) {\n    #[cfg(target_os = \"linux\")]\n    {\n        let _ = app;\n        return;\n    }\n\n    #[cfg(not(target_os = \"linux\"))]\n    {\n        let app_clone = app.clone();\n        tauri::async_runtime::spawn(async move {\n            if let Some(cancel_binding) = get_settings(&app_clone).bindings.get(\"cancel\").cloned() {\n                if let Some(state) = app_clone.try_state::<HandyKeysState>() {\n                    let _ = state.unregister(&cancel_binding);\n                }\n            }\n        });\n    }\n}\n\n/// Register a shortcut\npub fn register_shortcut(app: &AppHandle, binding: ShortcutBinding) -> Result<(), String> {\n    let state = app\n        .try_state::<HandyKeysState>()\n        .ok_or(\"HandyKeysState not initialized\")?;\n    state.register(&binding)\n}\n\n/// Unregister a shortcut\npub fn unregister_shortcut(app: &AppHandle, binding: ShortcutBinding) -> Result<(), String> {\n    let state = app\n        .try_state::<HandyKeysState>()\n        .ok_or(\"HandyKeysState not initialized\")?;\n    state.unregister(&binding)\n}\n\n/// Start key recording mode\n#[tauri::command]\n#[specta::specta]\npub fn start_handy_keys_recording(app: AppHandle, binding_id: String) -> Result<(), String> {\n    let settings = get_settings(&app);\n    if settings.keyboard_implementation != settings::KeyboardImplementation::HandyKeys {\n        return Err(\"handy-keys is not the active keyboard implementation\".into());\n    }\n\n    let state = app\n        .try_state::<HandyKeysState>()\n        .ok_or(\"HandyKeysState not initialized\")?;\n    state.start_recording(&app, binding_id)\n}\n\n/// Stop key recording mode\n#[tauri::command]\n#[specta::specta]\npub fn stop_handy_keys_recording(app: AppHandle) -> Result<(), String> {\n    let settings = get_settings(&app);\n    if settings.keyboard_implementation != settings::KeyboardImplementation::HandyKeys {\n        return Err(\"handy-keys is not the active keyboard implementation\".into());\n    }\n\n    let state = app\n        .try_state::<HandyKeysState>()\n        .ok_or(\"HandyKeysState not initialized\")?;\n    state.stop_recording()\n}\n"
  },
  {
    "path": "src-tauri/src/shortcut/mod.rs",
    "content": "//! Keyboard shortcut management module\n//!\n//! This module provides a unified interface for keyboard shortcuts with\n//! multiple backend implementations:\n//!\n//! - `tauri`: Uses Tauri's built-in global-shortcut plugin\n//! - `handy_keys`: Uses the handy-keys library for more control\n//!\n//! The active implementation is determined by the `keyboard_implementation`\n//! setting and can be changed at runtime.\n\nmod handler;\npub mod handy_keys;\nmod tauri_impl;\n\nuse log::{error, info, warn};\nuse serde::Serialize;\nuse specta::Type;\nuse tauri::{AppHandle, Emitter, Manager};\nuse tauri_plugin_autostart::ManagerExt;\n\n#[cfg(all(target_os = \"macos\", target_arch = \"aarch64\"))]\nuse crate::settings::APPLE_INTELLIGENCE_DEFAULT_MODEL_ID;\nuse crate::settings::{\n    self, get_settings, AutoSubmitKey, ClipboardHandling, KeyboardImplementation, LLMPrompt,\n    OverlayPosition, PasteMethod, ShortcutBinding, SoundTheme, TypingTool,\n    APPLE_INTELLIGENCE_PROVIDER_ID,\n};\nuse crate::tray;\n\n// Note: Commands are accessed via shortcut::handy_keys:: in lib.rs\n\n/// Initialize shortcuts using the configured implementation\npub fn init_shortcuts(app: &AppHandle) {\n    let user_settings = settings::load_or_create_app_settings(app);\n\n    // Check which implementation to use\n    match user_settings.keyboard_implementation {\n        KeyboardImplementation::Tauri => {\n            tauri_impl::init_shortcuts(app);\n        }\n        KeyboardImplementation::HandyKeys => {\n            if let Err(e) = handy_keys::init_shortcuts(app) {\n                error!(\"Failed to initialize handy-keys shortcuts: {}\", e);\n                // Fall back to Tauri implementation and persist this fallback\n                warn!(\"Falling back to Tauri global shortcut implementation and saving fallback to settings\");\n\n                // Update settings to persist the fallback so we don't retry HandyKeys on next launch\n                let mut settings = settings::get_settings(app);\n                settings.keyboard_implementation = KeyboardImplementation::Tauri;\n                settings::write_settings(app, settings);\n\n                tauri_impl::init_shortcuts(app);\n            }\n        }\n    }\n}\n\n/// Register the cancel shortcut (called when recording starts)\npub fn register_cancel_shortcut(app: &AppHandle) {\n    let settings = get_settings(app);\n    match settings.keyboard_implementation {\n        KeyboardImplementation::Tauri => tauri_impl::register_cancel_shortcut(app),\n        KeyboardImplementation::HandyKeys => handy_keys::register_cancel_shortcut(app),\n    }\n}\n\n/// Unregister the cancel shortcut (called when recording stops)\npub fn unregister_cancel_shortcut(app: &AppHandle) {\n    let settings = get_settings(app);\n    match settings.keyboard_implementation {\n        KeyboardImplementation::Tauri => tauri_impl::unregister_cancel_shortcut(app),\n        KeyboardImplementation::HandyKeys => handy_keys::unregister_cancel_shortcut(app),\n    }\n}\n\n/// Register a shortcut using the appropriate implementation\npub fn register_shortcut(app: &AppHandle, binding: ShortcutBinding) -> Result<(), String> {\n    let settings = get_settings(app);\n    match settings.keyboard_implementation {\n        KeyboardImplementation::Tauri => tauri_impl::register_shortcut(app, binding),\n        KeyboardImplementation::HandyKeys => handy_keys::register_shortcut(app, binding),\n    }\n}\n\n/// Unregister a shortcut using the appropriate implementation\npub fn unregister_shortcut(app: &AppHandle, binding: ShortcutBinding) -> Result<(), String> {\n    let settings = get_settings(app);\n    match settings.keyboard_implementation {\n        KeyboardImplementation::Tauri => tauri_impl::unregister_shortcut(app, binding),\n        KeyboardImplementation::HandyKeys => handy_keys::unregister_shortcut(app, binding),\n    }\n}\n\n// ============================================================================\n// Binding Management Commands\n// ============================================================================\n\n#[derive(Serialize, Type)]\npub struct BindingResponse {\n    success: bool,\n    binding: Option<ShortcutBinding>,\n    error: Option<String>,\n}\n\n#[tauri::command]\n#[specta::specta]\npub fn change_binding(\n    app: AppHandle,\n    id: String,\n    binding: String,\n) -> Result<BindingResponse, String> {\n    // Reject empty bindings — every shortcut should have a value\n    if binding.trim().is_empty() {\n        return Err(\"Binding cannot be empty\".to_string());\n    }\n\n    let mut settings = settings::get_settings(&app);\n\n    // Get the binding to modify, or create it from defaults if it doesn't exist\n    let binding_to_modify = match settings.bindings.get(&id) {\n        Some(binding) => binding.clone(),\n        None => {\n            // Try to get the default binding for this id\n            let default_settings = settings::get_default_settings();\n            match default_settings.bindings.get(&id) {\n                Some(default_binding) => {\n                    warn!(\n                        \"Binding '{}' not found in settings, creating from defaults\",\n                        id\n                    );\n                    default_binding.clone()\n                }\n                None => {\n                    let error_msg = format!(\"Binding with id '{}' not found in defaults\", id);\n                    warn!(\"change_binding error: {}\", error_msg);\n                    return Ok(BindingResponse {\n                        success: false,\n                        binding: None,\n                        error: Some(error_msg),\n                    });\n                }\n            }\n        }\n    };\n\n    // If this is the cancel binding, just update the settings and return\n    // It's managed dynamically, so we don't register/unregister here\n    if id == \"cancel\" {\n        if let Some(mut b) = settings.bindings.get(&id).cloned() {\n            b.current_binding = binding;\n            settings.bindings.insert(id.clone(), b.clone());\n            settings::write_settings(&app, settings);\n            return Ok(BindingResponse {\n                success: true,\n                binding: Some(b.clone()),\n                error: None,\n            });\n        }\n    }\n\n    // Unregister the existing binding\n    if let Err(e) = unregister_shortcut(&app, binding_to_modify.clone()) {\n        let error_msg = format!(\"Failed to unregister shortcut: {}\", e);\n        error!(\"change_binding error: {}\", error_msg);\n    }\n\n    // Validate the new shortcut for the current keyboard implementation\n    if let Err(e) = validate_shortcut_for_implementation(&binding, settings.keyboard_implementation)\n    {\n        warn!(\"change_binding validation error: {}\", e);\n        return Err(e);\n    }\n\n    // Create an updated binding\n    let mut updated_binding = binding_to_modify;\n    updated_binding.current_binding = binding;\n\n    // Register the new binding\n    if let Err(e) = register_shortcut(&app, updated_binding.clone()) {\n        let error_msg = format!(\"Failed to register shortcut: {}\", e);\n        error!(\"change_binding error: {}\", error_msg);\n        return Ok(BindingResponse {\n            success: false,\n            binding: None,\n            error: Some(error_msg),\n        });\n    }\n\n    // Update the binding in the settings\n    settings.bindings.insert(id, updated_binding.clone());\n\n    // Save the settings\n    settings::write_settings(&app, settings);\n\n    // Return the updated binding\n    Ok(BindingResponse {\n        success: true,\n        binding: Some(updated_binding),\n        error: None,\n    })\n}\n\n#[tauri::command]\n#[specta::specta]\npub fn reset_binding(app: AppHandle, id: String) -> Result<BindingResponse, String> {\n    let binding = settings::get_stored_binding(&app, &id);\n    change_binding(app, id, binding.default_binding)\n}\n\n/// Temporarily unregister a binding while the user is editing it in the UI.\n/// This avoids firing the action while keys are being recorded.\n#[tauri::command]\n#[specta::specta]\npub fn suspend_binding(app: AppHandle, id: String) -> Result<(), String> {\n    if let Some(b) = settings::get_bindings(&app).get(&id).cloned() {\n        if let Err(e) = unregister_shortcut(&app, b) {\n            error!(\"suspend_binding error for id '{}': {}\", id, e);\n            return Err(e);\n        }\n    }\n    Ok(())\n}\n\n/// Re-register the binding after the user has finished editing.\n#[tauri::command]\n#[specta::specta]\npub fn resume_binding(app: AppHandle, id: String) -> Result<(), String> {\n    if let Some(b) = settings::get_bindings(&app).get(&id).cloned() {\n        if let Err(e) = register_shortcut(&app, b) {\n            error!(\"resume_binding error for id '{}': {}\", id, e);\n            return Err(e);\n        }\n    }\n    Ok(())\n}\n\n// ============================================================================\n// Keyboard Implementation Switching\n// ============================================================================\n\n/// Result of changing keyboard implementation\n#[derive(Serialize, Type)]\npub struct ImplementationChangeResult {\n    pub success: bool,\n    /// List of binding IDs that were reset to defaults due to incompatibility\n    pub reset_bindings: Vec<String>,\n}\n\n/// Change the keyboard implementation with runtime switching.\n/// This will unregister all shortcuts from the old implementation,\n/// validate shortcuts for the new implementation (resetting invalid ones to defaults),\n/// and register them with the new implementation.\n#[tauri::command]\n#[specta::specta]\npub fn change_keyboard_implementation_setting(\n    app: AppHandle,\n    implementation: String,\n) -> Result<ImplementationChangeResult, String> {\n    let current_settings = settings::get_settings(&app);\n    let current_impl = current_settings.keyboard_implementation;\n    let new_impl = parse_keyboard_implementation(&implementation);\n\n    // If same implementation, nothing to do\n    if current_impl == new_impl {\n        return Ok(ImplementationChangeResult {\n            success: true,\n            reset_bindings: vec![],\n        });\n    }\n\n    info!(\n        \"Switching keyboard implementation from {:?} to {:?}\",\n        current_impl, new_impl\n    );\n\n    // Unregister all shortcuts from the current implementation\n    unregister_all_shortcuts(&app, current_impl);\n\n    // Update the setting\n    let mut settings = settings::get_settings(&app);\n    settings.keyboard_implementation = new_impl;\n    settings::write_settings(&app, settings);\n\n    // Initialize new implementation if needed (HandyKeys needs state)\n    if new_impl == KeyboardImplementation::HandyKeys {\n        if initialize_handy_keys_with_rollback(&app)? {\n            // Shortcuts already registered during init\n            return Ok(ImplementationChangeResult {\n                success: true,\n                reset_bindings: vec![],\n            });\n        }\n    }\n\n    // Register all shortcuts with new implementation, resetting invalid ones\n    let reset_bindings = register_all_shortcuts_for_implementation(&app, new_impl);\n\n    // Emit event to notify frontend of the change\n    let _ = app.emit(\n        \"settings-changed\",\n        serde_json::json!({\n            \"setting\": \"keyboard_implementation\",\n            \"value\": implementation,\n            \"reset_bindings\": reset_bindings\n        }),\n    );\n\n    info!(\"Keyboard implementation switched to {:?}\", new_impl);\n\n    Ok(ImplementationChangeResult {\n        success: true,\n        reset_bindings,\n    })\n}\n\n/// Get the current keyboard implementation\n#[tauri::command]\n#[specta::specta]\npub fn get_keyboard_implementation(app: AppHandle) -> String {\n    let settings = settings::get_settings(&app);\n    match settings.keyboard_implementation {\n        KeyboardImplementation::Tauri => \"tauri\".to_string(),\n        KeyboardImplementation::HandyKeys => \"handy_keys\".to_string(),\n    }\n}\n\n// ============================================================================\n// Validation Helpers\n// ============================================================================\n\n/// Validate a shortcut for a specific implementation\nfn validate_shortcut_for_implementation(\n    raw: &str,\n    implementation: KeyboardImplementation,\n) -> Result<(), String> {\n    match implementation {\n        KeyboardImplementation::Tauri => tauri_impl::validate_shortcut(raw),\n        KeyboardImplementation::HandyKeys => handy_keys::validate_shortcut(raw),\n    }\n}\n\n/// Parse a keyboard implementation string into the enum\nfn parse_keyboard_implementation(s: &str) -> KeyboardImplementation {\n    match s {\n        \"tauri\" => KeyboardImplementation::Tauri,\n        \"handy_keys\" => KeyboardImplementation::HandyKeys,\n        other => {\n            warn!(\n                \"Invalid keyboard implementation '{}', defaulting to tauri\",\n                other\n            );\n            KeyboardImplementation::Tauri\n        }\n    }\n}\n\n/// Unregister all shortcuts for the current implementation\nfn unregister_all_shortcuts(app: &AppHandle, implementation: KeyboardImplementation) {\n    let bindings = settings::get_bindings(app);\n\n    for (id, binding) in bindings {\n        // Skip cancel shortcut as it's dynamically registered\n        if id == \"cancel\" {\n            continue;\n        }\n\n        let result = match implementation {\n            KeyboardImplementation::Tauri => tauri_impl::unregister_shortcut(app, binding),\n            KeyboardImplementation::HandyKeys => handy_keys::unregister_shortcut(app, binding),\n        };\n\n        if let Err(e) = result {\n            warn!(\n                \"Failed to unregister shortcut '{}' during switch: {}\",\n                id, e\n            );\n        }\n    }\n}\n\n/// Register all shortcuts for a specific implementation, validating and resetting invalid ones\nfn register_all_shortcuts_for_implementation(\n    app: &AppHandle,\n    implementation: KeyboardImplementation,\n) -> Vec<String> {\n    let mut reset_bindings = Vec::new();\n    let default_bindings = settings::get_default_settings().bindings;\n    let mut current_settings = settings::get_settings(app);\n\n    for (id, default_binding) in &default_bindings {\n        // Skip cancel shortcut as it's dynamically registered\n        if id == \"cancel\" {\n            continue;\n        }\n\n        // Skip post-processing shortcut when the feature is disabled\n        if id == \"transcribe_with_post_process\" && !current_settings.post_process_enabled {\n            continue;\n        }\n\n        let mut binding = current_settings\n            .bindings\n            .get(id)\n            .cloned()\n            .unwrap_or_else(|| default_binding.clone());\n\n        // Validate the shortcut for the target implementation\n        if let Err(e) =\n            validate_shortcut_for_implementation(&binding.current_binding, implementation)\n        {\n            info!(\n                \"Shortcut '{}' ({}) is invalid for {:?}: {}. Resetting to default.\",\n                id, binding.current_binding, implementation, e\n            );\n\n            // Reset to default\n            binding.current_binding = default_binding.current_binding.clone();\n            current_settings\n                .bindings\n                .insert(id.clone(), binding.clone());\n            reset_bindings.push(id.clone());\n        }\n\n        // Register with the appropriate implementation\n        let result = match implementation {\n            KeyboardImplementation::Tauri => tauri_impl::register_shortcut(app, binding),\n            KeyboardImplementation::HandyKeys => handy_keys::register_shortcut(app, binding),\n        };\n\n        if let Err(e) = result {\n            error!(\n                \"Failed to register shortcut '{}' for {:?}: {}\",\n                id, implementation, e\n            );\n        }\n    }\n\n    // Save settings if any bindings were reset\n    if !reset_bindings.is_empty() {\n        settings::write_settings(app, current_settings);\n    }\n\n    reset_bindings\n}\n\n/// Initialize HandyKeys if not already initialized, with rollback on failure\nfn initialize_handy_keys_with_rollback(app: &AppHandle) -> Result<bool, String> {\n    if app.try_state::<handy_keys::HandyKeysState>().is_some() {\n        return Ok(false); // Already initialized, caller should continue\n    }\n\n    if let Err(e) = handy_keys::init_shortcuts(app) {\n        error!(\"Failed to initialize HandyKeys: {}\", e);\n        // Rollback to Tauri\n        let mut settings = settings::get_settings(app);\n        settings.keyboard_implementation = KeyboardImplementation::Tauri;\n        settings::write_settings(app, settings);\n        tauri_impl::init_shortcuts(app);\n        return Err(format!(\n            \"Failed to initialize HandyKeys: {}. Reverted to Tauri.\",\n            e\n        ));\n    }\n\n    // init_shortcuts already registered shortcuts\n    Ok(true)\n}\n\n// ============================================================================\n// General Settings Commands\n// ============================================================================\n\n#[tauri::command]\n#[specta::specta]\npub fn change_ptt_setting(app: AppHandle, enabled: bool) -> Result<(), String> {\n    let mut settings = settings::get_settings(&app);\n    settings.push_to_talk = enabled;\n    settings::write_settings(&app, settings);\n    Ok(())\n}\n\n#[tauri::command]\n#[specta::specta]\npub fn change_audio_feedback_setting(app: AppHandle, enabled: bool) -> Result<(), String> {\n    let mut settings = settings::get_settings(&app);\n    settings.audio_feedback = enabled;\n    settings::write_settings(&app, settings);\n    Ok(())\n}\n\n#[tauri::command]\n#[specta::specta]\npub fn change_audio_feedback_volume_setting(app: AppHandle, volume: f32) -> Result<(), String> {\n    let mut settings = settings::get_settings(&app);\n    settings.audio_feedback_volume = volume;\n    settings::write_settings(&app, settings);\n    Ok(())\n}\n\n#[tauri::command]\n#[specta::specta]\npub fn change_sound_theme_setting(app: AppHandle, theme: String) -> Result<(), String> {\n    let mut settings = settings::get_settings(&app);\n    let parsed = match theme.as_str() {\n        \"marimba\" => SoundTheme::Marimba,\n        \"pop\" => SoundTheme::Pop,\n        \"custom\" => SoundTheme::Custom,\n        other => {\n            warn!(\"Invalid sound theme '{}', defaulting to marimba\", other);\n            SoundTheme::Marimba\n        }\n    };\n    settings.sound_theme = parsed;\n    settings::write_settings(&app, settings);\n    Ok(())\n}\n\n#[tauri::command]\n#[specta::specta]\npub fn change_translate_to_english_setting(app: AppHandle, enabled: bool) -> Result<(), String> {\n    let mut settings = settings::get_settings(&app);\n    settings.translate_to_english = enabled;\n    settings::write_settings(&app, settings);\n    Ok(())\n}\n\n#[tauri::command]\n#[specta::specta]\npub fn change_selected_language_setting(app: AppHandle, language: String) -> Result<(), String> {\n    let mut settings = settings::get_settings(&app);\n    settings.selected_language = language;\n    settings::write_settings(&app, settings);\n    Ok(())\n}\n\n#[tauri::command]\n#[specta::specta]\npub fn change_overlay_position_setting(app: AppHandle, position: String) -> Result<(), String> {\n    let mut settings = settings::get_settings(&app);\n    let parsed = match position.as_str() {\n        \"none\" => OverlayPosition::None,\n        \"top\" => OverlayPosition::Top,\n        \"bottom\" => OverlayPosition::Bottom,\n        other => {\n            warn!(\"Invalid overlay position '{}', defaulting to bottom\", other);\n            OverlayPosition::Bottom\n        }\n    };\n    settings.overlay_position = parsed;\n    settings::write_settings(&app, settings);\n\n    // Update overlay position without recreating window\n    crate::utils::update_overlay_position(&app);\n\n    Ok(())\n}\n\n#[tauri::command]\n#[specta::specta]\npub fn change_debug_mode_setting(app: AppHandle, enabled: bool) -> Result<(), String> {\n    let mut settings = settings::get_settings(&app);\n    settings.debug_mode = enabled;\n    settings::write_settings(&app, settings);\n\n    // Emit event to notify frontend of debug mode change\n    let _ = app.emit(\n        \"settings-changed\",\n        serde_json::json!({\n            \"setting\": \"debug_mode\",\n            \"value\": enabled\n        }),\n    );\n\n    Ok(())\n}\n\n#[tauri::command]\n#[specta::specta]\npub fn change_start_hidden_setting(app: AppHandle, enabled: bool) -> Result<(), String> {\n    let mut settings = settings::get_settings(&app);\n    settings.start_hidden = enabled;\n    settings::write_settings(&app, settings);\n\n    // Notify frontend\n    let _ = app.emit(\n        \"settings-changed\",\n        serde_json::json!({\n            \"setting\": \"start_hidden\",\n            \"value\": enabled\n        }),\n    );\n\n    Ok(())\n}\n\n#[tauri::command]\n#[specta::specta]\npub fn change_autostart_setting(app: AppHandle, enabled: bool) -> Result<(), String> {\n    let mut settings = settings::get_settings(&app);\n    settings.autostart_enabled = enabled;\n    settings::write_settings(&app, settings);\n\n    // Apply the autostart setting immediately\n    let autostart_manager = app.autolaunch();\n    if enabled {\n        let _ = autostart_manager.enable();\n    } else {\n        let _ = autostart_manager.disable();\n    }\n\n    // Notify frontend\n    let _ = app.emit(\n        \"settings-changed\",\n        serde_json::json!({\n            \"setting\": \"autostart_enabled\",\n            \"value\": enabled\n        }),\n    );\n\n    Ok(())\n}\n\n#[tauri::command]\n#[specta::specta]\npub fn change_update_checks_setting(app: AppHandle, enabled: bool) -> Result<(), String> {\n    let mut settings = settings::get_settings(&app);\n    settings.update_checks_enabled = enabled;\n    settings::write_settings(&app, settings);\n\n    let _ = app.emit(\n        \"settings-changed\",\n        serde_json::json!({\n            \"setting\": \"update_checks_enabled\",\n            \"value\": enabled\n        }),\n    );\n\n    Ok(())\n}\n\n#[tauri::command]\n#[specta::specta]\npub fn update_custom_words(app: AppHandle, words: Vec<String>) -> Result<(), String> {\n    let mut settings = settings::get_settings(&app);\n    settings.custom_words = words;\n    settings::write_settings(&app, settings);\n    Ok(())\n}\n\n#[tauri::command]\n#[specta::specta]\npub fn change_word_correction_threshold_setting(\n    app: AppHandle,\n    threshold: f64,\n) -> Result<(), String> {\n    let mut settings = settings::get_settings(&app);\n    settings.word_correction_threshold = threshold;\n    settings::write_settings(&app, settings);\n    Ok(())\n}\n\n#[tauri::command]\n#[specta::specta]\npub fn change_extra_recording_buffer_setting(app: AppHandle, ms: u64) -> Result<(), String> {\n    let mut settings = settings::get_settings(&app);\n    settings.extra_recording_buffer_ms = ms;\n    settings::write_settings(&app, settings);\n    Ok(())\n}\n\n#[tauri::command]\n#[specta::specta]\npub fn change_paste_method_setting(app: AppHandle, method: String) -> Result<(), String> {\n    let mut settings = settings::get_settings(&app);\n    let parsed = match method.as_str() {\n        \"ctrl_v\" => PasteMethod::CtrlV,\n        \"direct\" => PasteMethod::Direct,\n        \"none\" => PasteMethod::None,\n        \"shift_insert\" => PasteMethod::ShiftInsert,\n        \"ctrl_shift_v\" => PasteMethod::CtrlShiftV,\n        \"external_script\" => PasteMethod::ExternalScript,\n        other => {\n            warn!(\"Invalid paste method '{}', defaulting to ctrl_v\", other);\n            PasteMethod::CtrlV\n        }\n    };\n    settings.paste_method = parsed;\n    settings::write_settings(&app, settings);\n    Ok(())\n}\n\n#[tauri::command]\n#[specta::specta]\npub fn get_available_typing_tools() -> Vec<String> {\n    #[cfg(target_os = \"linux\")]\n    {\n        crate::clipboard::get_available_typing_tools()\n    }\n    #[cfg(not(target_os = \"linux\"))]\n    {\n        vec![\"auto\".to_string()]\n    }\n}\n\n#[tauri::command]\n#[specta::specta]\npub fn change_typing_tool_setting(app: AppHandle, tool: String) -> Result<(), String> {\n    let mut settings = settings::get_settings(&app);\n    let parsed = match tool.as_str() {\n        \"auto\" => TypingTool::Auto,\n        \"wtype\" => TypingTool::Wtype,\n        \"kwtype\" => TypingTool::Kwtype,\n        \"dotool\" => TypingTool::Dotool,\n        \"ydotool\" => TypingTool::Ydotool,\n        \"xdotool\" => TypingTool::Xdotool,\n        other => {\n            warn!(\"Invalid typing tool '{}', defaulting to auto\", other);\n            TypingTool::Auto\n        }\n    };\n    settings.typing_tool = parsed;\n    settings::write_settings(&app, settings);\n    Ok(())\n}\n\n#[tauri::command]\n#[specta::specta]\npub fn change_external_script_path_setting(\n    app: AppHandle,\n    path: Option<String>,\n) -> Result<(), String> {\n    let mut settings = settings::get_settings(&app);\n    settings.external_script_path = path;\n    settings::write_settings(&app, settings);\n    Ok(())\n}\n\n#[tauri::command]\n#[specta::specta]\npub fn change_clipboard_handling_setting(app: AppHandle, handling: String) -> Result<(), String> {\n    let mut settings = settings::get_settings(&app);\n    let parsed = match handling.as_str() {\n        \"dont_modify\" => ClipboardHandling::DontModify,\n        \"copy_to_clipboard\" => ClipboardHandling::CopyToClipboard,\n        other => {\n            warn!(\n                \"Invalid clipboard handling '{}', defaulting to dont_modify\",\n                other\n            );\n            ClipboardHandling::DontModify\n        }\n    };\n    settings.clipboard_handling = parsed;\n    settings::write_settings(&app, settings);\n    Ok(())\n}\n\n#[tauri::command]\n#[specta::specta]\npub fn change_auto_submit_setting(app: AppHandle, enabled: bool) -> Result<(), String> {\n    let mut settings = settings::get_settings(&app);\n    settings.auto_submit = enabled;\n    settings::write_settings(&app, settings);\n    Ok(())\n}\n\n#[tauri::command]\n#[specta::specta]\npub fn change_auto_submit_key_setting(app: AppHandle, key: String) -> Result<(), String> {\n    let mut settings = settings::get_settings(&app);\n    let parsed = match key.as_str() {\n        \"enter\" => AutoSubmitKey::Enter,\n        \"ctrl_enter\" => AutoSubmitKey::CtrlEnter,\n        \"cmd_enter\" => AutoSubmitKey::CmdEnter,\n        other => {\n            warn!(\"Invalid auto submit key '{}', defaulting to enter\", other);\n            AutoSubmitKey::Enter\n        }\n    };\n    settings.auto_submit_key = parsed;\n    settings::write_settings(&app, settings);\n    Ok(())\n}\n\n#[tauri::command]\n#[specta::specta]\npub fn change_post_process_enabled_setting(app: AppHandle, enabled: bool) -> Result<(), String> {\n    let mut settings = settings::get_settings(&app);\n    settings.post_process_enabled = enabled;\n    settings::write_settings(&app, settings.clone());\n\n    // Register or unregister the post-processing shortcut\n    if let Some(binding) = settings\n        .bindings\n        .get(\"transcribe_with_post_process\")\n        .cloned()\n    {\n        if enabled {\n            let _ = register_shortcut(&app, binding);\n        } else {\n            let _ = unregister_shortcut(&app, binding);\n        }\n    }\n\n    Ok(())\n}\n\n#[tauri::command]\n#[specta::specta]\npub fn change_experimental_enabled_setting(app: AppHandle, enabled: bool) -> Result<(), String> {\n    let mut settings = settings::get_settings(&app);\n    settings.experimental_enabled = enabled;\n    settings::write_settings(&app, settings);\n    Ok(())\n}\n\n#[tauri::command]\n#[specta::specta]\npub fn change_post_process_base_url_setting(\n    app: AppHandle,\n    provider_id: String,\n    base_url: String,\n) -> Result<(), String> {\n    let mut settings = settings::get_settings(&app);\n    let label = settings\n        .post_process_provider(&provider_id)\n        .map(|provider| provider.label.clone())\n        .ok_or_else(|| format!(\"Provider '{}' not found\", provider_id))?;\n\n    let provider = settings\n        .post_process_provider_mut(&provider_id)\n        .expect(\"Provider looked up above must exist\");\n\n    if provider.id != \"custom\" {\n        return Err(format!(\n            \"Provider '{}' does not allow editing the base URL\",\n            label\n        ));\n    }\n\n    provider.base_url = base_url;\n    settings::write_settings(&app, settings);\n    Ok(())\n}\n\n/// Generic helper to validate provider exists\nfn validate_provider_exists(\n    settings: &settings::AppSettings,\n    provider_id: &str,\n) -> Result<(), String> {\n    if !settings\n        .post_process_providers\n        .iter()\n        .any(|provider| provider.id == provider_id)\n    {\n        return Err(format!(\"Provider '{}' not found\", provider_id));\n    }\n    Ok(())\n}\n\n#[tauri::command]\n#[specta::specta]\npub fn change_post_process_api_key_setting(\n    app: AppHandle,\n    provider_id: String,\n    api_key: String,\n) -> Result<(), String> {\n    let mut settings = settings::get_settings(&app);\n    validate_provider_exists(&settings, &provider_id)?;\n    settings.post_process_api_keys.insert(provider_id, api_key);\n    settings::write_settings(&app, settings);\n    Ok(())\n}\n\n#[tauri::command]\n#[specta::specta]\npub fn change_post_process_model_setting(\n    app: AppHandle,\n    provider_id: String,\n    model: String,\n) -> Result<(), String> {\n    let mut settings = settings::get_settings(&app);\n    validate_provider_exists(&settings, &provider_id)?;\n    settings.post_process_models.insert(provider_id, model);\n    settings::write_settings(&app, settings);\n    Ok(())\n}\n\n#[tauri::command]\n#[specta::specta]\npub fn set_post_process_provider(app: AppHandle, provider_id: String) -> Result<(), String> {\n    let mut settings = settings::get_settings(&app);\n    validate_provider_exists(&settings, &provider_id)?;\n    settings.post_process_provider_id = provider_id;\n    settings::write_settings(&app, settings);\n    Ok(())\n}\n\n#[tauri::command]\n#[specta::specta]\npub fn add_post_process_prompt(\n    app: AppHandle,\n    name: String,\n    prompt: String,\n) -> Result<LLMPrompt, String> {\n    let mut settings = settings::get_settings(&app);\n\n    // Generate unique ID using timestamp and random component\n    let id = format!(\"prompt_{}\", chrono::Utc::now().timestamp_millis());\n\n    let new_prompt = LLMPrompt {\n        id: id.clone(),\n        name,\n        prompt,\n    };\n\n    settings.post_process_prompts.push(new_prompt.clone());\n    settings::write_settings(&app, settings);\n\n    Ok(new_prompt)\n}\n\n#[tauri::command]\n#[specta::specta]\npub fn update_post_process_prompt(\n    app: AppHandle,\n    id: String,\n    name: String,\n    prompt: String,\n) -> Result<(), String> {\n    let mut settings = settings::get_settings(&app);\n\n    if let Some(existing_prompt) = settings\n        .post_process_prompts\n        .iter_mut()\n        .find(|p| p.id == id)\n    {\n        existing_prompt.name = name;\n        existing_prompt.prompt = prompt;\n        settings::write_settings(&app, settings);\n        Ok(())\n    } else {\n        Err(format!(\"Prompt with id '{}' not found\", id))\n    }\n}\n\n#[tauri::command]\n#[specta::specta]\npub fn delete_post_process_prompt(app: AppHandle, id: String) -> Result<(), String> {\n    let mut settings = settings::get_settings(&app);\n\n    // Don't allow deleting the last prompt\n    if settings.post_process_prompts.len() <= 1 {\n        return Err(\"Cannot delete the last prompt\".to_string());\n    }\n\n    // Find and remove the prompt\n    let original_len = settings.post_process_prompts.len();\n    settings.post_process_prompts.retain(|p| p.id != id);\n\n    if settings.post_process_prompts.len() == original_len {\n        return Err(format!(\"Prompt with id '{}' not found\", id));\n    }\n\n    // If the deleted prompt was selected, select the first one or None\n    if settings.post_process_selected_prompt_id.as_ref() == Some(&id) {\n        settings.post_process_selected_prompt_id =\n            settings.post_process_prompts.first().map(|p| p.id.clone());\n    }\n\n    settings::write_settings(&app, settings);\n    Ok(())\n}\n\n#[tauri::command]\n#[specta::specta]\npub async fn fetch_post_process_models(\n    app: AppHandle,\n    provider_id: String,\n) -> Result<Vec<String>, String> {\n    let settings = settings::get_settings(&app);\n\n    // Find the provider\n    let provider = settings\n        .post_process_providers\n        .iter()\n        .find(|p| p.id == provider_id)\n        .ok_or_else(|| format!(\"Provider '{}' not found\", provider_id))?;\n\n    if provider.id == APPLE_INTELLIGENCE_PROVIDER_ID {\n        #[cfg(all(target_os = \"macos\", target_arch = \"aarch64\"))]\n        {\n            return Ok(vec![APPLE_INTELLIGENCE_DEFAULT_MODEL_ID.to_string()]);\n        }\n\n        #[cfg(not(all(target_os = \"macos\", target_arch = \"aarch64\")))]\n        {\n            return Err(\"Apple Intelligence is only available on Apple silicon Macs running macOS 15 or later.\".to_string());\n        }\n    }\n\n    // Get API key\n    let api_key = settings\n        .post_process_api_keys\n        .get(&provider_id)\n        .cloned()\n        .unwrap_or_default();\n\n    // Skip fetching if no API key for providers that typically need one\n    if api_key.trim().is_empty() && provider.id != \"custom\" {\n        return Err(format!(\n            \"API key is required for {}. Please add an API key to list available models.\",\n            provider.label\n        ));\n    }\n\n    crate::llm_client::fetch_models(provider, api_key).await\n}\n\n#[tauri::command]\n#[specta::specta]\npub fn set_post_process_selected_prompt(app: AppHandle, id: String) -> Result<(), String> {\n    let mut settings = settings::get_settings(&app);\n\n    // Verify the prompt exists\n    if !settings.post_process_prompts.iter().any(|p| p.id == id) {\n        return Err(format!(\"Prompt with id '{}' not found\", id));\n    }\n\n    settings.post_process_selected_prompt_id = Some(id);\n    settings::write_settings(&app, settings);\n    Ok(())\n}\n\n#[tauri::command]\n#[specta::specta]\npub fn change_mute_while_recording_setting(app: AppHandle, enabled: bool) -> Result<(), String> {\n    let mut settings = settings::get_settings(&app);\n    settings.mute_while_recording = enabled;\n    settings::write_settings(&app, settings);\n    Ok(())\n}\n\n#[tauri::command]\n#[specta::specta]\npub fn change_append_trailing_space_setting(app: AppHandle, enabled: bool) -> Result<(), String> {\n    let mut settings = settings::get_settings(&app);\n    settings.append_trailing_space = enabled;\n    settings::write_settings(&app, settings);\n    Ok(())\n}\n\n#[tauri::command]\n#[specta::specta]\npub fn change_lazy_stream_close_setting(app: AppHandle, enabled: bool) -> Result<(), String> {\n    let mut settings = settings::get_settings(&app);\n    settings.lazy_stream_close = enabled;\n    settings::write_settings(&app, settings);\n    Ok(())\n}\n\n#[tauri::command]\n#[specta::specta]\npub fn change_app_language_setting(app: AppHandle, language: String) -> Result<(), String> {\n    let mut settings = settings::get_settings(&app);\n    settings.app_language = language.clone();\n    settings::write_settings(&app, settings);\n\n    // Refresh the tray menu with the new language\n    tray::update_tray_menu(&app, &tray::TrayIconState::Idle, Some(&language));\n\n    Ok(())\n}\n\n#[tauri::command]\n#[specta::specta]\npub fn change_show_tray_icon_setting(app: AppHandle, enabled: bool) -> Result<(), String> {\n    let mut settings = settings::get_settings(&app);\n    settings.show_tray_icon = enabled;\n    settings::write_settings(&app, settings);\n\n    // Apply change immediately\n    tray::set_tray_visibility(&app, enabled);\n\n    Ok(())\n}\n\n/// Save accelerator settings, re-apply globals, and unload the model so it\n/// reloads with the new backend on next transcription.\nfn apply_and_reload_accelerator(app: &AppHandle, s: settings::AppSettings) {\n    settings::write_settings(app, s);\n    crate::managers::transcription::apply_accelerator_settings(app);\n\n    let tm = app.state::<std::sync::Arc<crate::managers::transcription::TranscriptionManager>>();\n    if tm.is_model_loaded() {\n        if let Err(e) = tm.unload_model() {\n            log::warn!(\"Failed to unload model after accelerator change: {e}\");\n        }\n    }\n}\n\n#[tauri::command]\n#[specta::specta]\npub fn change_whisper_accelerator_setting(\n    app: AppHandle,\n    accelerator: settings::WhisperAcceleratorSetting,\n) -> Result<(), String> {\n    let mut s = settings::get_settings(&app);\n    s.whisper_accelerator = accelerator;\n    apply_and_reload_accelerator(&app, s);\n    Ok(())\n}\n\n#[tauri::command]\n#[specta::specta]\npub fn change_ort_accelerator_setting(\n    app: AppHandle,\n    accelerator: settings::OrtAcceleratorSetting,\n) -> Result<(), String> {\n    let mut s = settings::get_settings(&app);\n    s.ort_accelerator = accelerator;\n    apply_and_reload_accelerator(&app, s);\n    Ok(())\n}\n\n/// Return which ORT accelerators are compiled into this build.\n#[tauri::command]\n#[specta::specta]\npub fn get_available_accelerators() -> crate::managers::transcription::AvailableAccelerators {\n    crate::managers::transcription::get_available_accelerators()\n}\n"
  },
  {
    "path": "src-tauri/src/shortcut/tauri_impl.rs",
    "content": "//! Tauri global-shortcut implementation\n//!\n//! This module provides shortcut functionality using Tauri's built-in\n//! global-shortcut plugin.\n\nuse log::{error, warn};\nuse tauri::AppHandle;\nuse tauri_plugin_global_shortcut::{GlobalShortcutExt, Shortcut, ShortcutState};\n\n#[cfg(not(target_os = \"linux\"))]\nuse crate::settings::get_settings;\nuse crate::settings::{self, ShortcutBinding};\n\nuse super::handler::handle_shortcut_event;\n\n/// Initialize shortcuts using Tauri's global-shortcut plugin\npub fn init_shortcuts(app: &AppHandle) {\n    let default_bindings = settings::get_default_settings().bindings;\n    let user_settings = settings::load_or_create_app_settings(app);\n\n    // Register all default shortcuts, applying user customizations\n    for (id, default_binding) in default_bindings {\n        if id == \"cancel\" {\n            continue; // Skip cancel shortcut, it will be registered dynamically\n        }\n        // Skip post-processing shortcut when the feature is disabled\n        if id == \"transcribe_with_post_process\" && !user_settings.post_process_enabled {\n            continue;\n        }\n        let binding = user_settings\n            .bindings\n            .get(&id)\n            .cloned()\n            .unwrap_or(default_binding);\n\n        if let Err(e) = register_shortcut(app, binding) {\n            error!(\"Failed to register shortcut {} during init: {}\", id, e);\n        }\n    }\n}\n\n/// Validate a shortcut string for the Tauri global-shortcut implementation.\n/// Tauri requires at least one non-modifier key and doesn't support the fn key.\npub fn validate_shortcut(raw: &str) -> Result<(), String> {\n    if raw.trim().is_empty() {\n        return Err(\"Shortcut cannot be empty\".into());\n    }\n\n    let modifiers = [\n        \"ctrl\", \"control\", \"shift\", \"alt\", \"option\", \"meta\", \"command\", \"cmd\", \"super\", \"win\",\n        \"windows\",\n    ];\n\n    // Check for fn key which Tauri doesn't support\n    let parts: Vec<String> = raw.split('+').map(|p| p.trim().to_lowercase()).collect();\n    for part in &parts {\n        if part == \"fn\" || part == \"function\" {\n            return Err(\"The 'fn' key is not supported by Tauri global shortcuts\".into());\n        }\n    }\n\n    // Check for at least one non-modifier key\n    let has_non_modifier = parts.iter().any(|part| !modifiers.contains(&part.as_str()));\n\n    if has_non_modifier {\n        Ok(())\n    } else {\n        Err(\"Tauri shortcuts must include a main key (letter, number, F-key, etc.) in addition to modifiers\".into())\n    }\n}\n\n/// Register a shortcut using Tauri's global-shortcut plugin\npub fn register_shortcut(app: &AppHandle, binding: ShortcutBinding) -> Result<(), String> {\n    // Validate for Tauri requirements\n    if let Err(e) = validate_shortcut(&binding.current_binding) {\n        warn!(\n            \"register_tauri_shortcut validation error for binding '{}': {}\",\n            binding.current_binding, e\n        );\n        return Err(e);\n    }\n\n    // Parse shortcut and return error if it fails\n    let shortcut = match binding.current_binding.parse::<Shortcut>() {\n        Ok(s) => s,\n        Err(e) => {\n            let error_msg = format!(\n                \"Failed to parse shortcut '{}': {}\",\n                binding.current_binding, e\n            );\n            error!(\"register_tauri_shortcut parse error: {}\", error_msg);\n            return Err(error_msg);\n        }\n    };\n\n    // Prevent duplicate registrations that would silently shadow one another\n    if app.global_shortcut().is_registered(shortcut) {\n        let error_msg = format!(\"Shortcut '{}' is already in use\", binding.current_binding);\n        warn!(\"register_tauri_shortcut duplicate error: {}\", error_msg);\n        return Err(error_msg);\n    }\n\n    // Clone binding.id for use in the closure\n    let binding_id_for_closure = binding.id.clone();\n\n    app.global_shortcut()\n        .on_shortcut(shortcut, move |app_handle, scut, event| {\n            if scut == &shortcut {\n                let shortcut_string = scut.into_string();\n                let is_pressed = event.state == ShortcutState::Pressed;\n                handle_shortcut_event(\n                    app_handle,\n                    &binding_id_for_closure,\n                    &shortcut_string,\n                    is_pressed,\n                );\n            }\n        })\n        .map_err(|e| {\n            let error_msg = format!(\n                \"Couldn't register shortcut '{}': {}\",\n                binding.current_binding, e\n            );\n            error!(\"register_tauri_shortcut registration error: {}\", error_msg);\n            error_msg\n        })?;\n\n    Ok(())\n}\n\n/// Unregister a shortcut from Tauri's global-shortcut plugin\npub fn unregister_shortcut(app: &AppHandle, binding: ShortcutBinding) -> Result<(), String> {\n    let shortcut = match binding.current_binding.parse::<Shortcut>() {\n        Ok(s) => s,\n        Err(e) => {\n            let error_msg = format!(\n                \"Failed to parse shortcut '{}' for unregistration: {}\",\n                binding.current_binding, e\n            );\n            error!(\"unregister_tauri_shortcut parse error: {}\", error_msg);\n            return Err(error_msg);\n        }\n    };\n\n    app.global_shortcut().unregister(shortcut).map_err(|e| {\n        let error_msg = format!(\n            \"Failed to unregister shortcut '{}': {}\",\n            binding.current_binding, e\n        );\n        error!(\"unregister_tauri_shortcut error: {}\", error_msg);\n        error_msg\n    })?;\n\n    Ok(())\n}\n\n/// Register the cancel shortcut (called when recording starts)\npub fn register_cancel_shortcut(app: &AppHandle) {\n    // Cancel shortcut is disabled on Linux due to instability with dynamic shortcut registration\n    #[cfg(target_os = \"linux\")]\n    {\n        let _ = app;\n        return;\n    }\n\n    #[cfg(not(target_os = \"linux\"))]\n    {\n        let app_clone = app.clone();\n        tauri::async_runtime::spawn(async move {\n            if let Some(cancel_binding) = get_settings(&app_clone).bindings.get(\"cancel\").cloned() {\n                if let Err(e) = register_shortcut(&app_clone, cancel_binding) {\n                    error!(\"Failed to register cancel shortcut: {}\", e);\n                }\n            }\n        });\n    }\n}\n\n/// Unregister the cancel shortcut (called when recording stops)\npub fn unregister_cancel_shortcut(app: &AppHandle) {\n    // Cancel shortcut is disabled on Linux due to instability with dynamic shortcut registration\n    #[cfg(target_os = \"linux\")]\n    {\n        let _ = app;\n        return;\n    }\n\n    #[cfg(not(target_os = \"linux\"))]\n    {\n        let app_clone = app.clone();\n        tauri::async_runtime::spawn(async move {\n            if let Some(cancel_binding) = get_settings(&app_clone).bindings.get(\"cancel\").cloned() {\n                // We ignore errors here as it might already be unregistered\n                let _ = unregister_shortcut(&app_clone, cancel_binding);\n            }\n        });\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/signal_handle.rs",
    "content": "use crate::TranscriptionCoordinator;\n#[cfg(unix)]\nuse log::debug;\nuse log::warn;\nuse tauri::{AppHandle, Manager};\n\n#[cfg(unix)]\nuse signal_hook::consts::{SIGUSR1, SIGUSR2};\n#[cfg(unix)]\nuse signal_hook::iterator::Signals;\n#[cfg(unix)]\nuse std::thread;\n\n/// Send a transcription input to the coordinator.\n/// Used by signal handlers, CLI flags, and any other external trigger.\npub fn send_transcription_input(app: &AppHandle, binding_id: &str, source: &str) {\n    if let Some(c) = app.try_state::<TranscriptionCoordinator>() {\n        c.send_input(binding_id, source, true, false);\n    } else {\n        warn!(\"TranscriptionCoordinator not initialized\");\n    }\n}\n\n#[cfg(unix)]\npub fn setup_signal_handler(app_handle: AppHandle, mut signals: Signals) {\n    debug!(\"Signal handlers registered (SIGUSR1, SIGUSR2)\");\n    thread::spawn(move || {\n        for sig in signals.forever() {\n            let (binding_id, signal_name) = match sig {\n                SIGUSR1 => (\"transcribe_with_post_process\", \"SIGUSR1\"),\n                SIGUSR2 => (\"transcribe\", \"SIGUSR2\"),\n                _ => continue,\n            };\n            debug!(\"Received {signal_name}\");\n            send_transcription_input(&app_handle, binding_id, signal_name);\n        }\n    });\n}\n"
  },
  {
    "path": "src-tauri/src/transcription_coordinator.rs",
    "content": "use crate::actions::ACTION_MAP;\nuse crate::managers::audio::AudioRecordingManager;\nuse log::{debug, error, warn};\nuse std::sync::mpsc::{self, Sender};\nuse std::sync::Arc;\nuse std::thread;\nuse std::time::{Duration, Instant};\nuse tauri::{AppHandle, Manager};\n\nconst DEBOUNCE: Duration = Duration::from_millis(30);\n\n/// Commands processed sequentially by the coordinator thread.\nenum Command {\n    Input {\n        binding_id: String,\n        hotkey_string: String,\n        is_pressed: bool,\n        push_to_talk: bool,\n    },\n    Cancel {\n        recording_was_active: bool,\n    },\n    ProcessingFinished,\n}\n\n/// Pipeline lifecycle, owned exclusively by the coordinator thread.\nenum Stage {\n    Idle,\n    Recording(String), // binding_id\n    Processing,\n}\n\n/// Serialises all transcription lifecycle events through a single thread\n/// to eliminate race conditions between keyboard shortcuts, signals, and\n/// the async transcribe-paste pipeline.\npub struct TranscriptionCoordinator {\n    tx: Sender<Command>,\n}\n\npub fn is_transcribe_binding(id: &str) -> bool {\n    id == \"transcribe\" || id == \"transcribe_with_post_process\"\n}\n\nimpl TranscriptionCoordinator {\n    pub fn new(app: AppHandle) -> Self {\n        let (tx, rx) = mpsc::channel();\n\n        thread::spawn(move || {\n            let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {\n                let mut stage = Stage::Idle;\n                let mut last_press: Option<Instant> = None;\n\n                while let Ok(cmd) = rx.recv() {\n                    match cmd {\n                        Command::Input {\n                            binding_id,\n                            hotkey_string,\n                            is_pressed,\n                            push_to_talk,\n                        } => {\n                            // Debounce rapid-fire press events (key repeat / double-tap).\n                            // Releases always pass through for push-to-talk.\n                            if is_pressed {\n                                let now = Instant::now();\n                                if last_press.map_or(false, |t| now.duration_since(t) < DEBOUNCE) {\n                                    debug!(\"Debounced press for '{binding_id}'\");\n                                    continue;\n                                }\n                                last_press = Some(now);\n                            }\n\n                            if push_to_talk {\n                                if is_pressed && matches!(stage, Stage::Idle) {\n                                    start(&app, &mut stage, &binding_id, &hotkey_string);\n                                } else if !is_pressed\n                                    && matches!(&stage, Stage::Recording(id) if id == &binding_id)\n                                {\n                                    stop(&app, &mut stage, &binding_id, &hotkey_string);\n                                }\n                            } else if is_pressed {\n                                match &stage {\n                                    Stage::Idle => {\n                                        start(&app, &mut stage, &binding_id, &hotkey_string);\n                                    }\n                                    Stage::Recording(id) if id == &binding_id => {\n                                        stop(&app, &mut stage, &binding_id, &hotkey_string);\n                                    }\n                                    _ => {\n                                        debug!(\"Ignoring press for '{binding_id}': pipeline busy\")\n                                    }\n                                }\n                            }\n                        }\n                        Command::Cancel {\n                            recording_was_active,\n                        } => {\n                            // Don't reset during processing — wait for the pipeline to finish.\n                            if !matches!(stage, Stage::Processing)\n                                && (recording_was_active || matches!(stage, Stage::Recording(_)))\n                            {\n                                stage = Stage::Idle;\n                            }\n                        }\n                        Command::ProcessingFinished => {\n                            stage = Stage::Idle;\n                        }\n                    }\n                }\n                debug!(\"Transcription coordinator exited\");\n            }));\n            if let Err(e) = result {\n                error!(\"Transcription coordinator panicked: {e:?}\");\n            }\n        });\n\n        Self { tx }\n    }\n\n    /// Send a keyboard/signal input event for a transcribe binding.\n    /// For signal-based toggles, use `is_pressed: true` and `push_to_talk: false`.\n    pub fn send_input(\n        &self,\n        binding_id: &str,\n        hotkey_string: &str,\n        is_pressed: bool,\n        push_to_talk: bool,\n    ) {\n        if self\n            .tx\n            .send(Command::Input {\n                binding_id: binding_id.to_string(),\n                hotkey_string: hotkey_string.to_string(),\n                is_pressed,\n                push_to_talk,\n            })\n            .is_err()\n        {\n            warn!(\"Transcription coordinator channel closed\");\n        }\n    }\n\n    pub fn notify_cancel(&self, recording_was_active: bool) {\n        if self\n            .tx\n            .send(Command::Cancel {\n                recording_was_active,\n            })\n            .is_err()\n        {\n            warn!(\"Transcription coordinator channel closed\");\n        }\n    }\n\n    pub fn notify_processing_finished(&self) {\n        if self.tx.send(Command::ProcessingFinished).is_err() {\n            warn!(\"Transcription coordinator channel closed\");\n        }\n    }\n}\n\nfn start(app: &AppHandle, stage: &mut Stage, binding_id: &str, hotkey_string: &str) {\n    let Some(action) = ACTION_MAP.get(binding_id) else {\n        warn!(\"No action in ACTION_MAP for '{binding_id}'\");\n        return;\n    };\n    action.start(app, binding_id, hotkey_string);\n    if app\n        .try_state::<Arc<AudioRecordingManager>>()\n        .map_or(false, |a| a.is_recording())\n    {\n        *stage = Stage::Recording(binding_id.to_string());\n    } else {\n        debug!(\"Start for '{binding_id}' did not begin recording; staying idle\");\n    }\n}\n\nfn stop(app: &AppHandle, stage: &mut Stage, binding_id: &str, hotkey_string: &str) {\n    let Some(action) = ACTION_MAP.get(binding_id) else {\n        warn!(\"No action in ACTION_MAP for '{binding_id}'\");\n        return;\n    };\n    action.stop(app, binding_id, hotkey_string);\n    *stage = Stage::Processing;\n}\n"
  },
  {
    "path": "src-tauri/src/tray.rs",
    "content": "use crate::managers::history::{HistoryEntry, HistoryManager};\nuse crate::managers::model::ModelManager;\nuse crate::managers::transcription::TranscriptionManager;\nuse crate::settings;\nuse crate::tray_i18n::get_tray_translations;\nuse log::{error, info, warn};\nuse std::sync::Arc;\nuse tauri::image::Image;\nuse tauri::menu::{CheckMenuItem, Menu, MenuItem, PredefinedMenuItem, Submenu};\nuse tauri::tray::TrayIcon;\nuse tauri::{AppHandle, Manager, Theme};\nuse tauri_plugin_clipboard_manager::ClipboardExt;\n\n#[derive(Clone, Debug, PartialEq)]\npub enum TrayIconState {\n    Idle,\n    Recording,\n    Transcribing,\n}\n\n#[derive(Clone, Debug, PartialEq)]\npub enum AppTheme {\n    Dark,\n    Light,\n    Colored, // Pink/colored theme for Linux\n}\n\n/// Gets the current app theme, with Linux defaulting to Colored theme\npub fn get_current_theme(app: &AppHandle) -> AppTheme {\n    if cfg!(target_os = \"linux\") {\n        // On Linux, always use the colored theme\n        AppTheme::Colored\n    } else {\n        // On other platforms, map system theme to our app theme\n        if let Some(main_window) = app.get_webview_window(\"main\") {\n            match main_window.theme().unwrap_or(Theme::Dark) {\n                Theme::Light => AppTheme::Light,\n                Theme::Dark => AppTheme::Dark,\n                _ => AppTheme::Dark, // Default fallback\n            }\n        } else {\n            AppTheme::Dark\n        }\n    }\n}\n\n/// Gets the appropriate icon path for the given theme and state\npub fn get_icon_path(theme: AppTheme, state: TrayIconState) -> &'static str {\n    match (theme, state) {\n        // Dark theme uses light icons\n        (AppTheme::Dark, TrayIconState::Idle) => \"resources/tray_idle.png\",\n        (AppTheme::Dark, TrayIconState::Recording) => \"resources/tray_recording.png\",\n        (AppTheme::Dark, TrayIconState::Transcribing) => \"resources/tray_transcribing.png\",\n        // Light theme uses dark icons\n        (AppTheme::Light, TrayIconState::Idle) => \"resources/tray_idle_dark.png\",\n        (AppTheme::Light, TrayIconState::Recording) => \"resources/tray_recording_dark.png\",\n        (AppTheme::Light, TrayIconState::Transcribing) => \"resources/tray_transcribing_dark.png\",\n        // Colored theme uses pink icons (for Linux)\n        (AppTheme::Colored, TrayIconState::Idle) => \"resources/handy.png\",\n        (AppTheme::Colored, TrayIconState::Recording) => \"resources/recording.png\",\n        (AppTheme::Colored, TrayIconState::Transcribing) => \"resources/transcribing.png\",\n    }\n}\n\npub fn change_tray_icon(app: &AppHandle, icon: TrayIconState) {\n    let tray = app.state::<TrayIcon>();\n    let theme = get_current_theme(app);\n\n    let icon_path = get_icon_path(theme, icon.clone());\n\n    let _ = tray.set_icon(Some(\n        Image::from_path(\n            app.path()\n                .resolve(icon_path, tauri::path::BaseDirectory::Resource)\n                .expect(\"failed to resolve\"),\n        )\n        .expect(\"failed to set icon\"),\n    ));\n\n    // Update menu based on state\n    update_tray_menu(app, &icon, None);\n}\n\npub fn update_tray_menu(app: &AppHandle, state: &TrayIconState, locale: Option<&str>) {\n    let settings = settings::get_settings(app);\n\n    let locale = locale.unwrap_or(&settings.app_language);\n    let strings = get_tray_translations(Some(locale.to_string()));\n\n    // Platform-specific accelerators\n    #[cfg(target_os = \"macos\")]\n    let (settings_accelerator, quit_accelerator) = (Some(\"Cmd+,\"), Some(\"Cmd+Q\"));\n    #[cfg(not(target_os = \"macos\"))]\n    let (settings_accelerator, quit_accelerator) = (Some(\"Ctrl+,\"), Some(\"Ctrl+Q\"));\n\n    // Create common menu items\n    let version_label = if cfg!(debug_assertions) {\n        format!(\"Handy v{} (Dev)\", env!(\"CARGO_PKG_VERSION\"))\n    } else {\n        format!(\"Handy v{}\", env!(\"CARGO_PKG_VERSION\"))\n    };\n    let version_i = MenuItem::with_id(app, \"version\", &version_label, false, None::<&str>)\n        .expect(\"failed to create version item\");\n    let settings_i = MenuItem::with_id(\n        app,\n        \"settings\",\n        &strings.settings,\n        true,\n        settings_accelerator,\n    )\n    .expect(\"failed to create settings item\");\n    let check_updates_i = MenuItem::with_id(\n        app,\n        \"check_updates\",\n        &strings.check_updates,\n        settings.update_checks_enabled,\n        None::<&str>,\n    )\n    .expect(\"failed to create check updates item\");\n    let copy_last_transcript_i = MenuItem::with_id(\n        app,\n        \"copy_last_transcript\",\n        &strings.copy_last_transcript,\n        true,\n        None::<&str>,\n    )\n    .expect(\"failed to create copy last transcript item\");\n    let model_loaded = app.state::<Arc<TranscriptionManager>>().is_model_loaded();\n    let quit_i = MenuItem::with_id(app, \"quit\", &strings.quit, true, quit_accelerator)\n        .expect(\"failed to create quit item\");\n    let separator = || PredefinedMenuItem::separator(app).expect(\"failed to create separator\");\n\n    // Build model submenu — label is the active model name\n    let model_manager = app.state::<Arc<ModelManager>>();\n    let models = model_manager.get_available_models();\n    let current_model_id = &settings.selected_model;\n\n    let mut downloaded: Vec<_> = models.into_iter().filter(|m| m.is_downloaded).collect();\n    downloaded.sort_by(|a, b| a.name.cmp(&b.name));\n\n    let submenu_label = downloaded\n        .iter()\n        .find(|m| m.id == *current_model_id)\n        .map(|m| m.name.clone())\n        .unwrap_or_else(|| strings.model.clone());\n\n    let model_submenu = {\n        let submenu = Submenu::with_id(app, \"model_submenu\", &submenu_label, true)\n            .expect(\"failed to create model submenu\");\n\n        for model in &downloaded {\n            let is_active = model.id == *current_model_id;\n            let item_id = format!(\"model_select:{}\", model.id);\n            let item =\n                CheckMenuItem::with_id(app, &item_id, &model.name, true, is_active, None::<&str>)\n                    .expect(\"failed to create model item\");\n            let _ = submenu.append(&item);\n        }\n\n        submenu\n    };\n\n    let unload_model_i = MenuItem::with_id(\n        app,\n        \"unload_model\",\n        &strings.unload_model,\n        model_loaded,\n        None::<&str>,\n    )\n    .expect(\"failed to create unload model item\");\n\n    let menu = match state {\n        TrayIconState::Recording | TrayIconState::Transcribing => {\n            let cancel_i = MenuItem::with_id(app, \"cancel\", &strings.cancel, true, None::<&str>)\n                .expect(\"failed to create cancel item\");\n            Menu::with_items(\n                app,\n                &[\n                    &version_i,\n                    &separator(),\n                    &cancel_i,\n                    &separator(),\n                    &copy_last_transcript_i,\n                    &separator(),\n                    &settings_i,\n                    &check_updates_i,\n                    &separator(),\n                    &quit_i,\n                ],\n            )\n            .expect(\"failed to create menu\")\n        }\n        TrayIconState::Idle => Menu::with_items(\n            app,\n            &[\n                &version_i,\n                &separator(),\n                &copy_last_transcript_i,\n                &separator(),\n                &model_submenu,\n                &unload_model_i,\n                &separator(),\n                &settings_i,\n                &check_updates_i,\n                &separator(),\n                &quit_i,\n            ],\n        )\n        .expect(\"failed to create menu\"),\n    };\n\n    let tray = app.state::<TrayIcon>();\n    let _ = tray.set_menu(Some(menu));\n    let _ = tray.set_icon_as_template(true);\n}\n\nfn last_transcript_text(entry: &HistoryEntry) -> &str {\n    entry\n        .post_processed_text\n        .as_deref()\n        .unwrap_or(&entry.transcription_text)\n}\n\npub fn set_tray_visibility(app: &AppHandle, visible: bool) {\n    let tray = app.state::<TrayIcon>();\n    if let Err(e) = tray.set_visible(visible) {\n        error!(\"Failed to set tray visibility: {}\", e);\n    } else {\n        info!(\"Tray visibility set to: {}\", visible);\n    }\n}\n\npub fn copy_last_transcript(app: &AppHandle) {\n    let history_manager = app.state::<Arc<HistoryManager>>();\n    let entry = match history_manager.get_latest_entry() {\n        Ok(Some(entry)) => entry,\n        Ok(None) => {\n            warn!(\"No transcription history entries available for tray copy.\");\n            return;\n        }\n        Err(err) => {\n            error!(\"Failed to fetch last transcription entry: {}\", err);\n            return;\n        }\n    };\n\n    if let Err(err) = app.clipboard().write_text(last_transcript_text(&entry)) {\n        error!(\"Failed to copy last transcript to clipboard: {}\", err);\n        return;\n    }\n\n    info!(\"Copied last transcript to clipboard via tray.\");\n}\n\n#[cfg(test)]\nmod tests {\n    use super::last_transcript_text;\n    use crate::managers::history::HistoryEntry;\n\n    fn build_entry(transcription: &str, post_processed: Option<&str>) -> HistoryEntry {\n        HistoryEntry {\n            id: 1,\n            file_name: \"handy-1.wav\".to_string(),\n            timestamp: 0,\n            saved: false,\n            title: \"Recording\".to_string(),\n            transcription_text: transcription.to_string(),\n            post_processed_text: post_processed.map(|text| text.to_string()),\n            post_process_prompt: None,\n        }\n    }\n\n    #[test]\n    fn uses_post_processed_text_when_available() {\n        let entry = build_entry(\"raw\", Some(\"processed\"));\n        assert_eq!(last_transcript_text(&entry), \"processed\");\n    }\n\n    #[test]\n    fn falls_back_to_raw_transcription() {\n        let entry = build_entry(\"raw\", None);\n        assert_eq!(last_transcript_text(&entry), \"raw\");\n    }\n}\n"
  },
  {
    "path": "src-tauri/src/tray_i18n.rs",
    "content": "//! Tray menu internationalization\n//!\n//! Everything is auto-generated at compile time by build.rs from the\n//! frontend locale files (src/i18n/locales/*/translation.json).\n//!\n//! The English translation.json is the single source of truth:\n//! - TrayStrings struct fields are derived from the English \"tray\" keys\n//! - All languages are auto-discovered from the locales directory\n//!\n//! To add a new tray menu item:\n//! 1. Add the key to en/translation.json under \"tray\"\n//! 2. Add translations to other locale files\n//! 3. Update tray.rs to use the new field (e.g., strings.new_field)\n\nuse once_cell::sync::Lazy;\nuse std::collections::HashMap;\n\n// Include the auto-generated TrayStrings struct and TRANSLATIONS static\ninclude!(concat!(env!(\"OUT_DIR\"), \"/tray_translations.rs\"));\n\n/// Get localized tray menu strings based on the system locale.\n///\n/// Lookup order: full locale (e.g. \"zh-TW\") → language code (\"zh\") → English.\npub fn get_tray_translations(locale: Option<String>) -> TrayStrings {\n    let locale_str = locale.as_deref().unwrap_or(\"en\");\n    let lang_code = locale_str.split(['-', '_']).next().unwrap_or(\"en\");\n\n    TRANSLATIONS\n        .get(locale_str)\n        .or_else(|| TRANSLATIONS.get(lang_code))\n        .or_else(|| TRANSLATIONS.get(\"en\"))\n        .cloned()\n        .expect(\"English translations must exist\")\n}\n"
  },
  {
    "path": "src-tauri/src/utils.rs",
    "content": "use crate::managers::audio::AudioRecordingManager;\nuse crate::managers::transcription::TranscriptionManager;\nuse crate::shortcut;\nuse crate::TranscriptionCoordinator;\nuse log::info;\nuse std::sync::Arc;\nuse tauri::{AppHandle, Manager};\n\n// Re-export all utility modules for easy access\n// pub use crate::audio_feedback::*;\npub use crate::clipboard::*;\npub use crate::overlay::*;\npub use crate::tray::*;\n\n/// Centralized cancellation function that can be called from anywhere in the app.\n/// Handles cancelling both recording and transcription operations and updates UI state.\npub fn cancel_current_operation(app: &AppHandle) {\n    info!(\"Initiating operation cancellation...\");\n\n    // Unregister the cancel shortcut asynchronously\n    shortcut::unregister_cancel_shortcut(app);\n\n    // Cancel any ongoing recording\n    let audio_manager = app.state::<Arc<AudioRecordingManager>>();\n    let recording_was_active = audio_manager.is_recording();\n    audio_manager.cancel_recording();\n\n    // Update tray icon and hide overlay\n    change_tray_icon(app, crate::tray::TrayIconState::Idle);\n    hide_recording_overlay(app);\n\n    // Unload model if immediate unload is enabled\n    let tm = app.state::<Arc<TranscriptionManager>>();\n    tm.maybe_unload_immediately(\"cancellation\");\n\n    // Notify coordinator so it can keep lifecycle state coherent.\n    if let Some(coordinator) = app.try_state::<TranscriptionCoordinator>() {\n        coordinator.notify_cancel(recording_was_active);\n    }\n\n    info!(\"Operation cancellation completed - returned to idle state\");\n}\n\n/// Check if using the Wayland display server protocol\n#[cfg(target_os = \"linux\")]\npub fn is_wayland() -> bool {\n    std::env::var(\"WAYLAND_DISPLAY\").is_ok()\n        || std::env::var(\"XDG_SESSION_TYPE\")\n            .map(|v| v.to_lowercase() == \"wayland\")\n            .unwrap_or(false)\n}\n\n/// Check if running on KDE Plasma desktop environment\n#[cfg(target_os = \"linux\")]\npub fn is_kde_plasma() -> bool {\n    std::env::var(\"XDG_CURRENT_DESKTOP\")\n        .map(|v| v.to_uppercase().contains(\"KDE\"))\n        .unwrap_or(false)\n        || std::env::var(\"KDE_SESSION_VERSION\").is_ok()\n}\n\n/// Check if running on KDE Plasma with Wayland\n#[cfg(target_os = \"linux\")]\npub fn is_kde_wayland() -> bool {\n    is_wayland() && is_kde_plasma()\n}\n"
  },
  {
    "path": "src-tauri/swift/apple_intelligence.swift",
    "content": "import Dispatch\nimport Foundation\nimport FoundationModels\n\n@available(macOS 26.0, *)\n@Generable\nprivate struct CleanedTranscript: Sendable {\n    let cleanedText: String\n}\n\n// MARK: - Swift implementation for Apple LLM integration\n// This file is compiled via Cargo build script for Apple Silicon targets\n\nprivate typealias ResponsePointer = UnsafeMutablePointer<AppleLLMResponse>\n\nprivate func duplicateCString(_ text: String) -> UnsafeMutablePointer<CChar>? {\n    return text.withCString { basePointer in\n        guard let duplicated = strdup(basePointer) else {\n            return nil\n        }\n        return duplicated\n    }\n}\n\nprivate func truncatedText(_ text: String, limit: Int) -> String {\n    guard limit > 0 else { return text }\n    let words = text.split(\n        maxSplits: .max,\n        omittingEmptySubsequences: true,\n        whereSeparator: { $0.isWhitespace || $0.isNewline }\n    )\n    if words.count <= limit {\n        return text\n    }\n    return words.prefix(limit).joined(separator: \" \")\n}\n\n@_cdecl(\"is_apple_intelligence_available\")\npublic func isAppleIntelligenceAvailable() -> Int32 {\n    guard #available(macOS 26.0, *) else {\n        return 0\n    }\n\n    let model = SystemLanguageModel.default\n    switch model.availability {\n    case .available:\n        return 1\n    case .unavailable:\n        return 0\n    }\n}\n\n@_cdecl(\"process_text_with_system_prompt_apple\")\npublic func processTextWithSystemPrompt(\n    _ systemPrompt: UnsafePointer<CChar>,\n    _ userContent: UnsafePointer<CChar>,\n    maxTokens: Int32\n) -> UnsafeMutablePointer<AppleLLMResponse> {\n    let swiftSystemPrompt = String(cString: systemPrompt)\n    let swiftUserContent = String(cString: userContent)\n    let responsePtr = ResponsePointer.allocate(capacity: 1)\n    responsePtr.initialize(to: AppleLLMResponse(response: nil, success: 0, error_message: nil))\n\n    guard #available(macOS 26.0, *) else {\n        responsePtr.pointee.error_message = duplicateCString(\n            \"Apple Intelligence requires macOS 26 or newer.\"\n        )\n        return responsePtr\n    }\n\n    let model = SystemLanguageModel.default\n    guard model.availability == .available else {\n        responsePtr.pointee.error_message = duplicateCString(\n            \"Apple Intelligence is not currently available on this device.\"\n        )\n        return responsePtr\n    }\n\n    let tokenLimit = max(0, Int(maxTokens))\n    let semaphore = DispatchSemaphore(value: 0)\n\n    // Thread-safe container to pass results from async task back to calling thread\n    final class ResultBox: @unchecked Sendable {\n        var response: String?\n        var error: String?\n    }\n    let box = ResultBox()\n\n    Task.detached(priority: .userInitiated) {\n        defer { semaphore.signal() }\n        do {\n            let session = LanguageModelSession(\n                model: model,\n                instructions: swiftSystemPrompt\n            )\n            var output: String\n\n            do {\n                let structured = try await session.respond(\n                    to: swiftUserContent,\n                    generating: CleanedTranscript.self\n                )\n                output = structured.content.cleanedText\n            } catch {\n                let fallbackGeneration = try await session.respond(to: swiftUserContent)\n                output = fallbackGeneration.content\n            }\n\n            if tokenLimit > 0 {\n                output = truncatedText(output, limit: tokenLimit)\n            }\n            box.response = output\n        } catch {\n            box.error = error.localizedDescription\n        }\n    }\n\n    semaphore.wait()\n\n    // Write to responsePtr on the calling thread after task completes\n    if let response = box.response {\n        responsePtr.pointee.response = duplicateCString(response)\n        responsePtr.pointee.success = 1\n    } else {\n        responsePtr.pointee.error_message = duplicateCString(box.error ?? \"Unknown error\")\n    }\n\n    return responsePtr\n}\n\n@_cdecl(\"free_apple_llm_response\")\npublic func freeAppleLLMResponse(_ response: UnsafeMutablePointer<AppleLLMResponse>?) {\n    guard let response = response else { return }\n\n    if let responseStr = response.pointee.response {\n        free(UnsafeMutablePointer(mutating: responseStr))\n    }\n\n    if let errorStr = response.pointee.error_message {\n        free(UnsafeMutablePointer(mutating: errorStr))\n    }\n\n    response.deallocate()\n}"
  },
  {
    "path": "src-tauri/swift/apple_intelligence_bridge.h",
    "content": "#ifndef apple_intelligence_bridge_h\n#define apple_intelligence_bridge_h\n\n// C-compatible function declarations for Swift bridge\n\n#ifdef __cplusplus\nextern \"C\" {\n#endif\n\ntypedef struct {\n    char* response;\n    int success; // 0 for failure, 1 for success\n    char* error_message; // Only valid when success = 0\n} AppleLLMResponse;\n\n// Check if Apple Intelligence is available on the device\nint is_apple_intelligence_available(void);\n\n// Process text using Apple's on-device LLM with separate system prompt and user content\nAppleLLMResponse* process_text_with_system_prompt_apple(const char* system_prompt, const char* user_content, int max_tokens);\n\n// Free memory allocated by the Apple LLM response\nvoid free_apple_llm_response(AppleLLMResponse* response);\n\n#ifdef __cplusplus\n}\n#endif\n\n#endif /* apple_intelligence_bridge_h */"
  },
  {
    "path": "src-tauri/swift/apple_intelligence_stub.swift",
    "content": "import Foundation\n\n// Stub implementation when FoundationModels is not available\n// This file is compiled via Cargo build script when the build environment\n// does not support Apple Intelligence (e.g. older Xcode/SDK).\n\nprivate typealias ResponsePointer = UnsafeMutablePointer<AppleLLMResponse>\n\n@_cdecl(\"is_apple_intelligence_available\")\npublic func isAppleIntelligenceAvailable() -> Int32 {\n    return 0\n}\n\n@_cdecl(\"process_text_with_system_prompt_apple\")\npublic func processTextWithSystemPrompt(\n    _ systemPrompt: UnsafePointer<CChar>,\n    _ userContent: UnsafePointer<CChar>,\n    maxTokens: Int32\n) -> UnsafeMutablePointer<AppleLLMResponse> {\n    let responsePtr = ResponsePointer.allocate(capacity: 1)\n    // Initialize with safe defaults\n    responsePtr.initialize(to: AppleLLMResponse(response: nil, success: 0, error_message: nil))\n    \n    let msg = \"Apple Intelligence is not available in this build (SDK requirement not met).\"\n    \n    // Duplicate the string for the C caller to own\n    responsePtr.pointee.error_message = strdup(msg)\n    \n    return responsePtr\n}\n\n@_cdecl(\"free_apple_llm_response\")\npublic func freeAppleLLMResponse(_ response: UnsafeMutablePointer<AppleLLMResponse>?) {\n    guard let response = response else { return }\n    \n    if let responseStr = response.pointee.response {\n        free(UnsafeMutablePointer(mutating: responseStr))\n    }\n    \n    if let errorStr = response.pointee.error_message {\n        free(UnsafeMutablePointer(mutating: errorStr))\n    }\n    \n    response.deallocate()\n}\n"
  },
  {
    "path": "src-tauri/tauri.conf.json",
    "content": "{\n  \"$schema\": \"https://schema.tauri.app/config/2\",\n  \"productName\": \"Handy\",\n  \"version\": \"0.7.12\",\n  \"identifier\": \"com.pais.handy\",\n  \"build\": {\n    \"beforeDevCommand\": \"bun run dev\",\n    \"devUrl\": \"http://localhost:1420\",\n    \"beforeBuildCommand\": \"bun run build\",\n    \"frontendDist\": \"../dist\"\n  },\n  \"app\": {\n    \"macOSPrivateApi\": true,\n    \"windows\": [],\n    \"security\": {\n      \"csp\": null,\n      \"assetProtocol\": {\n        \"enable\": true,\n        \"scope\": {\n          \"allow\": [\"**\"],\n          \"requireLiteralLeadingDot\": false\n        }\n      }\n    }\n  },\n  \"bundle\": {\n    \"active\": true,\n    \"createUpdaterArtifacts\": true,\n    \"targets\": \"all\",\n    \"resources\": [\"resources/**/*\"],\n    \"license\": \"MIT\",\n    \"icon\": [\n      \"icons/32x32.png\",\n      \"icons/128x128.png\",\n      \"icons/128x128@2x.png\",\n      \"icons/icon.icns\",\n      \"icons/icon.ico\"\n    ],\n    \"macOS\": {\n      \"files\": {},\n      \"hardenedRuntime\": true,\n      \"minimumSystemVersion\": \"10.15\",\n      \"signingIdentity\": \"-\",\n      \"entitlements\": \"Entitlements.plist\"\n    },\n    \"linux\": {\n      \"deb\": {\n        \"depends\": [\"libgtk-layer-shell0\"]\n      },\n      \"rpm\": {\n        \"compression\": {\n          \"type\": \"none\"\n        }\n      },\n      \"appimage\": {\n        \"bundleMediaFramework\": true,\n        \"files\": {}\n      }\n    },\n    \"windows\": {\n      \"signCommand\": \"trusted-signing-cli -e https://eus.codesigning.azure.net/ -a CJ-Signing -c cjpais-dev -d Handy %1\",\n      \"nsis\": {\n        \"template\": \"nsis/installer.nsi\"\n      }\n    }\n  },\n  \"plugins\": {\n    \"updater\": {\n      \"pubkey\": \"dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEJBQjcyMDk1MjA2NjAxRjkKUldUNUFXWWdsU0MzdXRRZi8zYzhqV2FaNUVDbDd2Rk5VM1IvWWowVXdmRFNKQ1BrMXF5RFFsLy8K\",\n      \"endpoints\": [\n        \"https://github.com/cjpais/Handy/releases/latest/download/latest.json\"\n      ]\n    }\n  }\n}\n"
  },
  {
    "path": "tailwind.config.js",
    "content": "/** @type {import('tailwindcss').Config} */\nexport default {\n  content: [\"./index.html\", \"./src/**/*.{js,ts,jsx,tsx}\"],\n  theme: {\n    extend: {\n      colors: {\n        text: \"var(--color-text)\",\n        background: \"var(--color-background)\",\n        \"logo-primary\": \"var(--color-logo-primary)\",\n        \"logo-stroke\": \"var(--color-logo-stroke)\",\n        \"text-stroke\": \"var(--color-text-stroke)\",\n      },\n    },\n  },\n  plugins: [],\n};\n"
  },
  {
    "path": "tests/app.spec.ts",
    "content": "import { test, expect } from \"@playwright/test\";\n\ntest.describe(\"Handy App\", () => {\n  test(\"dev server responds\", async ({ page }) => {\n    // Just verify the dev server is running and responds\n    const response = await page.goto(\"/\");\n    expect(response?.status()).toBe(200);\n  });\n\n  test(\"page has html structure\", async ({ page }) => {\n    await page.goto(\"/\");\n\n    // Verify basic HTML structure exists\n    const html = await page.content();\n    expect(html).toContain(\"<html\");\n    expect(html).toContain(\"<body\");\n  });\n});\n"
  },
  {
    "path": "tsconfig.json",
    "content": "{\n  \"compilerOptions\": {\n    \"target\": \"ES2020\",\n    \"useDefineForClassFields\": true,\n    \"lib\": [\"ES2020\", \"DOM\", \"DOM.Iterable\"],\n    \"types\": [\"node\"],\n    \"module\": \"ESNext\",\n    \"skipLibCheck\": true,\n\n    /* Bundler mode */\n    \"moduleResolution\": \"bundler\",\n    \"allowImportingTsExtensions\": true,\n    \"resolveJsonModule\": true,\n    \"isolatedModules\": true,\n    \"noEmit\": true,\n    \"jsx\": \"react-jsx\",\n\n    /* Path Aliases */\n    \"baseUrl\": \".\",\n    \"paths\": {\n      \"@/*\": [\"./src/*\"],\n      \"@/bindings\": [\"./src/bindings.ts\"]\n    },\n\n    /* Linting */\n    \"strict\": true,\n    \"noUnusedLocals\": false,\n    \"noUnusedParameters\": false,\n    \"noFallthroughCasesInSwitch\": true\n  },\n  \"include\": [\"src\"],\n  \"references\": [{ \"path\": \"./tsconfig.node.json\" }]\n}\n"
  },
  {
    "path": "tsconfig.node.json",
    "content": "{\n  \"compilerOptions\": {\n    \"composite\": true,\n    \"skipLibCheck\": true,\n    \"module\": \"ESNext\",\n    \"moduleResolution\": \"bundler\",\n    \"allowSyntheticDefaultImports\": true\n  },\n  \"include\": [\"vite.config.ts\"]\n}\n"
  },
  {
    "path": "vite.config.ts",
    "content": "import { defineConfig } from \"vite\";\nimport react from \"@vitejs/plugin-react\";\nimport tailwindcss from \"@tailwindcss/vite\";\nimport { resolve } from \"path\";\n\nconst host = process.env.TAURI_DEV_HOST;\n\n// https://vitejs.dev/config/\nexport default defineConfig(async () => ({\n  plugins: [react(), tailwindcss()],\n\n  // Path aliases\n  resolve: {\n    alias: {\n      \"@\": resolve(__dirname, \"./src\"),\n      \"@/bindings\": resolve(__dirname, \"./src/bindings.ts\"),\n    },\n  },\n\n  // Multiple entry points for main app and overlay\n  build: {\n    rollupOptions: {\n      input: {\n        main: resolve(__dirname, \"index.html\"),\n        overlay: resolve(__dirname, \"src/overlay/index.html\"),\n      },\n    },\n  },\n\n  // Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`\n  //\n  // 1. prevent vite from obscuring rust errors\n  clearScreen: false,\n  // 2. tauri expects a fixed port, fail if that port is not available\n  server: {\n    port: 1420,\n    strictPort: true,\n    host: host || false,\n    hmr: host\n      ? {\n          protocol: \"ws\",\n          host,\n          port: 1421,\n        }\n      : undefined,\n    watch: {\n      // 3. tell vite to ignore watching `src-tauri`\n      ignored: [\"**/src-tauri/**\"],\n    },\n  },\n}));\n"
  }
]